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
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
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
CASTER's own square is NOT one of the affected squares: the affected
squares are the ones beyond the origin along the ray (SRD Lightning Bolt:
"The bolt begins at your fingertips" - it leaves your square, it does not
fill it). The line also stops at the first square that blocks line of
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
CASTER's own space is NOT one of the affected squares AND does not shorten
the effect: the whole footprint (every cell of a multi-cell caster, not
just the anchor) is skipped without spending one of the ``length_cells``
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
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
@@ -142,17 +145,20 @@ def line_cells(
The line runs along the Bresenham ray from the origin toward ``aim``,
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
- Aiming a Spell), so the CASTER's whole space is NOT affected: every
caster cell the ray grazes (the anchor ``origin`` included, i.e. all of
``caster_cells``) is outside the template. For a 1x1 caster that is just
the origin, so the ``length_cells`` affected squares are the ones
*beyond* the origin along the ray. Documented simplification: a caster
cell the ray crosses still consumes one of the ``length_cells`` slots, so
a multi-cell caster's line is at most its own footprint shorter.
Out-of-bounds cells simply drop out of the template (creatures
only 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
- Aiming a Spell), so the CASTER's whole space is NOT affected AND does
not shorten the effect: every caster cell the ray grazes (the anchor
``origin`` included, i.e. all of ``caster_cells``) is origin space skipped
WITHOUT spending one of the ``length_cells`` slots. The line is therefore
``length_cells`` squares long starting just past the caster's footprint
(SRD Lightning Bolt: "The bolt begins at your fingertips" - it leaves your
square, and a 120 ft bolt is 24 squares beyond the caster, not beyond the
anchor). For a 1x1 caster the only skipped cell is the origin (ray[0]),
so the result is bit-identical to counting the ``length_cells`` squares
*beyond* the origin. Out-of-bounds cells drop out of the template but
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
degenerate aim (origin == aim) affects nothing.
"""
@@ -163,21 +169,27 @@ def line_cells(
if d_row == 0 and d_col == 0:
return frozenset()
skip = caster_cells | {origin}
# Extend the aim vector so the ray reaches `length_cells` squares BEYOND
# the origin (the dominant-axis step count of a Bresenham line is
# max(|d_row|, |d_col|)); the +1 accounts for the origin square we drop.
scale = (length_cells + 1) / max(abs(d_row), abs(d_col))
# Extend the aim vector so the ray still yields `length_cells` real
# squares AFTER the caster's own space is skipped: the dominant-axis step
# count of a Bresenham line is max(|d_row|, |d_col|); the +1 drops the
# 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))
# 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)
cells: list[Pos] = []
for cell in ray[1 : length_cells + 1]:
if cell in skip or not grid.in_bounds(cell):
continue
for cell in ray[1:]:
if cell in skip:
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)
if grid.terrain_at(cell).blocks_los:
break # the line strikes this barrier: last cell of the template
if len(cells) >= length_cells:
break
return frozenset(cells)