"""Point-casting a cone/line does not require line of effect to the aim point. PF1e SRD "Aiming a Spell" (aiming-a-spell.md): a cone "shoots away from you in a quarter-circle in the direction you designate" and a line "shoots away from you in a line in the direction you designate" -- both "start from any corner of your square", so the aim point only names a DIRECTION from the caster's own space. The line-of-effect-to-the-point rule ("You must have a clear line of effect [...] to any space in which you wish to create an effect") binds targets and burst origins, not the direction a cone/line is pointed at; LoE is enforced by the template itself (the cone drops cells it cannot reach from its origin, the line stops at the first barrier). The engine therefore gates BURSTS on caster->point LoE and cones/lines on the point merely being in bounds. """ from __future__ import annotations from pf1e_simulator.combat import CombatantState, CombatEngine from pf1e_simulator.dice import parse_dice from pf1e_simulator.grid import Grid from pf1e_simulator.map import MapSpec, TerrainType from pf1e_simulator.rng import ScriptedRng from pf1e_simulator.spells import AreaEffect, AreaShape, DamageEffect, SpellSpec from tests.test_observation_enriched import make_combatant, make_state _FLOOR = TerrainType(type="floor", move_cost=1) _WALL = TerrainType(type="wall", move_cost=None, blocks_los=True) def _walled_grid() -> Grid: """8x8 open floor with a solid wall column at col 3 (rows 0..7).""" rows = tuple("." * 3 + "#" + "." * 4 for _ in range(8)) return Grid.from_spec( MapSpec(name="wall-col", terrain=rows, legend={".": _FLOOR, "#": _WALL}) ) def _area_spell(name: str, shape: AreaShape) -> SpellSpec: return SpellSpec( name=name, level=3, school="evocation", range="long", save="none", base_dc=10, effects=( AreaEffect( shape=shape, radius_ft=30, effects=( DamageEffect(formula=parse_dice("1d6"), types=("cold",)), ), save_for_half=False, ), ), ) _CONE = _area_spell("Cone of Cold", "cone") _BURST = _area_spell("Chill Sphere", "burst") _REGISTRY = {_CONE.name: _CONE, _BURST.name: _BURST} def _caster() -> CombatantState: spec = make_combatant( "caster", spells=[_CONE.name, _BURST.name], spell_slots={3: 1} ) return make_state(spec, "players", (1, 1)) def _engine( states: list[CombatantState], registry: dict[str, SpellSpec] | None = None ) -> CombatEngine: return CombatEngine( ScriptedRng([]), _walled_grid(), states, spell_registry=registry or _REGISTRY ) def test_burst_behind_a_wall_is_rejected() -> None: """The burst's point of origin is the aimed space: no LoE -> rejected, the slot is NOT spent.""" caster = _caster() engine = _engine([caster]) result = engine._execute_cast_spell(caster, None, _BURST.name, target_pos=(1, 5)) assert result is not None assert result.ok is False assert result.reason == "invalid_destination" assert caster.spell_slots_left[3] == 1 def test_cone_aimed_behind_a_wall_casts() -> None: """The aim point only names a direction: the cast proceeds (slot spent) even though the caster cannot see the point; the wall simply blocks the template's line of effect from the origin.""" caster = _caster() engine = _engine([caster]) result = engine._execute_cast_spell(caster, None, _CONE.name, target_pos=(1, 5)) assert result is None assert caster.spell_slots_left[3] == 0 def test_line_aimed_behind_a_wall_casts() -> None: line = _area_spell("Lightning Bolt", "line") caster = make_state( make_combatant("caster2", spells=[line.name], spell_slots={3: 1}), "players", (1, 1), ) engine = _engine([caster], {**_REGISTRY, line.name: line}) result = engine._execute_cast_spell(caster, None, line.name, target_pos=(1, 5)) assert result is None assert caster.spell_slots_left[3] == 0 def test_cone_aimed_out_of_bounds_is_still_rejected() -> None: caster = _caster() engine = _engine([caster]) result = engine._execute_cast_spell(caster, None, _CONE.name, target_pos=(9, 9)) assert result is not None assert result.ok is False assert result.reason == "invalid_destination" assert caster.spell_slots_left[3] == 1 def test_cone_reaches_a_target_beyond_the_wall_gap_direction() -> None: """Sanity that the direction-aimed cone still works normally when the aimed cells have LoE from the origin: a creature at (1,2) is caught.""" caster = _caster() victim_spec = make_combatant("victim", hp=20) victim = make_state(victim_spec, "monsters", (1, 2)) engine = _engine([caster, victim]) effect = AreaEffect(shape="cone", radius_ft=30) affected = engine._area_creatures(effect, caster, (1, 5)) assert victim in affected