From 5e478bfd27086f7b736b87bba71eefb4a5802ca1 Mon Sep 17 00:00:00 2001 From: ctan Date: Sat, 19 Sep 2026 15:44:12 +0200 Subject: [PATCH] fix(rules): charge and run walks enforce the diagonal corner rule (SRD measuring-distance.md: 'You can't move diagonally past a corner') - _charge_path_clear and _run_line_path never checked diagonal_allowed, so a charge/run could cut diagonally through a wall corner; both walks now stop/reject a diagonal step whose flanks are not both passable, same predicate the Dijkstra descent uses --- README.md | 4 +- src/pf1e_simulator/combat.py | 36 +++++++- tests/test_charge_run_corner.py | 159 ++++++++++++++++++++++++++++++++ 3 files changed, 196 insertions(+), 3 deletions(-) create mode 100644 tests/test_charge_run_corner.py diff --git a/README.md b/README.md index e1549fd..353410b 100644 --- a/README.md +++ b/README.md @@ -894,7 +894,9 @@ Règles modélisées : frapper la cible en mêlée, puis porte une seule attaque de mêlée à +2. La charge impose −2 CA à l'attaquant jusqu'au début de son prochain tour. La ligne droite doit être dégagée (pas de terrain difficile, d'obstacles ni de - créatures) ; distance minimum 2 cases (10 ft). `default_policy` choisit la + créatures) et ne peut pas couper un coin de mur (règle des coins, SRD + « Measuring Distance » : « You can't move diagonally past a corner ») ; + distance minimum 2 cases (10 ft). `default_policy` choisit la charge quand l'ennemi est hors de portée mais joignable en ligne droite. - Retraite : action à round complet. Le combattant se déplace jusqu'à 2× sa vitesse en s'éloignant de l'ennemi le plus proche (ascension gloutonne du diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 407f3c0..42e1af5 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -3988,13 +3988,33 @@ class CombatEngine: def _charge_path_clear( self, path: list[Pos], state: CombatantState ) -> bool: - """True if the footprint fits at every anchor on the charge path.""" + """True if the footprint fits at every anchor on the charge path. + + PF1e (SRD "Measuring Distance"): "You can't move diagonally past a + corner" - a diagonal step whose two flanking squares are not both + passable is illegal, so a charge may never cut a wall corner (the + same corner rule the Dijkstra descent enforces in + ``_best_move_step``). PF1e (SRD "Charge"): "If any line from your + starting space to the ending space passes through a square that + 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. + """ + prev = state.pos for pos in path: if not self._cells_free(self._footprint_at(pos, state), state): return False terrain = self._grid.terrain_at(pos) if terrain.move_cost is not None and terrain.move_cost > 1: return False + if ( + prev[0] != pos[0] + and prev[1] != pos[1] + and not self._grid.diagonal_allowed(prev, pos) + ): + return False + prev = pos return True def _charge( @@ -7860,7 +7880,9 @@ class CombatEngine: speed budget and stopped at the first obstacle, mirroring the charge's straight-line clear-path logic (CRB "Charge"): impassable terrain, a difficult-terrain cell (CRB "Difficult Terrain": "You can't run or - charge across difficult terrain"), or a blocking creature. A hostile + charge across difficult terrain"), a diagonal step past a blocked + corner (SRD "Measuring Distance": "You can't move diagonally past a + corner"), or a blocking creature. A hostile creature (including the target itself) halts the walk one cell short, so an auto-directed run ends adjacent to the target — except one the mover may pass through (three size categories apart, SRD "Moving @@ -7879,6 +7901,16 @@ class CombatEngine: current = state.pos diagonals_used = 0 for nxt in _bresenham_line(state.pos, goal): + # PF1e (SRD "Measuring Distance"): "You can't move diagonally + # past a corner" - a diagonal step with a blocked flank is + # illegal, so a run stops at the corner just like an obstacle + # (same rule _best_move_step enforces for the Dijkstra descent). + if ( + current[0] != nxt[0] + and current[1] != nxt[1] + and not self._grid.diagonal_allowed(current, nxt) + ): + break footprint = self._footprint_at(nxt, state) if self._footprint_touches_difficult(footprint): break diff --git a/tests/test_charge_run_corner.py b/tests/test_charge_run_corner.py new file mode 100644 index 0000000..dca8ca9 --- /dev/null +++ b/tests/test_charge_run_corner.py @@ -0,0 +1,159 @@ +"""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)