fix(rules): line spells stop at a line-of-effect barrier (SRD aiming-a-spell.md: a line 'extends to the limit of its range or until it strikes a barrier that blocks line of effect') - line_cells truncates at the first blocks_los cell, creatures behind a solid wall are never caught

This commit is contained in:
2026-09-19 13:54:25 +02:00
parent b95533d49f
commit 5250763c35
2 changed files with 72 additions and 6 deletions
+19 -5
View File
@@ -14,8 +14,12 @@ PF1e (CRB, Magic — Aiming a Spell):
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 square is NOT one of the affected squares: the affected
squares are the ones beyond the origin along the ray (SRD Lightning Bolt: 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 "The bolt begins at your fingertips" - it leaves your square, it does not
fill it). fill it). 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 itself and everything beyond it are outside the template,
so a bolt never reaches creatures behind a solid wall.
Bursts need no template here: they are circles around a center cell and are Bursts need no template here: they are circles around a center cell and are
resolved by ``CombatEngine._creatures_in_range`` (unchanged). resolved by ``CombatEngine._creatures_in_range`` (unchanged).
@@ -78,10 +82,13 @@ 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 own square is NOT affected: the - Aiming a Spell), so the CASTER's own square is NOT affected: the
``length_cells`` affected squares are the ones *beyond* the origin along ``length_cells`` affected squares are the ones *beyond* the origin along
the ray. Out-of-bounds cells simply drop out of the template (creatures the ray. Out-of-bounds cells simply drop out of the template (creatures
only exist in bounds). A degenerate aim (origin == aim) affects nothing. 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") - that barrier cell and everything beyond it are excluded. A
degenerate aim (origin == aim) affects nothing.
""" """
if length_cells <= 0: if length_cells <= 0:
return frozenset() return frozenset()
@@ -98,7 +105,14 @@ def line_cells(
# 120 ft / 5 ft = 24 squares, none of them the caster's own (SRD Lightning # 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). # Bolt: "The bolt begins at your fingertips" — it leaves your square).
ray = _bresenham(origin, end) ray = _bresenham(origin, end)
return frozenset(cell for cell in ray[1 : length_cells + 1] if grid.in_bounds(cell)) cells: list[Pos] = []
for cell in ray[1 : length_cells + 1]:
if not grid.in_bounds(cell):
continue
if grid.terrain_at(cell).blocks_los:
break
cells.append(cell)
return frozenset(cells)
def _bresenham(start: Pos, end: Pos) -> list[Pos]: def _bresenham(start: Pos, end: Pos) -> list[Pos]:
+53 -1
View File
@@ -18,10 +18,11 @@ from typing import TYPE_CHECKING
import pytest import pytest
from pf1e_simulator.areas import line_cells
from pf1e_simulator.combat import Action, CombatantState, CombatEngine from pf1e_simulator.combat import Action, CombatantState, CombatEngine
from pf1e_simulator.dice import parse_dice from pf1e_simulator.dice import parse_dice
from pf1e_simulator.grid import Grid from pf1e_simulator.grid import Grid
from pf1e_simulator.map import MapSpec, TerrainType from pf1e_simulator.map import MapSpec, Pos, TerrainType
from pf1e_simulator.rng import ScriptedRng from pf1e_simulator.rng import ScriptedRng
from pf1e_simulator.spells import ( from pf1e_simulator.spells import (
AreaEffect, AreaEffect,
@@ -103,6 +104,20 @@ def _open_grid(side: int = 12) -> Grid:
return Grid.from_spec(spec) return Grid.from_spec(spec)
def _walled_grid(walls: frozenset[Pos], side: int = 12) -> Grid:
"""Open ``side``x``side`` floor with impassable walls at ``walls``."""
legend = {
".": TerrainType(type="floor", move_cost=1),
"#": TerrainType(type="wall", move_cost=None, blocks_los=True),
}
rows = tuple(
"".join("#" if (r, c) in walls else "." for c in range(side))
for r in range(side)
)
spec = MapSpec(name="walled", terrain=rows, legend=legend)
return Grid.from_spec(spec)
def _engine(states: list[CombatantState], rolls: list[int]) -> CombatEngine: def _engine(states: list[CombatantState], rolls: list[int]) -> CombatEngine:
return CombatEngine( return CombatEngine(
ScriptedRng(rolls), _open_grid(), states, spell_registry=_REGISTRY ScriptedRng(rolls), _open_grid(), states, spell_registry=_REGISTRY
@@ -232,6 +247,43 @@ def test_line_follows_a_diagonal_bresenham_ray() -> None:
assert foe_off.hp == 100 assert foe_off.hp == 100
def test_line_cells_stop_at_a_line_of_effect_barrier() -> None:
"""Given: a wall cell (6,6) on the east ray from origin (6,2)
When: line_cells builds a 10-cell template
Then: cells before the barrier are in the template; the barrier cell and
everything beyond it are not (CRB: the line "extends to the limit of its
range or until it strikes a barrier that blocks line of effect")."""
grid = _walled_grid(frozenset({(6, 6)}))
cells = line_cells(grid, (6, 2), (6, 8), 10)
assert (6, 5) in cells
assert (6, 6) not in cells
assert (6, 7) not in cells
assert (6, 8) not in cells
def test_line_behind_a_wall_does_not_hit_sheltered_creatures() -> None:
"""Given: caster (6,2) aiming east; a wall column at col 6 (rows 4-8);
foe_front (6,4) on the near side, foe_behind (6,8) fully behind the wall
When: cast the 120-ft line (24 cells - long enough to cross the map)
Then: the bolt stops at the barrier: only foe_front is caught (one 1d6
consumed); foe_behind is untouched (SRD Lightning Bolt cannot reach
around or through a solid wall)."""
caster = _caster()
walls = frozenset({(4, 6), (5, 6), (6, 6), (7, 6), (8, 6)})
foe_front = make_state(make_combatant("foe_front", hp=100), "monsters", (6, 4))
foe_behind = make_state(make_combatant("foe_behind", hp=100), "monsters", (6, 8))
eng = CombatEngine(
ScriptedRng([4]), _walled_grid(walls), [caster, foe_front, foe_behind],
spell_registry=_REGISTRY,
)
result = eng._execute(
caster, Action(kind="cast_spell", target_id="foe_front", spell_name="Line Sparks")
)
assert result.ok
assert foe_front.hp == 100 - 4
assert foe_behind.hp == 100
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# burst: unchanged circle around the resolved center # burst: unchanged circle around the resolved center
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------