Files
pf1e-simulator/tests/test_conformance_point_aim_loe.py
T

135 lines
4.9 KiB
Python

"""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