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:
@@ -14,8 +14,12 @@ PF1e (CRB, Magic — Aiming a Spell):
|
||||
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 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
|
||||
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
|
||||
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``,
|
||||
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 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
|
||||
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:
|
||||
return frozenset()
|
||||
@@ -98,7 +105,14 @@ def line_cells(
|
||||
# 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)
|
||||
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]:
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user