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
+39 -27
View File
@@ -11,12 +11,15 @@ PF1e (CRB, Magic — Aiming a Spell):
designate. It starts from any corner of your square and extends to the designate. It starts from any corner of your square and extends to the
limit of its range [...]. A line-shaped spell affects all creatures in limit of its range [...]. A line-shaped spell affects all creatures in
squares through which the line passes." — modeled here as the 5-ft-wide squares through which the line passes." — modeled here as the 5-ft-wide
Bresenham ray from the origin through the aim cell, truncated to the Bresenham ray from the origin through the aim cell, truncated to the
line's length. The line "starts from any corner of your square", so the line's length. The line "starts from any corner of your square", so the
CASTER's own square is NOT one of the affected squares: the affected CASTER's own space is NOT one of the affected squares AND does not shorten
squares are the ones beyond the origin along the ray (SRD Lightning Bolt: the effect: the whole footprint (every cell of a multi-cell caster, not
"The bolt begins at your fingertips" - it leaves your square, it does not just the anchor) is skipped without spending one of the ``length_cells``
fill it). The line also stops at the first square that blocks line of slots, so the line runs its full length starting just past the caster
(SRD Lightning Bolt: "The bolt begins at your fingertips" - it leaves your
square, it does not fill it, and a 120 ft bolt is 24 squares beyond the
caster). The line also stops at the first square that blocks line of
effect (CRB, Magic - Aiming a Spell, Line: it "extends to the limit of its effect (CRB, Magic - Aiming a Spell, Line: it "extends to the limit of its
range or until it strikes a barrier that blocks line of effect"): the range or until it strikes a barrier that blocks line of effect"): the
barrier square is the last square of the template and everything beyond barrier square is the last square of the template and everything beyond
@@ -142,17 +145,20 @@ def line_cells(
The line runs along the Bresenham ray from the origin toward ``aim``, The line runs along the Bresenham ray from the origin toward ``aim``,
extended to ``length_cells`` squares (CRB: it "extends to the limit of extended to ``length_cells`` squares (CRB: it "extends to the limit of
its range"). The line "starts from any corner of your square" (CRB, Magic its range"). The line "starts from any corner of your square" (CRB, Magic
- Aiming a Spell), so the CASTER's whole space is NOT affected: every - Aiming a Spell), so the CASTER's whole space is NOT affected AND does
caster cell the ray grazes (the anchor ``origin`` included, i.e. all of not shorten the effect: every caster cell the ray grazes (the anchor
``caster_cells``) is outside the template. For a 1x1 caster that is just ``origin`` included, i.e. all of ``caster_cells``) is origin space skipped
the origin, so the ``length_cells`` affected squares are the ones WITHOUT spending one of the ``length_cells`` slots. The line is therefore
*beyond* the origin along the ray. Documented simplification: a caster ``length_cells`` squares long starting just past the caster's footprint
cell the ray crosses still consumes one of the ``length_cells`` slots, so (SRD Lightning Bolt: "The bolt begins at your fingertips" - it leaves your
a multi-cell caster's line is at most its own footprint shorter. square, and a 120 ft bolt is 24 squares beyond the caster, not beyond the
Out-of-bounds cells simply drop out of the template (creatures anchor). For a 1x1 caster the only skipped cell is the origin (ray[0]),
only exist in bounds). The ray stops at the first cell that blocks line so the result is bit-identical to counting the ``length_cells`` squares
of effect (CRB, Line: "or until it strikes a barrier that blocks line of *beyond* the origin. Out-of-bounds cells drop out of the template but
effect") - the barrier cell itself is the last cell of the template (the never re-enter (a straight ray leaves a convex map once), so creatures
only ever exist in bounds. The ray stops at the first cell that blocks
line of effect (CRB, Line: "or until it strikes a barrier that blocks line
of effect") - the barrier cell itself is the last cell of the template (the
line strikes it) and everything beyond it is excluded. A line strikes it) and everything beyond it is excluded. A
degenerate aim (origin == aim) affects nothing. degenerate aim (origin == aim) affects nothing.
""" """
@@ -163,21 +169,27 @@ def line_cells(
if d_row == 0 and d_col == 0: if d_row == 0 and d_col == 0:
return frozenset() return frozenset()
skip = caster_cells | {origin} skip = caster_cells | {origin}
# Extend the aim vector so the ray reaches `length_cells` squares BEYOND # Extend the aim vector so the ray still yields `length_cells` real
# the origin (the dominant-axis step count of a Bresenham line is # squares AFTER the caster's own space is skipped: the dominant-axis step
# max(|d_row|, |d_col|)); the +1 accounts for the origin square we drop. # count of a Bresenham line is max(|d_row|, |d_col|); the +1 drops the
scale = (length_cells + 1) / max(abs(d_row), abs(d_col)) # origin square and the extra `len(caster_cells)` steps cover every
# footprint square the ray can graze before it leaves the caster's space
# (those are origin space and spend no length slot).
dominant = max(abs(d_row), abs(d_col))
reach = length_cells + 1 + len(caster_cells)
scale = reach / dominant
end = (origin[0] + round(d_row * scale), origin[1] + round(d_col * scale)) end = (origin[0] + round(d_row * scale), origin[1] + round(d_col * scale))
# The line is `length_cells` squares LONG starting just past the caster:
# 120 ft / 5 ft = 24 squares, none of them the caster's own (SRD Lightning
# Bolt: "The bolt begins at your fingertips" — it leaves your square).
ray = _bresenham(origin, end) ray = _bresenham(origin, end)
cells: list[Pos] = [] cells: list[Pos] = []
for cell in ray[1 : length_cells + 1]: for cell in ray[1:]:
if cell in skip or not grid.in_bounds(cell): if cell in skip:
continue continue # origin space: the line starts past it, no slot spent
if not grid.in_bounds(cell):
continue # off the map: a straight ray never re-enters
cells.append(cell) cells.append(cell)
if grid.terrain_at(cell).blocks_los: if grid.terrain_at(cell).blocks_los:
break # the line strikes this barrier: last cell of the template
if len(cells) >= length_cells:
break break
return frozenset(cells) return frozenset(cells)
+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)