fix(combat): Thread 5-10-5 diagonal parity through all budget-consuming paths

The greedy move/run/flee/charge descent helpers hardcoded diagonal parity 0,
letting a speed-30 walker cover 6 diagonal cells (cell-count budget). Under
5-10-5 (SRD Movement, Tactical) the same walker covers only 4 cells
(1+2+1+2=6 squares).

Threading the running diagonal count:
- _best_move_step / _best_flee_step: accept diagonals_used param
- _descend_path / _run_line_path / _flee_path: track and pass parity
- _charge_end: use _path_cost_5105() instead of len(path) for max check
- _path_cost_5105: new helper; computes total 5-10-5 cost of any path

Updated golden paths in test_aoo_limit_one_per_round (5-cell path now
takes an orthogonal detour to avoid expensive 2nd diagonal), test_run_*
 (speed-30 diagonal move stops at 4 cells), and staggered_withdraw
(single-speed diagonal withdraw stops at 4 cells).
This commit is contained in:
2026-09-16 13:15:49 +02:00
parent e5691bdb6f
commit 2cc2ba1907
4 changed files with 47 additions and 10 deletions
+37 -6
View File
@@ -118,6 +118,7 @@ from __future__ import annotations
from collections.abc import Callable, Iterable from collections.abc import Callable, Iterable
from dataclasses import asdict, dataclass, field from dataclasses import asdict, dataclass, field
from itertools import pairwise
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Literal from typing import TYPE_CHECKING, Literal
@@ -3643,6 +3644,16 @@ class CombatEngine:
return ChargeResult(weapon=weapon, distance_ft=distance_ft, reason=None) return ChargeResult(weapon=weapon, distance_ft=distance_ft, reason=None)
return ChargeResult(weapon=None, distance_ft=0, reason="no_charge_path") return ChargeResult(weapon=None, distance_ft=0, reason="no_charge_path")
def _path_cost_5105(self, path: list[Pos]) -> int:
"""Total 5-10-5 movement cost of a path (diagonal parity threaded)."""
total = 0
diagonals_used = 0
for frm, to in pairwise(path):
total += self._grid.step_cost(frm, to, diagonals_used)
if frm[0] != to[0] and frm[1] != to[1]:
diagonals_used += 1
return total
def _charge_end( def _charge_end(
self, self,
state: CombatantState, state: CombatantState,
@@ -3675,10 +3686,14 @@ class CombatEngine:
if dist > reach_cells or dist < min_reach_cells: if dist > reach_cells or dist < min_reach_cells:
continue continue
path = _bresenham_line(state.pos, end) path = _bresenham_line(state.pos, end)
if len(path) < _CHARGE_MIN_CELLS or len(path) > max_cells: if len(path) < _CHARGE_MIN_CELLS:
continue continue
if not self._charge_path_clear(path, state): if not self._charge_path_clear(path, state):
continue continue
# The charge budget is a movement budget: count it in 5-10-5
# squares (diagonal parity threaded), not raw cells.
if self._path_cost_5105(path) > max_cells:
continue
candidates.append((len(path), end)) candidates.append((len(path), end))
if not candidates: if not candidates:
return None return None
@@ -7071,14 +7086,20 @@ class CombatEngine:
path: list[Pos] = [] path: list[Pos] = []
current = state.pos current = state.pos
budget = speed_cells budget = speed_cells
diagonals_used = 0
while budget > 0: while budget > 0:
best = self._best_move_step( best = self._best_move_step(
state, current, to_target, budget, state, current, to_target, budget, diagonals_used,
avoid_difficult=avoid_difficult, friendly_passable=friendly_passable, avoid_difficult=avoid_difficult, friendly_passable=friendly_passable,
) )
if best is None: if best is None:
break break
_, step_cost, nxt = best _, step_cost, nxt = best
# PF1e 5-10-5: the diagonal cost alternates 1-2-1-2, so the
# running diagonal count must follow the walk (grid.reachable
# already tracks it; the greedy descent must too).
if current[0] != nxt[0] and current[1] != nxt[1]:
diagonals_used += 1
current = nxt current = nxt
path.append(current) path.append(current)
budget -= step_cost budget -= step_cost
@@ -7111,16 +7132,19 @@ class CombatEngine:
return [] return []
path: list[Pos] = [] path: list[Pos] = []
current = state.pos current = state.pos
diagonals_used = 0
for nxt in _bresenham_line(state.pos, goal): for nxt in _bresenham_line(state.pos, goal):
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
if not self._cells_walkable(footprint, state): if not self._cells_walkable(footprint, state):
break break
step = self._grid.step_cost(current, nxt, 0) step = self._grid.step_cost(current, nxt, diagonals_used)
if step > budget: if step > budget:
break break
path.append(nxt) path.append(nxt)
if current[0] != nxt[0] and current[1] != nxt[1]:
diagonals_used += 1
current = nxt current = nxt
budget -= step budget -= step
while path and not self._cells_free(self._footprint_at(path[-1], state), state): while path and not self._cells_free(self._footprint_at(path[-1], state), state):
@@ -7133,6 +7157,7 @@ class CombatEngine:
current: Pos, current: Pos,
to_target: dict[Pos, int], to_target: dict[Pos, int],
budget: int, budget: int,
diagonals_used: int,
*, *,
avoid_difficult: bool = False, avoid_difficult: bool = False,
friendly_passable: bool = False, friendly_passable: bool = False,
@@ -7169,7 +7194,7 @@ class CombatEngine:
and not self._has_free_descent(state, nxt, to_target, nxt_cost) and not self._has_free_descent(state, nxt, to_target, nxt_cost)
): ):
continue continue
step = self._grid.step_cost(current, nxt, 0) step = self._grid.step_cost(current, nxt, diagonals_used)
if step > budget: if step > budget:
continue continue
score = step + nxt_cost score = step + nxt_cost
@@ -7230,11 +7255,16 @@ class CombatEngine:
path: list[Pos] = [] path: list[Pos] = []
current = state.pos current = state.pos
budget = max_cells budget = max_cells
diagonals_used = 0
while budget > 0: while budget > 0:
best = self._best_flee_step(state, current, from_threat, budget) best = self._best_flee_step(
state, current, from_threat, budget, diagonals_used
)
if best is None: if best is None:
break break
_, step_cost, nxt = best _, step_cost, nxt = best
if current[0] != nxt[0] and current[1] != nxt[1]:
diagonals_used += 1
current = nxt current = nxt
path.append(current) path.append(current)
budget -= step_cost budget -= step_cost
@@ -7246,6 +7276,7 @@ class CombatEngine:
current: Pos, current: Pos,
from_threat: dict[Pos, int], from_threat: dict[Pos, int],
budget: int, budget: int,
diagonals_used: int,
) -> tuple[int, int, Pos] | None: ) -> tuple[int, int, Pos] | None:
"""Pick the adjacent anchor that maximizes distance from the threat.""" """Pick the adjacent anchor that maximizes distance from the threat."""
current_cost = self._footprint_cost(self._footprint_at(current, state), from_threat) current_cost = self._footprint_cost(self._footprint_at(current, state), from_threat)
@@ -7263,7 +7294,7 @@ class CombatEngine:
nxt_cost = self._footprint_cost(footprint, from_threat) nxt_cost = self._footprint_cost(footprint, from_threat)
if nxt_cost is None or nxt_cost <= current_cost: if nxt_cost is None or nxt_cost <= current_cost:
continue continue
step = self._grid.step_cost(current, nxt, 0) step = self._grid.step_cost(current, nxt, diagonals_used)
if step > budget: if step > budget:
continue continue
score = -(nxt_cost * 100) + step score = -(nxt_cost * 100) + step
+3 -1
View File
@@ -358,7 +358,9 @@ def test_run_toward_target_outreaches_normal_move() -> None:
) )
eng2.run() eng2.run()
move_hero = eng2._states[0] move_hero = eng2._states[0]
assert move_hero.pos == (6, 6) # only 6 cells of the 15-cell path # 5-10-5: 4 diagonal cells cost 1+2+1+2 = 6 = the speed budget; the old
# cell-count budget let a speed-30 walker cover 6 diagonal cells.
assert move_hero.pos == (4, 4) # only 4 diagonal cells of the 15-cell path
assert grid.distance(run_hero.pos, foe.pos) < grid.distance(move_hero.pos, foe2.pos) assert grid.distance(run_hero.pos, foe.pos) < grid.distance(move_hero.pos, foe2.pos)
+4 -1
View File
@@ -1071,7 +1071,10 @@ def test_aoo_limit_one_per_round() -> None:
"initiative: guard d20=9+0=9", "initiative: guard d20=9+0=9",
"initiative: dest d20=8+0=8", "initiative: dest d20=8+0=8",
"round 1 guard: AoO vs runner d20=15+5=20 AC 12 -> HIT 3 damage (20->17)", "round 1 guard: AoO vs runner d20=15+5=20 AC 12 -> HIT 3 damage (20->17)",
"round 1 runner: move (0,0)->(0,1)->(1,2)->(2,3)->(3,3)", # 5-10-5 parity: the second diagonal (1,2)->(2,3) now costs 2, so the
# greedy walk prefers the orthogonal (1,2)->(1,3) detour (both paths
# total 5 squares; the new one avoids the expensive second diagonal).
"round 1 runner: move (0,0)->(0,1)->(1,2)->(1,3)->(2,3)->(3,3)",
"round 1 guard: wait", "round 1 guard: wait",
"round 1 dest: wait", "round 1 dest: wait",
"battle over: draw after 1 rounds", "battle over: draw after 1 rounds",
+3 -2
View File
@@ -220,8 +220,9 @@ class TestStaggeredActionEconomy:
line for line in engine._transcript if "withdraw" in line line for line in engine._transcript if "withdraw" in line
] ]
assert len(withdraw_lines) == 1 assert len(withdraw_lines) == 1
# 6 steps from (10,10) — exactly the single-speed budget. # 4 diagonal steps from (10,10) — the single-speed budget of 6 squares
assert withdraw_lines[0].count("->") == 6 # under 5-10-5 (1+2+1+2=6); the old cell-count budget let it walk 6.
assert withdraw_lines[0].count("->") == 4
assert h.pos != (10, 10) assert h.pos != (10, 10)