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.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,