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:
@@ -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