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

This commit is contained in:
2026-09-19 15:44:12 +02:00
parent 27ce33ba67
commit 5e478bfd27
3 changed files with 196 additions and 3 deletions
+3 -1
View File
@@ -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 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 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 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. 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 - 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 vitesse en s'éloignant de l'ennemi le plus proche (ascension gloutonne du
+34 -2
View File
@@ -3988,13 +3988,33 @@ class CombatEngine:
def _charge_path_clear( def _charge_path_clear(
self, path: list[Pos], state: CombatantState self, path: list[Pos], state: CombatantState
) -> bool: ) -> 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: for pos in path:
if not self._cells_free(self._footprint_at(pos, state), state): if not self._cells_free(self._footprint_at(pos, state), state):
return False return False
terrain = self._grid.terrain_at(pos) terrain = self._grid.terrain_at(pos)
if terrain.move_cost is not None and terrain.move_cost > 1: if terrain.move_cost is not None and terrain.move_cost > 1:
return False 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 return True
def _charge( def _charge(
@@ -7860,7 +7880,9 @@ class CombatEngine:
speed budget and stopped at the first obstacle, mirroring the charge's speed budget and stopped at the first obstacle, mirroring the charge's
straight-line clear-path logic (CRB "Charge"): impassable terrain, a straight-line clear-path logic (CRB "Charge"): impassable terrain, a
difficult-terrain cell (CRB "Difficult Terrain": "You can't run or 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, creature (including the target itself) halts the walk one cell short,
so an auto-directed run ends adjacent to the target — except one the so an auto-directed run ends adjacent to the target — except one the
mover may pass through (three size categories apart, SRD "Moving mover may pass through (three size categories apart, SRD "Moving
@@ -7879,6 +7901,16 @@ class CombatEngine:
current = state.pos current = state.pos
diagonals_used = 0 diagonals_used = 0
for nxt in _bresenham_line(state.pos, goal): 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) footprint = self._footprint_at(nxt, state)
if self._footprint_touches_difficult(footprint): if self._footprint_touches_difficult(footprint):
break break
+159
View File
@@ -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)