Files

519 lines
20 KiB
Python

"""Cone and line spell areas (Package S1a).
PF1e (CRB, Magic - Aiming a Spell): a cone "shoots away from you in a
quarter-circle in the direction you designate"; a line "shoots away from you
in a line in the direction you designate" and "affects all creatures in
squares through which the line passes". Both originate at the CASTER and are
aimed via ``cast_spell``'s existing ``target_id`` / ``target_pos``.
Factories are reused from ``tests/test_observation_enriched.py``; every roll
is scripted. The cone spell is save-less (save="none", save_for_half=False)
so each affected creature consumes exactly its damage dice — the scripted
queues also pin the EXACT set of creatures caught in each template.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from pf1e_simulator.areas import cone_cells, line_cells
from pf1e_simulator.combat import Action, CombatantState, CombatEngine
from pf1e_simulator.dice import parse_dice
from pf1e_simulator.grid import Grid
from pf1e_simulator.map import MapSpec, Pos, TerrainType
from pf1e_simulator.rng import ScriptedRng
from pf1e_simulator.spells import (
AreaEffect,
DamageEffect,
SpellSpec,
load_spell,
)
from tests.test_observation_enriched import make_combatant, make_state
if TYPE_CHECKING:
from pathlib import Path
_CONE_SPARKS = SpellSpec(
name="Cone Sparks",
level=1,
school="evocation",
range="close",
save="none",
base_dc=10,
effects=(
AreaEffect(
shape="cone",
radius_ft=15,
effects=(
DamageEffect(formula=parse_dice("1d6"), types=("fire",)),
),
save_for_half=False,
),
),
)
_LINE_SPARKS = SpellSpec(
name="Line Sparks",
level=2,
school="evocation",
range="long",
save="none",
base_dc=10,
effects=(
AreaEffect(
shape="line",
radius_ft=120,
effects=(
DamageEffect(formula=parse_dice("1d6"), types=("electricity",)),
),
save_for_half=False,
),
),
)
_BURST_SPARKS = SpellSpec(
name="Burst Sparks",
level=3,
school="evocation",
range="long",
save="none",
base_dc=10,
effects=(
AreaEffect(
radius_ft=20,
effects=(
DamageEffect(formula=parse_dice("1d6"), types=("fire",)),
),
save_for_half=False,
),
),
)
_BURST_GUST = SpellSpec(
name="Burst Gust",
level=3,
school="evocation",
range="long",
save="ref",
base_dc=10,
effects=(
AreaEffect(
radius_ft=20,
effects=(
DamageEffect(
formula=parse_dice("1d6"), types=("fire",), half_on_save=True
),
),
save_for_half=True,
),
),
)
_REGISTRY = {
"Cone Sparks": _CONE_SPARKS,
"Line Sparks": _LINE_SPARKS,
"Burst Sparks": _BURST_SPARKS,
"Burst Gust": _BURST_GUST,
}
def _open_grid(side: int = 12) -> Grid:
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(name="open", terrain=tuple(["." * side] * side), legend=legend)
return Grid.from_spec(spec)
def _walled_grid(walls: frozenset[Pos], side: int = 12) -> Grid:
"""Open ``side``x``side`` floor with impassable walls at ``walls``."""
legend = {
".": TerrainType(type="floor", move_cost=1),
"#": TerrainType(type="wall", move_cost=None, blocks_los=True),
}
rows = tuple(
"".join("#" if (r, c) in walls else "." for c in range(side))
for r in range(side)
)
spec = MapSpec(name="walled", terrain=rows, legend=legend)
return Grid.from_spec(spec)
def _engine(states: list[CombatantState], rolls: list[int]) -> CombatEngine:
return CombatEngine(
ScriptedRng(rolls), _open_grid(), states, spell_registry=_REGISTRY
)
def _caster(hp: int = 100) -> CombatantState:
return make_state(
make_combatant(
"caster",
hp=hp,
spells=["Cone Sparks", "Line Sparks", "Burst Sparks", "Burst Gust"],
spell_slots={1: 9, 2: 9, 3: 9},
),
"players",
(6, 2),
)
def _cover_grid(cover_cells: dict[Pos, int], side: int = 12) -> Grid:
"""Open floor with terrain-only cover cells; the char encodes the bonus."""
legend = {".": TerrainType(type="floor", move_cost=1)}
for bonus in cover_cells.values():
legend[str(bonus)] = TerrainType(
type=f"cover_{bonus}", move_cost=1, cover_bonus=bonus
)
rows = tuple(
"".join(
str(cover_cells[(r, c)]) if (r, c) in cover_cells else "."
for c in range(side)
)
for r in range(side)
)
spec = MapSpec(name="cover", terrain=rows, legend=legend)
return Grid.from_spec(spec)
# ---------------------------------------------------------------------------
# cone: 15 ft in front of the caster, ±45° wide, origin cell excluded
# ---------------------------------------------------------------------------
def test_cone_hits_cells_in_front_and_not_behind() -> None:
"""Given: caster (6,2); foe_front (6,4) east, foe_behind (6,0) west,
foe_side (2,2) north, foe_far (6,7) east beyond the 15-ft reach
When: cast a 15-ft cone aimed at foe_front (target_id)
Then: only foe_front (in front, within 3 cells) is hit; the behind/side
foes fall outside the ±45° quarter circle and foe_far exceeds the length.
The caster's own cell (the origin) is excluded by the cone template."""
caster = _caster()
foe_front = make_state(make_combatant("foe_front", hp=100), "monsters", (6, 4))
foe_behind = make_state(make_combatant("foe_behind", hp=100), "monsters", (6, 0))
foe_side = make_state(make_combatant("foe_side", hp=100), "monsters", (2, 2))
foe_far = make_state(make_combatant("foe_far", hp=100), "monsters", (6, 7))
eng = _engine([caster, foe_front, foe_behind, foe_side, foe_far], [4])
result = eng._execute(
caster, Action(kind="cast_spell", target_id="foe_front", spell_name="Cone Sparks")
)
assert result.ok
assert foe_front.hp == 100 - 4
assert foe_behind.hp == 100
assert foe_side.hp == 100
assert foe_far.hp == 100
assert caster.hp == 100 # origin cell excluded — the caster is never caught
def test_cone_covers_the_full_90_degree_arc() -> None:
"""Given: caster (6,2) aiming east; foes on the straight axis (6,4), the
45° edge (4,4) and just off the edge (3,4)
When: the 15-ft cone resolves
Then: the axis and the 45° edge cell are inside the quarter circle; the
off-edge cell (beyond ±45°) is untouched."""
caster = _caster()
foe_axis = make_state(make_combatant("foe_axis", hp=100), "monsters", (6, 4))
foe_edge = make_state(make_combatant("foe_edge", hp=100), "monsters", (4, 4))
foe_outside = make_state(make_combatant("foe_outside", hp=100), "monsters", (3, 4))
eng = _engine([caster, foe_axis, foe_edge, foe_outside], [4, 4, 4])
eng._execute(
caster, Action(kind="cast_spell", target_id="foe_axis", spell_name="Cone Sparks")
)
assert foe_axis.hp == 100 - 4
assert foe_edge.hp == 100 - 4
assert foe_outside.hp == 100
def test_cone_cells_require_line_of_effect_from_the_origin() -> None:
"""Given: a wall column at col 4 (rows 4-8) between origin (6,2) and the
cell (6,5) that sits on the cone axis at distance 3
When: cone_cells builds a 3-cell east cone
Then: (6,5) is dropped - geometrically inside but without line of effect
from the origin (CRB, Line of Effect: a cone "affects only an area,
creature, or object to which it has line of effect from its origin");
on the same geometry without walls it stays in the template."""
walls = frozenset({(4, 4), (5, 4), (6, 4), (7, 4), (8, 4)})
grid = _walled_grid(walls)
cells = cone_cells(grid, (6, 2), (6, 3), 3)
assert (6, 3) in cells
assert (6, 4) in cells # the cone reaches the barrier square itself
assert (6, 5) not in cells
open_cells = cone_cells(_open_grid(), (6, 2), (6, 3), 3)
assert (6, 5) in open_cells # control: nothing else filters it out
def test_cone_behind_a_solid_wall_does_not_hit_sheltered_creatures() -> None:
"""Given: caster (6,2) aiming the 15-ft cone east at foe_front (6,3); a
wall column at col 4 (rows 4-8); foe_behind (6,5) sits on the cone axis
behind that wall
When: the cone resolves
Then: only foe_front is caught (one 1d6 consumed after the adjacent
foe_front's scripted -4 AoO d20 misses); foe_behind is untouched - the
cone stops at the solid barrier, it does not pass through it."""
caster = _caster()
walls = frozenset({(4, 4), (5, 4), (6, 4), (7, 4), (8, 4)})
foe_front = make_state(make_combatant("foe_front", hp=100), "monsters", (6, 3))
foe_behind = make_state(make_combatant("foe_behind", hp=100), "monsters", (6, 5))
eng = CombatEngine(
ScriptedRng([4, 4]), _walled_grid(walls), [caster, foe_front, foe_behind],
spell_registry=_REGISTRY,
)
result = eng._execute(
caster, Action(kind="cast_spell", target_id="foe_front", spell_name="Cone Sparks")
)
assert result.ok
assert foe_front.hp == 100 - 4
assert foe_behind.hp == 100
def test_cone_directed_by_target_pos_point_cast() -> None:
"""Given: the same scene, no target_id — the cone is aimed at an explicit
cell (6,5) via target_pos (U7b point cast)
When: the point cast resolves
Then: the direction is caster -> point; the foe at (6,4) on that bearing
is caught, the north foe is not."""
caster = _caster()
foe_in = make_state(make_combatant("foe_in", hp=100), "monsters", (6, 4))
foe_out = make_state(make_combatant("foe_out", hp=100), "monsters", (2, 2))
eng = _engine([caster, foe_in, foe_out], [4])
result = eng._execute(
caster, Action(kind="cast_spell", spell_name="Cone Sparks", target_pos=(6, 5))
)
assert result.ok
assert foe_in.hp == 100 - 4
assert foe_out.hp == 100
# ---------------------------------------------------------------------------
# line: 120 ft straight ray, 5 ft wide — only the cells it passes through
# ---------------------------------------------------------------------------
def test_line_hits_the_straight_ray_only() -> None:
"""Given: caster (6,2) aiming east at foe_ray (6,5); foe_off (5,5) sits a
row away, foe_west (6,0) is behind the caster
When: cast the 120-ft line aimed at foe_ray
Then: every creature ON the ray beyond the caster's own square (foe_ray)
takes damage; the caster's own cell is NOT part of the line (CRB: it
"starts from any corner of your square"), and the off-row and the
behind-caster foes are outside the 5-ft-wide template."""
caster = _caster()
foe_ray = make_state(make_combatant("foe_ray", hp=100), "monsters", (6, 5))
foe_off = make_state(make_combatant("foe_off", hp=100), "monsters", (5, 5))
foe_west = make_state(make_combatant("foe_west", hp=100), "monsters", (6, 0))
eng = _engine([caster, foe_ray, foe_off, foe_west], [4, 4])
result = eng._execute(
caster, Action(kind="cast_spell", target_id="foe_ray", spell_name="Line Sparks")
)
assert result.ok
assert foe_ray.hp == 100 - 4
assert caster.hp == 100 # the caster's own square is outside the line
assert foe_off.hp == 100
assert foe_west.hp == 100
def test_line_follows_a_diagonal_bresenham_ray() -> None:
"""Given: caster (6,2) aiming diagonally at foe_diag (2,6)
When: the line resolves
Then: the diagonal ray cells are affected (foe_diag is hit) while a cell
beside the ray (foe_off at (2,5)) is not."""
caster = _caster()
foe_diag = make_state(make_combatant("foe_diag", hp=100), "monsters", (2, 6))
foe_off = make_state(make_combatant("foe_off", hp=100), "monsters", (2, 5))
eng = _engine([caster, foe_diag, foe_off], [4, 4])
eng._execute(
caster, Action(kind="cast_spell", target_id="foe_diag", spell_name="Line Sparks")
)
assert foe_diag.hp == 100 - 4
assert foe_off.hp == 100
def test_line_cells_stop_at_a_line_of_effect_barrier() -> None:
"""Given: a wall cell (6,6) on the east ray from origin (6,2)
When: line_cells builds a 10-cell template
Then: cells before the barrier are in the template; the barrier cell and
everything beyond it are not (CRB: the line "extends to the limit of its
range or until it strikes a barrier that blocks line of effect")."""
grid = _walled_grid(frozenset({(6, 6)}))
cells = line_cells(grid, (6, 2), (6, 8), 10)
assert (6, 5) in cells
assert (6, 6) in cells # the bolt strikes the barrier square itself
assert (6, 7) not in cells
assert (6, 8) not in cells
def test_line_behind_a_wall_does_not_hit_sheltered_creatures() -> None:
"""Given: caster (6,2) aiming east; a wall column at col 6 (rows 4-8);
foe_front (6,4) on the near side, foe_behind (6,8) fully behind the wall
When: cast the 120-ft line (24 cells - long enough to cross the map)
Then: the bolt stops at the barrier: only foe_front is caught (one 1d6
consumed); foe_behind is untouched (SRD Lightning Bolt cannot reach
around or through a solid wall)."""
caster = _caster()
walls = frozenset({(4, 6), (5, 6), (6, 6), (7, 6), (8, 6)})
foe_front = make_state(make_combatant("foe_front", hp=100), "monsters", (6, 4))
foe_behind = make_state(make_combatant("foe_behind", hp=100), "monsters", (6, 8))
eng = CombatEngine(
ScriptedRng([4]), _walled_grid(walls), [caster, foe_front, foe_behind],
spell_registry=_REGISTRY,
)
result = eng._execute(
caster, Action(kind="cast_spell", target_id="foe_front", spell_name="Line Sparks")
)
assert result.ok
assert foe_front.hp == 100 - 4
assert foe_behind.hp == 100
# ---------------------------------------------------------------------------
# burst: circle around the center, minus creatures in total cover of it
# ---------------------------------------------------------------------------
def test_burst_does_not_extend_around_a_solid_barrier() -> None:
"""Given: a 20-ft burst point-cast at center (6,3); wall cells at col 5
(rows 5-7); foe_open (4,3) inside the radius with clear LoE, foe_behind
(6,7) inside the radius but behind the wall (total cover from the
center)
When: the burst resolves (caster at (6,2) is in the radius too)
Then: the caster and foe_open take damage (two 1d6 consumed); foe_behind
is untouched - a burst "can't affect creatures with total cover from its
point of origin" (its effects don't extend around corners)."""
caster = _caster()
walls = frozenset({(5, 5), (6, 5), (7, 5)})
foe_open = make_state(make_combatant("foe_open", hp=100), "monsters", (4, 3))
foe_behind = make_state(make_combatant("foe_behind", hp=100), "monsters", (6, 7))
eng = CombatEngine(
ScriptedRng([4, 4]), _walled_grid(walls), [caster, foe_open, foe_behind],
spell_registry=_REGISTRY,
)
result = eng._execute(
caster, Action(kind="cast_spell", spell_name="Burst Sparks", target_pos=(6, 3))
)
assert result.ok
assert caster.hp == 100 - 4
assert foe_open.hp == 100 - 4
assert foe_behind.hp == 100
def test_burst_open_field_control_hits_creatures_behind_the_center() -> None:
"""Given: the same layout on an OPEN grid (no walls)
When: the 20-ft burst point-cast at (6,3) resolves
Then: all three creatures (caster, foe_open, foe_behind at distance 4)
are inside the radius and affected - the LoE filter drops nothing when
there is no barrier."""
caster = _caster()
foe_open = make_state(make_combatant("foe_open", hp=100), "monsters", (4, 3))
foe_behind = make_state(make_combatant("foe_behind", hp=100), "monsters", (6, 7))
eng = _engine([caster, foe_open, foe_behind], [4, 4, 4])
eng._execute(
caster, Action(kind="cast_spell", spell_name="Burst Sparks", target_pos=(6, 3))
)
assert caster.hp == 100 - 4
assert foe_open.hp == 100 - 4
assert foe_behind.hp == 100 - 4
def test_burst_still_centers_on_the_target_and_ignores_the_caster_side() -> None:
"""Given: caster (6,2); foe_in (4,4) and foe_out (2,0) around a burst
centered on target_id=foe_in (radius 20 ft = 4 cells)
When: the burst cast resolves
Then: the circle around the TARGET cell decides — foe_in (distance 0)
and the caster (distance 3) are inside, foe_out (distance 5) is not."""
caster = _caster()
foe_in = make_state(make_combatant("foe_in", hp=100), "monsters", (4, 4))
foe_out = make_state(make_combatant("foe_out", hp=100), "monsters", (2, 0))
eng = _engine([caster, foe_in, foe_out], [4, 4, 4])
eng._execute(
caster, Action(kind="cast_spell", target_id="foe_in", spell_name="Burst Sparks")
)
assert foe_in.hp == 100 - 4
assert caster.hp == 100 - 4
assert foe_out.hp == 100
# ---------------------------------------------------------------------------
# cover and Reflex saves (CRB "Cover and Reflex Saves")
# ---------------------------------------------------------------------------
def test_improved_cover_grants_reflex_bonus_against_a_burst() -> None:
"""Given: a Reflex-for-half burst point-cast at (6,6); an improved-cover
cell (+8, cover_bonus 8, no LoS block) at (6,7) between the center and
foe_cover (6,8); foe_open (4,6) inside the radius with no cover; both
roll d20=10 with a +0 Reflex bonus against DC 13
When: the burst resolves
Then: foe_cover's Reflex total is 10+4=14 (the +8 cover grants +2 on
Reflex, half the AC bonus) -> saves -> half of 4 = 2 damage; foe_open
totals 10 -> fails -> full 4 damage."""
caster = _caster()
caster.pos = (6, 0)
foe_cover = make_state(make_combatant("foe_cover", hp=100), "monsters", (6, 8))
foe_open = make_state(make_combatant("foe_open", hp=100), "monsters", (4, 6))
grid = _cover_grid({(6, 7): 8})
eng = CombatEngine(
ScriptedRng([10, 4, 10, 4]), grid, [caster, foe_cover, foe_open],
spell_registry=_REGISTRY,
)
result = eng._execute(
caster, Action(kind="cast_spell", spell_name="Burst Gust", target_pos=(6, 6))
)
assert result.ok
assert foe_cover.hp == 100 - 2
assert foe_open.hp == 100 - 4
assert caster.hp == 100 # distance 6 > 20 ft: outside the burst
def test_partial_cover_grants_plus_one_reflex_against_a_burst() -> None:
"""Given: the same Reflex-for-half burst, but the sheltering cell grants
partial cover (+2 AC) and foe_partial rolls d20=12 against DC 13 with a
+0 Reflex bonus
When: the burst resolves
Then: 12 + 1 (half of the +2 partial cover) = 13 meets the DC -> saves
-> half damage; the +2 Reflex a full-value bonus would grant is NOT
applied (CRB: partial cover is +2 AC / +1 Reflex)."""
caster = _caster()
caster.pos = (6, 0)
foe_partial = make_state(make_combatant("foe_partial", hp=100), "monsters", (6, 8))
grid = _cover_grid({(6, 7): 2})
eng = CombatEngine(
ScriptedRng([12, 4]), grid, [caster, foe_partial], spell_registry=_REGISTRY
)
result = eng._execute(
caster, Action(kind="cast_spell", spell_name="Burst Gust", target_pos=(6, 6))
)
assert result.ok
assert foe_partial.hp == 100 - 2
# ---------------------------------------------------------------------------
# loader: shape parsing
# ---------------------------------------------------------------------------
def test_loader_parses_cone_and_line_shapes(tmp_path: Path) -> None:
"""Given: area effects declaring "shape": "cone" / "line" / (absent)
When: load_spell parses them
Then: the shapes are carried on the AreaEffect; an absent shape defaults
to "burst"; an unknown shape is a data error."""
spell_file = tmp_path / "shapes.json"
spell_file.write_text(
'{"name": "Shapes", "level": 1, "school": "evocation",'
' "range": "close", "save": "ref", "effects": ['
' {"type": "area", "shape": "cone", "radius_ft": 15, "effects": []},'
' {"type": "area", "shape": "line", "radius_ft": 120, "effects": []},'
' {"type": "area", "radius_ft": 20, "effects": []}]}'
)
spell = load_spell(spell_file)
areas = [e for e in spell.effects if isinstance(e, AreaEffect)]
assert [a.shape for a in areas] == ["cone", "line", "burst"]
bad_file = tmp_path / "bad_shape.json"
bad_file.write_text(
'{"name": "Bad", "level": 1, "school": "evocation",'
' "range": "close", "save": "none", "effects": ['
' {"type": "area", "shape": "spiral", "radius_ft": 10, "effects": []}]}'
)
with pytest.raises(ValueError, match="unknown area shape"):
load_spell(bad_file)