From 1e2de7f89dc357d99e300f6430ade6412da226f5 Mon Sep 17 00:00:00 2001 From: ctan Date: Sat, 19 Sep 2026 16:34:22 +0200 Subject: [PATCH] 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 --- src/pf1e_simulator/areas.py | 39 +++++++--- src/pf1e_simulator/combat.py | 10 ++- ...est_conformance_caster_footprint_origin.py | 74 +++++++++++++++++++ 3 files changed, 111 insertions(+), 12 deletions(-) create mode 100644 tests/test_conformance_caster_footprint_origin.py diff --git a/src/pf1e_simulator/areas.py b/src/pf1e_simulator/areas.py index fafafc1..80af376 100644 --- a/src/pf1e_simulator/areas.py +++ b/src/pf1e_simulator/areas.py @@ -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: diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 90f72cd..bc19c69 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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 diff --git a/tests/test_conformance_caster_footprint_origin.py b/tests/test_conformance_caster_footprint_origin.py new file mode 100644 index 0000000..a8e6421 --- /dev/null +++ b/tests/test_conformance_caster_footprint_origin.py @@ -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 == []