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
|
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
|
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
|
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
|
- "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
|
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
|
limit of its range [...]. A line-shaped spell affects all creatures in
|
||||||
@@ -45,7 +46,11 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
|
|
||||||
def cone_cells(
|
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]:
|
) -> frozenset[Pos]:
|
||||||
"""Cells of a 90° cone of ``radius_cells`` squares starting at ``origin``.
|
"""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
|
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
|
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 caster's own space is never affected: the cone "starts
|
||||||
"shoots away from you"). A cell with no line of effect from the origin
|
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,
|
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
|
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
|
stops the cone, it does not pass through). A degenerate aim
|
||||||
@@ -68,12 +76,13 @@ def cone_cells(
|
|||||||
d_col = aim[1] - origin[1]
|
d_col = aim[1] - origin[1]
|
||||||
if d_row == 0 and d_col == 0:
|
if d_row == 0 and d_col == 0:
|
||||||
return frozenset()
|
return frozenset()
|
||||||
|
skip = caster_cells | {origin}
|
||||||
aim_sq = d_row * d_row + d_col * d_col
|
aim_sq = d_row * d_row + d_col * d_col
|
||||||
cells: set[Pos] = set()
|
cells: set[Pos] = set()
|
||||||
for row in range(origin[0] - radius_cells, origin[0] + radius_cells + 1):
|
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):
|
for col in range(origin[1] - radius_cells, origin[1] + radius_cells + 1):
|
||||||
cell = (row, col)
|
cell = (row, col)
|
||||||
if cell == origin or not grid.in_bounds(cell):
|
if cell in skip or not grid.in_bounds(cell):
|
||||||
continue
|
continue
|
||||||
v_row = row - origin[0]
|
v_row = row - origin[0]
|
||||||
v_col = col - origin[1]
|
v_col = col - origin[1]
|
||||||
@@ -91,16 +100,25 @@ def cone_cells(
|
|||||||
|
|
||||||
|
|
||||||
def line_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]:
|
) -> frozenset[Pos]:
|
||||||
"""Cells of a 5-ft-wide line of ``length_cells`` squares from ``origin``.
|
"""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``,
|
The line runs along the Bresenham ray from the origin toward ``aim``,
|
||||||
extended to ``length_cells`` squares (CRB: it "extends to the limit of
|
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
|
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 whole space is NOT affected: every
|
||||||
``length_cells`` affected squares are the ones *beyond* the origin along
|
caster cell the ray grazes (the anchor ``origin`` included, i.e. all of
|
||||||
the ray. Out-of-bounds cells simply drop out of the template (creatures
|
``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
|
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") - the barrier cell itself is the last cell of the template (the
|
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]
|
d_col = aim[1] - origin[1]
|
||||||
if d_row == 0 and d_col == 0:
|
if d_row == 0 and d_col == 0:
|
||||||
return frozenset()
|
return frozenset()
|
||||||
|
skip = caster_cells | {origin}
|
||||||
# Extend the aim vector so the ray reaches `length_cells` squares BEYOND
|
# Extend the aim vector so the ray reaches `length_cells` squares BEYOND
|
||||||
# the origin (the dominant-axis step count of a Bresenham line is
|
# 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.
|
# 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)
|
ray = _bresenham(origin, end)
|
||||||
cells: list[Pos] = []
|
cells: list[Pos] = []
|
||||||
for cell in ray[1 : length_cells + 1]:
|
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
|
continue
|
||||||
cells.append(cell)
|
cells.append(cell)
|
||||||
if grid.terrain_at(cell).blocks_los:
|
if grid.terrain_at(cell).blocks_los:
|
||||||
|
|||||||
@@ -7255,9 +7255,15 @@ class CombatEngine:
|
|||||||
else:
|
else:
|
||||||
origin = caster.pos
|
origin = caster.pos
|
||||||
if effect.shape == "cone":
|
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:
|
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 = [
|
affected = [
|
||||||
state
|
state
|
||||||
for state in self._states
|
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