diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 42e1af5..960cf74 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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] diff --git a/tests/test_charge_footprint_terrain.py b/tests/test_charge_footprint_terrain.py new file mode 100644 index 0000000..6ddd4ee --- /dev/null +++ b/tests/test_charge_footprint_terrain.py @@ -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