fix(rules): a grazed caster cell no longer shortens a line spell - SRD Aiming a Spell: a line 'starts from any corner of your square and extends to the limit of its range' (Lightning Bolt: 'The bolt begins at your fingertips'), so the caster's own squares are origin space the line starts PAST and must not consume one of the length_cells squares; line_cells took a fixed ray[1:length_cells+1] window and dropped caster cells inside it, so a 2x2 caster's line came out one square short per grazed footprint cell (the old code documented this as a simplification); the ray is now extended (reach = length_cells + 1 + len(caster_cells)) and caster cells are skipped WITHOUT spending a length slot, so a multi-cell line runs its full length starting just past the footprint; 1x1 casters are bit-identical (the only skipped cell is the origin at ray[0], already outside the counted window); the barrier/LoE stop and out-of-bounds semantics are unchanged (a straight ray never re-enters a convex map); golden untouched (no line spell in it); +3 tests (grazed-cell full-length template, 1x1 bit-identical regression, engine-level reach of a foe the short line missed)

This commit is contained in:
2026-09-20 00:41:02 +02:00
parent d347dde3fc
commit d5ad3c3aac
2 changed files with 140 additions and 27 deletions
+101
View File
@@ -0,0 +1,101 @@
"""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)