fix(rules): a multi-cell creature moving under mixed terrain pays the difficult rate for its whole footprint - SRD Movement, Position and Distance: 'If you occupy squares with different kinds of terrain, you can move only as fast as the most difficult terrain you occupy will allow'; the charge/run/5-foot-step already gated on the whole footprint (_footprint_touches_difficult) but the move/withdraw DESCENT priced every step by the anchor cell alone (grid.step_cost), so a 2x2 creature straddling rubble crossed at full speed; the descent now prices a step by the most difficult square under the footprint via _footprint_step_cost -> a new grid.step_cost_with_terrain (the 5-10-5 + elevation formula stays centralized in grid.py, step_cost delegates to it); 1x1 creatures are bit-identical (footprint == anchor cell); the direction Dijkstra field remains anchor-priced (making it creature-aware rewrites grid.reachable wholesale - the documented deferral); golden untouched (open-floor arena, no difficult terrain); +7 tests (trailing-square orthogonal/diagonal cost, 1x1 bit-identical regression, full-lane move budget 3-vs-6 squares, withdraw hampering)

This commit is contained in:
2026-09-20 00:29:36 +02:00
parent b775d46850
commit d347dde3fc
3 changed files with 239 additions and 7 deletions
+26 -2
View File
@@ -8378,7 +8378,7 @@ class CombatEngine:
and not self._has_free_descent(state, nxt, to_target, nxt_cost)
):
continue
step = self._grid.step_cost(current, nxt, diagonals_used)
step = self._footprint_step_cost(state, current, nxt, diagonals_used)
if step > budget:
continue
score = step + nxt_cost
@@ -8424,6 +8424,30 @@ class CombatEngine:
"""True if any footprint cell is difficult terrain (move cost > 1)."""
return any(self._is_difficult_cell(cell) for cell in footprint)
def _footprint_step_cost(
self, state: CombatantState, frm: Pos, to: Pos, diagonals_used: int
) -> int:
"""Cost of one anchor step priced by the WHOLE destination footprint.
SRD "Movement, Position, and Distance": "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 straddling
rubble is hampered even when its ANCHOR square is plain floor, so the
step is priced by the most difficult square the footprint would cover
at ``to`` (the 5-10-5 + elevation formula stays in ``grid.py``). For a
1x1 creature the footprint is the anchor cell, so this is exactly
``grid.step_cost`` - bit-identical to the old anchor-only pricing.
"""
footprint = self._footprint_at(to, state)
hardest = 1
for cell in footprint:
if not self._grid.in_bounds(cell):
continue
cost = self._grid.terrain_at(cell).move_cost
if cost is not None and cost > hardest:
hardest = cost
return self._grid.step_cost_with_terrain(frm, to, diagonals_used, hardest)
def _is_difficult_cell(self, pos: Pos) -> bool:
"""True for a passable cell whose terrain costs more than one square."""
if not self._grid.in_bounds(pos):
@@ -8494,7 +8518,7 @@ class CombatEngine:
nxt_cost = self._footprint_cost(footprint, from_threat)
if nxt_cost is None or nxt_cost <= current_cost:
continue
step = self._grid.step_cost(current, nxt, diagonals_used)
step = self._footprint_step_cost(state, current, nxt, diagonals_used)
if step > budget:
continue
score = -(nxt_cost * 100) + step
+20 -5
View File
@@ -108,14 +108,29 @@ class Grid:
if cost is None:
msg = f"cannot step into impassable square {to}"
raise ValueError(msg)
return self.step_cost_with_terrain(frm, to, diagonals_used, cost)
def step_cost_with_terrain(
self, frm: Pos, to: Pos, diagonals_used: int, cost: int
) -> int:
"""Cost of one step given an explicit destination terrain ``cost``.
The same 5-10-5 + elevation rule as ``step_cost`` (the single home of
that formula), but the caller supplies the terrain cost instead of
reading it from the anchor cell. This lets a multi-cell creature price
a step by the MOST DIFFICULT square its whole footprint would occupy
(SRD "Movement, Position, and Distance": "If you occupy squares with
different kinds of terrain, you can move only as fast as the most
difficult terrain you occupy will allow") rather than by the anchor
square alone.
"""
is_diagonal = frm[0] != to[0] and frm[1] != to[1]
climb = max(0, self.elevation_at(to) - self.elevation_at(frm))
if not is_diagonal:
return cost + max(0, self.elevation_at(to) - self.elevation_at(frm))
return cost + climb
if cost >= _DIFFICULT_TERRAIN_COST:
return 3 + max(0, self.elevation_at(to) - self.elevation_at(frm))
return 1 + (diagonals_used % 2) + max(
0, self.elevation_at(to) - self.elevation_at(frm)
)
return 3 + climb
return 1 + (diagonals_used % 2) + climb
def diagonal_allowed(self, frm: Pos, to: Pos) -> bool:
flank_a = (frm[0], to[1])
+193
View File
@@ -0,0 +1,193 @@
"""Multi-cell creatures move at the difficult rate under MIXED terrain (SRD).
SRD "Movement, Position, and Distance" (movement-position-and-distance-2.md):
"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
ANCHOR square is plain floor but whose TRAILING footprint square sits in rubble
is hampered exactly like a 1x1 creature standing in it.
The charge, the run and the 5-foot step already test the whole footprint
(``_footprint_touches_difficult``); the ``move``/withdraw DESCENTS previously
priced every step by the anchor cell alone (``grid.step_cost``), so a Large
creature straddling rubble crossed the board at full speed. The descent now
prices a step by the most difficult square under the footprint
(``_footprint_step_cost`` -> ``grid.step_cost_with_terrain``), keeping the
5-10-5 + elevation formula in ``grid.py``. For a 1x1 creature the footprint is
the anchor cell, so the cost is bit-identical to the old anchor-only pricing.
"""
from __future__ import annotations
from pf1e_simulator.combat import 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="club",
kind="melee",
attack_bonus=0,
damage=[DamageComponent(formula="1d6", types=["bludgeoning"])],
)
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=30,
ac=ACProfile(total=10, touch=10, flat_footed=10),
bab=0,
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 = 12) -> 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 _rubble_cols(cols: set[int], size: int = 12) -> Grid:
"""Open grid with FULL-HEIGHT rubble in the given columns (no escape
around the difficult lane: every footprint touching those columns is
hampered)."""
return _rubble_at(
{(r, c) for r in range(size) for c in cols},
size=size,
)
# ── the descent step cost itself ──────────────────────────────────────────────
def test_large_step_priced_by_trailing_rubble_square() -> None:
"""Large anchor at (0,0) covers rows 0-1, cols 0-1. Rubble at (1,1) is a
TRAILING footprint square: the anchor (0,1) is floor, so the old
anchor-only pricing charged 1 for the step (0,0)->(0,1); the SRD 'most
difficult terrain you occupy' rule charges the difficult rate (2)."""
large = _state(_combatant("ogre", size="Large"), "players", (0, 0))
engine = CombatEngine(ScriptedRng([]), _rubble_at({(1, 1)}), [large])
cost = engine._footprint_step_cost(large, (0, 0), (0, 1), 0)
assert cost == 2
def test_large_step_clean_footprint_stays_normal() -> None:
"""Same creature, rubble far off every footprint: the step is priced
normally (1) - the difficult rate only applies when the footprint touches
rubble."""
large = _state(_combatant("ogre", size="Large"), "players", (0, 0))
engine = CombatEngine(ScriptedRng([]), _rubble_at({(9, 9)}), [large])
cost = engine._footprint_step_cost(large, (0, 0), (0, 1), 0)
assert cost == 1
def test_medium_step_bit_identical_to_anchor_pricing() -> None:
"""1x1 regression: the footprint IS the anchor cell, so the footprint-aware
cost equals ``grid.step_cost`` exactly - into floor and into rubble."""
med = _state(_combatant("orc", size="Medium"), "players", (0, 0))
engine = CombatEngine(ScriptedRng([]), _rubble_at({(0, 2)}), [med])
assert engine._footprint_step_cost(med, (0, 0), (0, 1), 0) == engine._grid.step_cost(
(0, 0), (0, 1), 0
)
assert engine._footprint_step_cost(med, (0, 1), (0, 2), 0) == engine._grid.step_cost(
(0, 1), (0, 2), 0
)
def test_large_diagonal_step_priced_by_trailing_rubble() -> None:
"""Diagonal step: a footprint straddling rubble prices the diagonal at the
difficult-diagonal rate (3), matching grid.step_cost's own diagonal-into-
difficult rule (SRD: 'Each diagonal move into a difficult terrain square
counts as 3 squares')."""
large = _state(_combatant("ogre", size="Large"), "players", (0, 0))
engine = CombatEngine(ScriptedRng([]), _rubble_at({(1, 2)}), [large])
# (0,0)->(1,1): footprint rows 1-2, cols 1-2 covers the rubble cell (1,2).
cost = engine._footprint_step_cost(large, (0, 0), (1, 1), 0)
assert cost == 3
# ── the observable effect: the move budget ────────────────────────────────────
def test_large_straddling_rubble_cannot_cross_at_full_speed() -> None:
"""A Large creature (30 ft = 6 squares) advancing east through a full-
height rubble lane is hampered the whole way: it covers only 3 squares on a
6-square move (cost 2 per square), where the anchor-only pricing let it
cross 6. The foe far east keeps the move auto-directed straight down the
lane (no south escape: the rubble spans every row)."""
large = _state(_combatant("ogre", size="Large"), "players", (0, 0))
foe = _state(_combatant("archer", size="Medium"), "monsters", (0, 11))
engine = CombatEngine(
ScriptedRng([15, 1]),
_rubble_cols({0, 1, 2, 3, 4, 5, 6, 7}),
[large, foe],
)
engine._move(large, foe)
# 6-square budget / cost-2 squares = 3 squares of advance (cols 0 -> 3).
assert large.pos[1] == 3
def test_large_clean_lane_still_crosses_at_full_speed() -> None:
"""Same geometry with the rubble removed: the Large creature crosses its
full 6-square speed (the difficult rate never applies)."""
large = _state(_combatant("ogre", size="Large"), "players", (0, 0))
foe = _state(_combatant("archer", size="Medium"), "monsters", (0, 11))
engine = CombatEngine(ScriptedRng([15, 1]), _rubble_at(set()), [large, foe])
engine._move(large, foe)
assert large.pos[1] == 6
def test_large_straddling_rubble_withdraw_is_hampered() -> None:
"""The withdraw ascent shares the footprint-aware cost: a Large creature
retreating through a full-height rubble lane spends double the budget per
square, so its 2x-speed flee covers FEWER squares than the same flee on
clean ground (the difficult rate bites on every step)."""
large = _state(_combatant("ogre", size="Large"), "players", (5, 8))
threat = _state(_combatant("brute", size="Medium"), "monsters", (5, 10))
rubble_engine = CombatEngine(
ScriptedRng([]),
_rubble_cols(set(range(16)), size=16),
[large, threat],
)
rubble_path = rubble_engine._flee_path(large, threat)
large2 = _state(_combatant("ogre", size="Large"), "players", (5, 8))
threat2 = _state(_combatant("brute", size="Medium"), "monsters", (5, 10))
clean_engine = CombatEngine(
ScriptedRng([]), _rubble_at(set(), size=16), [large2, threat2]
)
clean_path = clean_engine._flee_path(large2, threat2)
# Same 12-square (2x30ft) budget; the rubble lane halves the squares.
assert len(rubble_path) < len(clean_path)
assert len(rubble_path) == 4 # 4 diagonal steps x cost-3 = 12 budget