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