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:
@@ -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