fix(rules): a cone/line template excludes the caster's whole footprint, not just the anchor cell (SRD aiming-a-spell.md: 'It starts from any corner of your square' - every occupied square is origin space and never inside its own area) - cone_cells/line_cells take caster_cells; combat passes caster.occupied_cells(); 1x1 casters bit-identical; documented simplification: a grazed caster cell still consumes a length slot

This commit is contained in:
2026-09-19 16:34:22 +02:00
parent 2e468ba619
commit 1e2de7f89d
3 changed files with 111 additions and 12 deletions
+29 -10
View File
@@ -5,7 +5,8 @@ PF1e (CRB, Magic — Aiming a Spell):
direction you designate. It starts from any corner of your square and
widens out as it goes." — modeled here as every cell whose center lies
within the cone's length of the origin and within ±45° of the aim
direction (a 90°-wide quarter circle), excluding the origin cell.
direction (a 90°-wide quarter circle), excluding the caster's whole
space (every cell of the footprint, not just the anchor).
- "A line-shaped spell shoots away from you in a line in the direction you
designate. It starts from any corner of your square and extends to the
limit of its range [...]. A line-shaped spell affects all creatures in
@@ -45,7 +46,11 @@ if TYPE_CHECKING:
def cone_cells(
grid: Grid, origin: Pos, aim: Pos, radius_cells: int
grid: Grid,
origin: Pos,
aim: Pos,
radius_cells: int,
caster_cells: frozenset[Pos] = frozenset(),
) -> frozenset[Pos]:
"""Cells of a 90° cone of ``radius_cells`` squares starting at ``origin``.
@@ -53,8 +58,11 @@ def cone_cells(
resolved target or point-cast center). A cell is affected when its offset
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
aim bearing. The origin cell itself is never affected (CRB: the cone
"shoots away from you"). A cell with no line of effect from the origin
aim bearing. The caster's own space is never affected: the cone "starts
from any corner of your square" (CRB: it "shoots away from you"), so
EVERY cell in ``caster_cells`` (the caster's whole footprint; the anchor
``origin`` is always one of them) is outside the template, not just the
origin. 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
@@ -68,12 +76,13 @@ def cone_cells(
d_col = aim[1] - origin[1]
if d_row == 0 and d_col == 0:
return frozenset()
skip = caster_cells | {origin}
aim_sq = d_row * d_row + d_col * d_col
cells: set[Pos] = set()
for row in range(origin[0] - radius_cells, origin[0] + radius_cells + 1):
for col in range(origin[1] - radius_cells, origin[1] + radius_cells + 1):
cell = (row, col)
if cell == origin or not grid.in_bounds(cell):
if cell in skip or not grid.in_bounds(cell):
continue
v_row = row - origin[0]
v_col = col - origin[1]
@@ -91,16 +100,25 @@ def cone_cells(
def line_cells(
grid: Grid, origin: Pos, aim: Pos, length_cells: int
grid: Grid,
origin: Pos,
aim: Pos,
length_cells: int,
caster_cells: frozenset[Pos] = frozenset(),
) -> frozenset[Pos]:
"""Cells of a 5-ft-wide line of ``length_cells`` squares from ``origin``.
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
``length_cells`` affected squares are the ones *beyond* the origin along
the ray. Out-of-bounds cells simply drop out of the template (creatures
- Aiming a Spell), so the CASTER's whole space is NOT affected: every
caster cell the ray grazes (the anchor ``origin`` included, i.e. all of
``caster_cells``) is outside the template. For a 1x1 caster that is just
the origin, so the ``length_cells`` affected squares are the ones
*beyond* the origin along the ray. Documented simplification: a caster
cell the ray crosses still consumes one of the ``length_cells`` slots, so
a multi-cell caster's line is at most its own footprint shorter.
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
of effect (CRB, Line: "or until it strikes a barrier that blocks line of
effect") - the barrier cell itself is the last cell of the template (the
@@ -113,6 +131,7 @@ def line_cells(
d_col = aim[1] - origin[1]
if d_row == 0 and d_col == 0:
return frozenset()
skip = caster_cells | {origin}
# Extend the aim vector so the ray reaches `length_cells` squares BEYOND
# the origin (the dominant-axis step count of a Bresenham line is
# max(|d_row|, |d_col|)); the +1 accounts for the origin square we drop.
@@ -124,7 +143,7 @@ def line_cells(
ray = _bresenham(origin, end)
cells: list[Pos] = []
for cell in ray[1 : length_cells + 1]:
if not grid.in_bounds(cell):
if cell in skip or not grid.in_bounds(cell):
continue
cells.append(cell)
if grid.terrain_at(cell).blocks_los:
+8 -2
View File
@@ -7255,9 +7255,15 @@ class CombatEngine:
else:
origin = caster.pos
if effect.shape == "cone":
cells = cone_cells(self._grid, origin, aim, span_cells)
cells = cone_cells(
self._grid, origin, aim, span_cells,
caster_cells=caster.occupied_cells(),
)
else:
cells = line_cells(self._grid, origin, aim, span_cells)
cells = line_cells(
self._grid, origin, aim, span_cells,
caster_cells=caster.occupied_cells(),
)
affected = [
state
for state in self._states
@@ -0,0 +1,74 @@
"""A multi-cell caster's WHOLE footprint is outside its own cone/line.
PF1e SRD "Aiming a Spell" (aiming-a-spell.md): a cone and a line "start from
any corner of your square" -- for a creature with a space larger than one
square, every occupied square is part of the origin space, so none of them is
ever inside the creature's own area (the template "shoots away from you").
The engine used to exclude only the anchor cell, so a Large caster's other
cells fell inside its own cone/line template.
"""
from __future__ import annotations
from pf1e_simulator.areas import cone_cells, line_cells
from pf1e_simulator.combat import CombatEngine
from pf1e_simulator.grid import Grid
from pf1e_simulator.map import MapSpec, TerrainType
from pf1e_simulator.rng import ScriptedRng
from pf1e_simulator.spells import AreaEffect
from tests.test_observation_enriched import make_combatant, make_state
_FLOOR = TerrainType(type="floor", move_cost=1)
def _open_grid() -> Grid:
return Grid.from_spec(
MapSpec(
name="footprint-origin",
terrain=tuple(["." * 8] * 8),
legend={".": _FLOOR},
)
)
# A Large caster anchored at (1,1) occupies the 2x2 block below. Aiming east
# from the anchor, the ray/cone grazes the caster's own cells (1,2) and (2,2).
_FOOTPRINT = frozenset({(1, 1), (1, 2), (2, 1), (2, 2)})
def test_cone_excludes_the_whole_caster_footprint() -> None:
grid = _open_grid()
cells = cone_cells(grid, (1, 1), (1, 6), 4, caster_cells=_FOOTPRINT)
assert not cells & _FOOTPRINT
# The exclusion is the footprint only: the cone keeps everything past it.
assert (1, 3) in cells
assert (2, 3) in cells
def test_line_excludes_the_whole_caster_footprint() -> None:
grid = _open_grid()
cells = line_cells(grid, (1, 1), (1, 6), 6, caster_cells=_FOOTPRINT)
assert (1, 2) not in cells
assert (1, 3) in cells
assert (1, 7) in cells
def test_single_cell_caster_templates_are_unchanged() -> None:
"""1x1 regression: the footprint is just the anchor, which the old code
already dropped -- both templates must be identical to the pre-fix set."""
grid = _open_grid()
old_cone = cone_cells(grid, (1, 1), (1, 6), 4)
new_cone = cone_cells(grid, (1, 1), (1, 6), 4, caster_cells=frozenset({(1, 1)}))
assert old_cone == new_cone
old_line = line_cells(grid, (1, 1), (1, 6), 6)
new_line = line_cells(grid, (1, 1), (1, 6), 6, caster_cells=frozenset({(1, 1)}))
assert old_line == new_line
def test_large_caster_is_not_caught_by_its_own_cone() -> None:
caster_spec = make_combatant("ogre", size="Large", hp=20)
caster = make_state(caster_spec, "players", (1, 1))
engine = CombatEngine(ScriptedRng([]), _open_grid(), [caster])
effect = AreaEffect(shape="cone", radius_ft=30)
affected = engine._area_creatures(effect, caster, (1, 6))
assert affected == []