Files
pf1e-simulator/tests/test_charge_run_corner.py
T

160 lines
6.0 KiB
Python

"""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)