Files
pf1e-simulator/tests/test_line_length_slots.py
T

102 lines
4.6 KiB
Python

"""A grazed caster cell must not shorten a line spell (SRD).
PF1e SRD "Aiming a Spell" (aiming-a-spell.md): "A line-shaped spell shoots
away from you in a line in the direction you designate. It starts from any
corner of your square and extends to the limit of its range." The line's
length is its RANGE measured from the caster's space: the caster's own squares
are origin space the line starts past, so a square of a multi-cell caster's
footprint that the ray grazes must NOT consume one of the ``length_cells``
squares. SRD Lightning Bolt: "The bolt begins at your fingertips" — a 120-ft
bolt is 24 squares of effect beyond the caster, not 24 minus its footprint.
The engine used to take a fixed ``ray[1 : length_cells + 1]`` window and drop
caster cells inside it, so a 2x2 caster's line came out one square shorter per
grazed footprint cell. ``line_cells`` now skips caster cells WITHOUT spending a
length slot (the ray is extended to cover them), so a multi-cell caster's line
runs its full ``length_cells`` squares starting just past the footprint. For a
1x1 caster the only skipped cell is the origin (``ray[0]``, already outside the
window), so the template is bit-identical to the old behaviour.
"""
from __future__ import annotations
from pf1e_simulator.areas import line_cells
from pf1e_simulator.combat import Action, 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, DamageEffect, SpellSpec
from tests.test_observation_enriched import make_combatant, make_state
_FLOOR = TerrainType(type="floor", move_cost=1)
# A Large caster anchored at (1,1) occupies this 2x2 block; aiming east the
# ray grazes the footprint cell (1,2) before leaving the caster's space.
_FOOTPRINT = frozenset({(1, 1), (1, 2), (2, 1), (2, 2)})
_LINE_SPARKS = SpellSpec(
name="Line Sparks",
level=2,
school="evocation",
range="long",
save="none",
base_dc=10,
effects=(
AreaEffect(
shape="line",
radius_ft=30,
effects=(DamageEffect(formula=parse_dice("1d6"), types=("electricity",)),),
save_for_half=False,
),
),
)
_REGISTRY = {"Line Sparks": _LINE_SPARKS}
def _open_grid(side: int = 12) -> Grid:
return Grid.from_spec(
MapSpec(name="line-length", terrain=tuple(["." * side] * side), legend={".": _FLOOR})
)
def test_grazed_caster_cell_does_not_shorten_the_line() -> None:
"""A 2x2 caster aiming east grazes footprint cell (1,2); the line must
still be 6 squares long starting past the footprint, so the far square
(1,8) is inside the template (the old fixed window dropped it)."""
cells = line_cells(_open_grid(), (1, 1), (1, 6), 6, caster_cells=_FOOTPRINT)
assert (1, 2) not in cells # the caster's own space is never affected
assert len(cells) == 6 # full length, not shortened by the grazed cell
assert cells == {(1, 3), (1, 4), (1, 5), (1, 6), (1, 7), (1, 8)}
def test_single_cell_line_is_bit_identical() -> None:
"""1x1 regression: the only skipped cell is the origin (ray[0]), already
outside the counted window, so the template is exactly the length_cells
squares beyond the origin — unchanged from the pre-fix behaviour."""
grid = _open_grid()
cells = line_cells(grid, (1, 1), (1, 6), 6, caster_cells=frozenset({(1, 1)}))
assert cells == {(1, 2), (1, 3), (1, 4), (1, 5), (1, 6), (1, 7)}
# Identical to the no-caster-cells call (the origin is dropped either way).
assert cells == line_cells(grid, (1, 1), (1, 6), 6)
def test_large_caster_line_reaches_a_foe_the_short_line_missed() -> None:
"""Engine level: a Large caster at (1,1) (footprint rows 1-2, cols 1-2)
casts a 30-ft line (6 cells) east at a foe on (1,8). The grazed footprint
cell (1,2) used to shorten the line to 5 cells (reaching only (1,7)), so
the foe at (1,8) escaped; with the length-slot fix the line runs its full
6 squares past the footprint and catches the foe."""
caster = make_state(
make_combatant("ogre", size="Large", hp=100, spells=["Line Sparks"], spell_slots={2: 9}),
"players",
(1, 1),
)
foe = make_state(make_combatant("gob", hp=100), "monsters", (1, 8))
engine = CombatEngine(ScriptedRng([4]), _open_grid(), [caster, foe], spell_registry=_REGISTRY)
result = engine._execute(
caster, Action(kind="cast_spell", target_id="gob", spell_name="Line Sparks")
)
assert result.ok
assert foe.hp == 100 - 4 # one 1d6 (d20=4) consumed: the line reached (1,8)