fix(rules): cones require line of effect from their origin (SRD aiming-a-spell.md: a cone 'affects only an area, creature, or object to which it has line of effect from its origin') - cone_cells drops geometrically-inside cells without corner-to-corner LoE from the caster square; line/cone barrier-cell semantics aligned (both templates include the barrier square the effect strikes, exclude everything behind it)

This commit is contained in:
2026-09-19 14:04:34 +02:00
parent 5250763c35
commit 389e160627
2 changed files with 65 additions and 7 deletions
+21 -5
View File
@@ -18,8 +18,15 @@ PF1e (CRB, Magic — Aiming a Spell):
fill it). The line also stops at the first square that blocks line of 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 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 range or until it strikes a barrier that blocks line of effect"): the
barrier square itself and everything beyond it are outside the template, barrier square is the last square of the template and everything beyond
so a bolt never reaches creatures behind a solid wall. it is outside, so a bolt never reaches creatures behind a solid wall.
- Cones require line of effect from their origin (CRB, Magic - Line of
Effect): "A burst, cone, cylinder, or emanation spell affects only an
area, creature, or object to which it has line of effect from its origin
(a spherical burst's center point, a cone-shaped burst's starting
point, ...)". ``cone_cells`` therefore drops every geometric cell that
has no corner-to-corner line of effect from the origin square (a solid
wall between them shields everything behind it).
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).
@@ -29,6 +36,8 @@ from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from pf1e_simulator.los import has_line_of_effect
if TYPE_CHECKING: if TYPE_CHECKING:
from pf1e_simulator.grid import Grid from pf1e_simulator.grid import Grid
from pf1e_simulator.map import Pos from pf1e_simulator.map import Pos
@@ -44,7 +53,11 @@ def cone_cells(
from the origin is within ``radius_cells`` (5-10-5 grid distance), points from the origin is within ``radius_cells`` (5-10-5 grid distance), points
strictly into the cone's forward half-plane, and stays within ±45° of the strictly into the cone's forward half-plane, and stays within ±45° of the
aim bearing. The origin cell itself is never affected (CRB: the cone aim bearing. The origin cell itself is never affected (CRB: the cone
"shoots away from you"). A degenerate aim (origin == aim) affects nothing. "shoots away from you"). A cell with no line of effect from the origin
is dropped (CRB, Line of Effect: a cone "affects only an area, creature,
or object to which it has line of effect from its origin" - a solid wall
stops the cone, it does not pass through). A degenerate aim
(origin == aim) affects nothing.
The ±45° test is exact integer arithmetic: with v the cell offset and d The ±45° test is exact integer arithmetic: with v the cell offset and d
the aim offset, ``cos(angle) >= cos(45°)`` is equivalent to the aim offset, ``cos(angle) >= cos(45°)`` is equivalent to
@@ -70,6 +83,8 @@ def cone_cells(
continue continue
if grid.distance(origin, cell) > radius_cells: if grid.distance(origin, cell) > radius_cells:
continue continue
if not has_line_of_effect(grid, origin, cell):
continue
cells.add(cell) cells.add(cell)
return frozenset(cells) return frozenset(cells)
@@ -87,7 +102,8 @@ def line_cells(
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). The ray stops at the first cell that blocks line 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 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 effect") - the barrier cell itself is the last cell of the template (the
line strikes it) and everything beyond it is excluded. A
degenerate aim (origin == aim) affects nothing. degenerate aim (origin == aim) affects nothing.
""" """
if length_cells <= 0: if length_cells <= 0:
@@ -109,9 +125,9 @@ def line_cells(
for cell in ray[1 : length_cells + 1]: for cell in ray[1 : length_cells + 1]:
if not grid.in_bounds(cell): if not grid.in_bounds(cell):
continue continue
cells.append(cell)
if grid.terrain_at(cell).blocks_los: if grid.terrain_at(cell).blocks_los:
break break
cells.append(cell)
return frozenset(cells) return frozenset(cells)
+44 -2
View File
@@ -18,7 +18,7 @@ from typing import TYPE_CHECKING
import pytest import pytest
from pf1e_simulator.areas import line_cells from pf1e_simulator.areas import cone_cells, 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
@@ -185,6 +185,48 @@ def test_cone_covers_the_full_90_degree_arc() -> None:
assert foe_outside.hp == 100 assert foe_outside.hp == 100
def test_cone_cells_require_line_of_effect_from_the_origin() -> None:
"""Given: a wall column at col 4 (rows 4-8) between origin (6,2) and the
cell (6,5) that sits on the cone axis at distance 3
When: cone_cells builds a 3-cell east cone
Then: (6,5) is dropped - geometrically inside but without line of effect
from the origin (CRB, Line of Effect: a cone "affects only an area,
creature, or object to which it has line of effect from its origin");
on the same geometry without walls it stays in the template."""
walls = frozenset({(4, 4), (5, 4), (6, 4), (7, 4), (8, 4)})
grid = _walled_grid(walls)
cells = cone_cells(grid, (6, 2), (6, 3), 3)
assert (6, 3) in cells
assert (6, 4) in cells # the cone reaches the barrier square itself
assert (6, 5) not in cells
open_cells = cone_cells(_open_grid(), (6, 2), (6, 3), 3)
assert (6, 5) in open_cells # control: nothing else filters it out
def test_cone_behind_a_solid_wall_does_not_hit_sheltered_creatures() -> None:
"""Given: caster (6,2) aiming the 15-ft cone east at foe_front (6,3); a
wall column at col 4 (rows 4-8); foe_behind (6,5) sits on the cone axis
behind that wall
When: the cone resolves
Then: only foe_front is caught (one 1d6 consumed after the adjacent
foe_front's scripted -4 AoO d20 misses); foe_behind is untouched - the
cone stops at the solid barrier, it does not pass through it."""
caster = _caster()
walls = frozenset({(4, 4), (5, 4), (6, 4), (7, 4), (8, 4)})
foe_front = make_state(make_combatant("foe_front", hp=100), "monsters", (6, 3))
foe_behind = make_state(make_combatant("foe_behind", hp=100), "monsters", (6, 5))
eng = CombatEngine(
ScriptedRng([4, 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="Cone Sparks")
)
assert result.ok
assert foe_front.hp == 100 - 4
assert foe_behind.hp == 100
def test_cone_directed_by_target_pos_point_cast() -> None: def test_cone_directed_by_target_pos_point_cast() -> None:
"""Given: the same scene, no target_id — the cone is aimed at an explicit """Given: the same scene, no target_id — the cone is aimed at an explicit
cell (6,5) via target_pos (U7b point cast) cell (6,5) via target_pos (U7b point cast)
@@ -256,7 +298,7 @@ def test_line_cells_stop_at_a_line_of_effect_barrier() -> None:
grid = _walled_grid(frozenset({(6, 6)})) grid = _walled_grid(frozenset({(6, 6)}))
cells = line_cells(grid, (6, 2), (6, 8), 10) cells = line_cells(grid, (6, 2), (6, 8), 10)
assert (6, 5) in cells assert (6, 5) in cells
assert (6, 6) not in cells assert (6, 6) in cells # the bolt strikes the barrier square itself
assert (6, 7) not in cells assert (6, 7) not in cells
assert (6, 8) not in cells assert (6, 8) not in cells