fix(rules): point-cast cones/lines no longer demand line of effect to the aim point (SRD aiming-a-spell.md: a cone/line 'shoots away from you in the direction you designate' and 'starts from any corner of your square' - the LoE-to-the-space rule binds targets and burst origins only) - _point_target_rejection now requires in-bounds for every area and caster->point LoE only for bursts (and area-less spells, conservatively); template LoE stays enforced by cone_cells/line_cells

This commit is contained in:
2026-09-19 16:29:38 +02:00
parent 778d6a8620
commit 2e468ba619
2 changed files with 187 additions and 13 deletions
+53 -13
View File
@@ -130,6 +130,7 @@ from pf1e_simulator.loaders.combatant import CombatantLoadError, load_combatant
from pf1e_simulator.models import PREDEFINED_SKILLS from pf1e_simulator.models import PREDEFINED_SKILLS
from pf1e_simulator.spells import ( from pf1e_simulator.spells import (
AreaEffect, AreaEffect,
AreaShape,
BuffEffect, BuffEffect,
CastTime, CastTime,
ConditionEffect, ConditionEffect,
@@ -4778,7 +4779,7 @@ class CombatEngine:
) )
return None return None
if target_pos is not 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: if rejection is not None:
return rejection return rejection
if spell_name is None: if spell_name is None:
@@ -4789,20 +4790,59 @@ class CombatEngine:
) )
def _point_target_rejection( 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: ) -> ActionResult | None:
"""U7b gate: a point-targeted area must be in bounds and reachable.""" """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}) 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(
self._log( f"round {self._current_round} {state.combatant.id}: "
f"round {self._current_round} {state.combatant.id}: " f"cast_spell rejected ({_REASON_INVALID_DESTINATION})"
f"cast_spell rejected ({_REASON_INVALID_DESTINATION})" )
) return ActionResult(
return ActionResult( kind="cast_spell", ok=False, reason=_REASON_INVALID_DESTINATION
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( def _execute_maneuver(
self, self,
+134
View File
@@ -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