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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user