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
+53 -1
View File
@@ -18,10 +18,11 @@ from typing import TYPE_CHECKING
import pytest
from pf1e_simulator.areas import line_cells
from pf1e_simulator.combat import Action, CombatantState, CombatEngine
from pf1e_simulator.dice import parse_dice
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.spells import (
AreaEffect,
@@ -103,6 +104,20 @@ def _open_grid(side: int = 12) -> Grid:
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:
return CombatEngine(
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
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
# ---------------------------------------------------------------------------