From d5ad3c3aaca3848539031d6c3ee322033ae3fd9e Mon Sep 17 00:00:00 2001 From: ctan Date: Sun, 20 Sep 2026 00:41:02 +0200 Subject: [PATCH] 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) --- src/pf1e_simulator/areas.py | 66 ++++++++++++--------- tests/test_line_length_slots.py | 101 ++++++++++++++++++++++++++++++++ 2 files changed, 140 insertions(+), 27 deletions(-) create mode 100644 tests/test_line_length_slots.py diff --git a/src/pf1e_simulator/areas.py b/src/pf1e_simulator/areas.py index bd05ef4..47a0b4e 100644 --- a/src/pf1e_simulator/areas.py +++ b/src/pf1e_simulator/areas.py @@ -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) diff --git a/tests/test_line_length_slots.py b/tests/test_line_length_slots.py new file mode 100644 index 0000000..cd1744a --- /dev/null +++ b/tests/test_line_length_slots.py @@ -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)