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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user