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:
+29
-10
@@ -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:
|
||||
|
||||
@@ -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 == []
|
||||
Reference in New Issue
Block a user