"""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 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, ), ), ) _REGISTRY = { "Cone Sparks": _CONE_SPARKS, "Line Sparks": _LINE_SPARKS, "Burst Sparks": _BURST_SPARKS, } 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"], spell_slots={1: 9, 2: 9, 3: 9}, ), "players", (6, 2), ) # --------------------------------------------------------------------------- # 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_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) not in cells 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: unchanged circle around the resolved center # --------------------------------------------------------------------------- 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 # --------------------------------------------------------------------------- # 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)