fix(rules): charge clear-path tests the whole footprint for difficult terrain (SRD movement-position-and-distance-2.md: 'You can't run or charge across difficult terrain' + 'you can move only as fast as the most difficult terrain you occupy') - _charge_path_clear priced only the anchor cell, so a 2x2 creature with one trailing square in rubble charged freely; the move-descent footprint pricing is deferred (would rewrite the anchor-based Dijkstra cost field)

This commit is contained in:
2026-09-19 15:51:15 +02:00
parent 5e478bfd27
commit 9086038bfb
2 changed files with 146 additions and 4 deletions
+7 -4
View File
@@ -3999,14 +3999,17 @@ class CombatEngine:
blocks movement, slows movement, or contains a creature (even an
ally), you can't charge" - the straight Bresenham anchor line is the
engine's model of that path, walked under the full-footprint gates
below.
below. Difficult terrain is tested over the WHOLE footprint, not
just the anchor cell (SRD "Movement, Position and Distance": "You
can't run or charge across difficult terrain" - a 2x2 creature with
one trailing square in rubble cannot charge).
"""
prev = state.pos
for pos in path:
if not self._cells_free(self._footprint_at(pos, state), state):
footprint = self._footprint_at(pos, state)
if not self._cells_free(footprint, state):
return False
terrain = self._grid.terrain_at(pos)
if terrain.move_cost is not None and terrain.move_cost > 1:
if self._footprint_touches_difficult(footprint):
return False
if (
prev[0] != pos[0]
+139
View File
@@ -0,0 +1,139 @@
"""Multi-cell creatures cannot charge across difficult terrain (SRD).
SRD "Movement, Position and Distance": "You can't run or charge across
difficult terrain" and "If you occupy squares with different kinds of
terrain, you can move only as fast as the most difficult terrain you
occupy will allow." A 2x2 creature whose TRAILING footprint square sits
in rubble is just as hampered as a 1x1 creature standing in it - the
charge clear-path check must test the whole footprint
(``_footprint_touches_difficult``), not only the anchor cell.
Run and the 5-foot step already test the footprint; the charge did not.
The move-descent budget (Dijkstra cost field) remaining anchor-priced is
a known, documented deferral: making it footprint-aware rewrites
``grid.reachable`` paths wholesale.
"""
from __future__ import annotations
from pf1e_simulator.combat import ChargeResult, CombatantState, CombatEngine
from pf1e_simulator.grid import Grid
from pf1e_simulator.map import MapSpec, TerrainType
from pf1e_simulator.models import (
AbilityScores,
ACProfile,
AttackSpec,
Combatant,
DamageComponent,
Saves,
)
from pf1e_simulator.rng import ScriptedRng
def _combatant(cid: str, *, size: str, speed: int = 30) -> Combatant:
attack = AttackSpec(
id=f"{cid}-w",
name="short sword",
kind="melee",
attack_bonus=2,
damage=[DamageComponent(formula="1d6", types=["slashing"])],
)
return Combatant(
id=cid,
name=cid,
level=1,
size=size,
abilities=AbilityScores(
str_score=10, dex_score=10, con_score=10, int_score=10, wis_score=10,
cha_score=10,
),
hp_max=20,
ac=ACProfile(total=15, touch=15, flat_footed=15),
bab=2,
initiative_mod=0,
speed_land_ft=speed,
saves=Saves(fort=0, ref=0, will=0),
attacks=[attack],
)
def _state(c: Combatant, side: str, pos: tuple[int, int]) -> CombatantState:
return CombatantState(combatant=c, side=side, pos=pos, hp=c.hp_max)
def _rubble_at(cells: set[tuple[int, int]], size: int = 8) -> Grid:
"""Open size x size grid; the listed cells are rubble (cost 2)."""
legend = {
".": TerrainType(type="floor", move_cost=1),
"~": TerrainType(type="rubble", move_cost=2),
}
rows = [list("." * size) for _ in range(size)]
for r, c in cells:
rows[r][c] = "~"
return Grid.from_spec(
MapSpec(name="rubble", terrain=tuple("".join(r) for r in rows), legend=legend),
)
def test_large_charge_blocked_by_footprint_rubble() -> None:
"""Large at (0,0) (footprint rows 0-1, cols 0-1), foe at (0,4), rubble
at (1,2): every charge anchor whose footprint reaches (0,2)/(0,3) also
covers the rubble square -> the charge is impossible (anchor-only
pricing let it through: the anchor path (0,1)->(0,2) is all floor)."""
large = _state(_combatant("ogre-mob", size="Large"), "players", (0, 0))
foe = _state(_combatant("sentinel", size="Medium"), "monsters", (0, 4))
engine = CombatEngine(
ScriptedRng([]), _rubble_at({(1, 2)}), [large, foe],
)
result = engine.can_charge(large, foe)
assert isinstance(result, ChargeResult)
assert result.weapon is None
assert result.reason == "no_charge_path"
def test_large_charge_clear_when_footprint_avoids_rubble() -> None:
"""Same geometry, rubble moved off the swept footprints (row 2): the
anchor path and every footprint along it stay on floor -> charge OK."""
large = _state(_combatant("ogre-mob", size="Large"), "players", (0, 0))
foe = _state(_combatant("sentinel", size="Medium"), "monsters", (0, 4))
engine = CombatEngine(
ScriptedRng([]), _rubble_at({(3, 2)}), [large, foe],
)
result = engine.can_charge(large, foe)
assert isinstance(result, ChargeResult)
assert result.weapon is not None
assert result.reason is None
def test_medium_charge_anchor_rubble_still_blocked() -> None:
"""1x1 regression: rubble directly on the anchor path blocks the charge
exactly as before (footprint == anchor cell). A wall corridor (row 1
all wall) forces the only charge path through the rubble cell (0,2)."""
legend = {
".": TerrainType(type="floor", move_cost=1),
"~": TerrainType(type="rubble", move_cost=2),
"#": TerrainType(type="wall", move_cost=None, blocks_los=True),
}
rows = (".~....", "######", "######", "######")
grid = Grid.from_spec(MapSpec(name="corridor", terrain=rows, legend=legend))
gob = _state(_combatant("gob", size="Small"), "players", (0, 0))
foe = _state(_combatant("orc", size="Medium"), "monsters", (0, 4))
engine = CombatEngine(ScriptedRng([]), grid, [gob, foe])
result = engine.can_charge(gob, foe)
assert isinstance(result, ChargeResult)
assert result.weapon is None
assert result.reason == "no_charge_path"
def test_medium_charge_offpath_rubble_unaffected() -> None:
"""1x1 regression: rubble beside the path never blocked a charge and
still does not (the footprint is a single square)."""
gob = _state(_combatant("gob", size="Small"), "players", (0, 0))
foe = _state(_combatant("orc", size="Medium"), "monsters", (0, 4))
engine = CombatEngine(
ScriptedRng([]), _rubble_at({(1, 2)}), [gob, foe],
)
result = engine.can_charge(gob, foe)
assert isinstance(result, ChargeResult)
assert result.weapon is not None
assert result.reason is None