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