"""Charge and run obey the diagonal corner rule (SRD "Measuring Distance"). SRD: "You can't move diagonally past a corner (even by taking a 5-foot step)." The Dijkstra descent already enforces it (``_best_move_step`` -> ``grid.diagonal_allowed``); the charge's clear-path walk (``_charge_path_clear``) and the run's straight-line walk (``_run_line_path``) must enforce the same rule, or a charge/run can cut diagonally through a wall corner (SRD "Charge": "If any line from your starting space to the ending space passes through a square that blocks movement [...] you can't charge"). The engine's corner rule requires BOTH flanks passable for every diagonal step (documented stricter-than-RAW approximation, commit fb6f58a) - the same predicate ``grid.diagonal_allowed`` the Dijkstra paths use. """ from __future__ import annotations from pf1e_simulator.combat import Action, 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, *, speed: int = 30, attack_bonus: int = 5, kind: str = "melee" ) -> Combatant: attack = AttackSpec( id=f"{cid}-w", name="short sword", kind=kind, attack_bonus=attack_bonus, damage=[DamageComponent(formula="1d6", types=["slashing"])], ) return Combatant( id=cid, name=cid, level=1, size="Medium", 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=attack_bonus, 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 _big_corner_grid(size: int = 8) -> Grid: """Walls at (0,1) and (1,0) on an open size x size grid.""" legend = { ".": TerrainType(type="floor", move_cost=1), "#": TerrainType(type="wall", move_cost=None, blocks_los=True), } rows = [list("." * size) for _ in range(size)] rows[0][1] = "#" rows[1][0] = "#" spec = MapSpec( name="corner", terrain=tuple("".join(r) for r in rows), legend=legend, ) return Grid.from_spec(spec) # --- charge ------------------------------------------------------------------ def test_charge_cannot_cut_a_wall_corner() -> None: """Attacker (0,0), foe (2,2), walls (0,1)+(1,0): every charge endpoint needs the (0,0)->(1,1) diagonal whose flanks are both walls -> no charge path (before the fix the walk cut the corner and charged).""" a = _state(_combatant("a"), "players", (0, 0)) b = _state(_combatant("b"), "monsters", (2, 2)) engine = CombatEngine(ScriptedRng([]), _big_corner_grid(), [a, b]) result = engine.can_charge(a, b) assert isinstance(result, ChargeResult) assert result.weapon is None assert result.reason == "no_charge_path" def test_executed_charge_does_not_move_through_a_corner() -> None: """Executing the charge action leaves the attacker in place (no path).""" a = _state(_combatant("a"), "players", (0, 0)) b = _state(_combatant("b"), "monsters", (2, 2)) engine = CombatEngine(ScriptedRng([]), _big_corner_grid(), [a, b]) engine._execute(a, Action(kind="charge", target_id="b")) assert a.pos == (0, 0) assert not a.moved_this_turn def test_charge_still_works_when_the_corner_flanks_are_open() -> None: """Regression: the same geometry without the walls charges normally.""" a = _state(_combatant("a"), "players", (0, 0)) b = _state(_combatant("b"), "monsters", (2, 2)) legend = {".": TerrainType(type="floor", move_cost=1)} open_grid = Grid.from_spec( MapSpec(name="open", terrain=tuple(["." * 8] * 8), legend=legend), ) engine = CombatEngine(ScriptedRng([]), open_grid, [a, b]) result = engine.can_charge(a, b) assert isinstance(result, ChargeResult) assert result.weapon is not None assert result.reason is None # --- run --------------------------------------------------------------------- def test_run_rejects_a_destination_across_a_wall_corner() -> None: """Run to (1,1) from (0,0) with walls (0,1)+(1,0): the first step is a corner-cutting diagonal -> the straight line cannot honour it.""" a = _state(_combatant("a"), "players", (0, 0)) b = _state(_combatant("b"), "monsters", (5, 5)) engine = CombatEngine(ScriptedRng([]), _big_corner_grid(), [a, b]) result = engine._execute(a, Action(kind="run", destination=(1, 1))) assert result.ok is False assert result.reason == "unreachable_destination" assert a.pos == (0, 0) def test_run_toward_a_foe_beyond_a_corner_stops_in_place() -> None: """Auto-directed run toward (2,2): the walk halts at the corner (0,0) and the combatant is still marked running for the AC penalty.""" a = _state(_combatant("a"), "players", (0, 0)) b = _state(_combatant("b"), "monsters", (2, 2)) engine = CombatEngine(ScriptedRng([]), _big_corner_grid(), [a, b]) engine._execute(a, Action(kind="run", target_id="b")) assert a.pos == (0, 0) assert a.running is True def test_run_straight_line_without_corner_blockage_still_works() -> None: """Regression: an open diagonal run reaches its destination.""" a = _state(_combatant("a"), "players", (0, 0)) b = _state(_combatant("b"), "monsters", (5, 5)) legend = {".": TerrainType(type="floor", move_cost=1)} open_grid = Grid.from_spec( MapSpec(name="open", terrain=tuple(["." * 8] * 8), legend=legend), ) engine = CombatEngine(ScriptedRng([]), open_grid, [a, b]) result = engine._execute(a, Action(kind="run", destination=(4, 4))) assert result.ok is True assert a.pos == (4, 4)