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:
@@ -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]
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user