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: