135 lines
4.9 KiB
Python
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
|