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.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,13 +4790,26 @@ 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):
|
||||||
return None
|
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(
|
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})"
|
||||||
@@ -4803,6 +4817,32 @@ class CombatEngine:
|
|||||||
return ActionResult(
|
return ActionResult(
|
||||||
kind="cast_spell", ok=False, reason=_REASON_INVALID_DESTINATION
|
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})
|
||||||
|
)
|
||||||
|
):
|
||||||
|
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(
|
def _execute_maneuver(
|
||||||
self,
|
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