diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 21a6f89..90f72cd 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -130,6 +130,7 @@ from pf1e_simulator.loaders.combatant import CombatantLoadError, load_combatant from pf1e_simulator.models import PREDEFINED_SKILLS from pf1e_simulator.spells import ( AreaEffect, + AreaShape, BuffEffect, CastTime, ConditionEffect, @@ -4778,7 +4779,7 @@ class CombatEngine: ) return None if target_pos is not None: - rejection = self._point_target_rejection(state, target_pos) + rejection = self._point_target_rejection(state, target_pos, spell_name) if rejection is not None: return rejection if spell_name is None: @@ -4789,20 +4790,59 @@ class CombatEngine: ) def _point_target_rejection( - self, state: CombatantState, target_pos: tuple[int, int] + self, + state: CombatantState, + target_pos: tuple[int, int], + spell_name: str | None, ) -> ActionResult | None: - """U7b gate: a point-targeted area must be in bounds and reachable.""" - if self._grid.in_bounds(target_pos) and has_line_of_effect_cells( - self._grid, state.occupied_cells(), frozenset({target_pos}) + """U7b gate: a point-targeted area must be in bounds and reachable. + + The point must always be in bounds. Line of effect caster -> point is + required only for a BURST (or a spell with no area, conservatively): + the burst's point of origin is that space and SRD aiming-a-spell.md + demands "a clear line of effect ... to any space in which you wish to + create an effect", and a burst "can't affect creatures with total + cover from its point of origin". A cone or line instead "shoots away + from you ... in the direction you designate" and "starts from any + corner of your square": the aim point only names a DIRECTION from the + caster's own space, so no line of effect to it is required — LoE is + enforced by the template itself (the cone drops cells it cannot reach + from its origin, the line stops at the first barrier). + """ + if not self._grid.in_bounds(target_pos): + self._log( + f"round {self._current_round} {state.combatant.id}: " + f"cast_spell rejected ({_REASON_INVALID_DESTINATION})" + ) + return ActionResult( + kind="cast_spell", ok=False, reason=_REASON_INVALID_DESTINATION + ) + if self._point_cast_area_shape(spell_name) == "burst" and not ( + has_line_of_effect_cells( + self._grid, state.occupied_cells(), frozenset({target_pos}) + ) ): - return None - self._log( - f"round {self._current_round} {state.combatant.id}: " - f"cast_spell rejected ({_REASON_INVALID_DESTINATION})" - ) - return ActionResult( - kind="cast_spell", ok=False, reason=_REASON_INVALID_DESTINATION - ) + self._log( + f"round {self._current_round} {state.combatant.id}: " + f"cast_spell rejected ({_REASON_INVALID_DESTINATION})" + ) + return ActionResult( + kind="cast_spell", ok=False, reason=_REASON_INVALID_DESTINATION + ) + return None + + def _point_cast_area_shape(self, spell_name: str | None) -> AreaShape: + """Shape of the spell's top-level area: 'burst' when it has none. + + A spell with no AreaEffect never honours ``target_pos`` downstream; + the gate keeps its historical burst-grade strictness for those. + """ + spell = self._spell_registry.get(spell_name) if spell_name is not None else None + if spell is not None: + for effect in spell.effects: + if isinstance(effect, AreaEffect): + return effect.shape + return "burst" def _execute_maneuver( self, diff --git a/tests/test_conformance_point_aim_loe.py b/tests/test_conformance_point_aim_loe.py new file mode 100644 index 0000000..6c4a57a --- /dev/null +++ b/tests/test_conformance_point_aim_loe.py @@ -0,0 +1,134 @@ +"""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