From d347dde3fcf2bf50941c0c68b967df4e00c41ed2 Mon Sep 17 00:00:00 2001 From: ctan Date: Sun, 20 Sep 2026 00:29:36 +0200 Subject: [PATCH] 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) --- src/pf1e_simulator/combat.py | 28 +++- src/pf1e_simulator/grid.py | 25 +++- tests/test_move_footprint_terrain.py | 193 +++++++++++++++++++++++++++ 3 files changed, 239 insertions(+), 7 deletions(-) create mode 100644 tests/test_move_footprint_terrain.py diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 16050e0..2e5a985 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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 diff --git a/src/pf1e_simulator/grid.py b/src/pf1e_simulator/grid.py index b986ee2..7cfb055 100644 --- a/src/pf1e_simulator/grid.py +++ b/src/pf1e_simulator/grid.py @@ -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]) diff --git a/tests/test_move_footprint_terrain.py b/tests/test_move_footprint_terrain.py new file mode 100644 index 0000000..1d5cf78 --- /dev/null +++ b/tests/test_move_footprint_terrain.py @@ -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