"""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