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
|
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]:
|
||||||
|
|||||||
@@ -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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user