diff --git a/src/pf1e_simulator/areas.py b/src/pf1e_simulator/areas.py index dfb95ed..6501a68 100644 --- a/src/pf1e_simulator/areas.py +++ b/src/pf1e_simulator/areas.py @@ -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]: diff --git a/tests/test_area_shapes.py b/tests/test_area_shapes.py index 67d5330..6ac6f2e 100644 --- a/tests/test_area_shapes.py +++ b/tests/test_area_shapes.py @@ -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 # ---------------------------------------------------------------------------