102 lines
4.6 KiB
Python
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)
|