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:
@@ -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)
|
||||
Reference in New Issue
Block a user