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 dataclasses import asdict, dataclass, field
from itertools import pairwise
from pathlib import Path
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=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(
self,
state: CombatantState,
@@ -3675,10 +3686,14 @@ class CombatEngine:
if dist > reach_cells or dist < min_reach_cells:
continue
path = _bresenham_line(state.pos, end)
if len(path) < _CHARGE_MIN_CELLS or len(path) > max_cells:
if len(path) < _CHARGE_MIN_CELLS:
continue
if not self._charge_path_clear(path, state):
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))
if not candidates:
return None
@@ -7071,14 +7086,20 @@ class CombatEngine:
path: list[Pos] = []
current = state.pos
budget = speed_cells
diagonals_used = 0
while budget > 0:
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,
)
if best is None:
break
_, 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
path.append(current)
budget -= step_cost
@@ -7111,16 +7132,19 @@ class CombatEngine:
return []
path: list[Pos] = []
current = state.pos
diagonals_used = 0
for nxt in _bresenham_line(state.pos, goal):
footprint = self._footprint_at(nxt, state)
if self._footprint_touches_difficult(footprint):
break
if not self._cells_walkable(footprint, state):
break
step = self._grid.step_cost(current, nxt, 0)
step = self._grid.step_cost(current, nxt, diagonals_used)
if step > budget:
break
path.append(nxt)
if current[0] != nxt[0] and current[1] != nxt[1]:
diagonals_used += 1
current = nxt
budget -= step
while path and not self._cells_free(self._footprint_at(path[-1], state), state):
@@ -7133,6 +7157,7 @@ class CombatEngine:
current: Pos,
to_target: dict[Pos, int],
budget: int,
diagonals_used: int,
*,
avoid_difficult: bool = False,
friendly_passable: bool = False,
@@ -7169,7 +7194,7 @@ class CombatEngine:
and not self._has_free_descent(state, nxt, to_target, nxt_cost)
):
continue
step = self._grid.step_cost(current, nxt, 0)
step = self._grid.step_cost(current, nxt, diagonals_used)
if step > budget:
continue
score = step + nxt_cost
@@ -7230,11 +7255,16 @@ class CombatEngine:
path: list[Pos] = []
current = state.pos
budget = max_cells
diagonals_used = 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:
break
_, step_cost, nxt = best
if current[0] != nxt[0] and current[1] != nxt[1]:
diagonals_used += 1
current = nxt
path.append(current)
budget -= step_cost
@@ -7246,6 +7276,7 @@ class CombatEngine:
current: Pos,
from_threat: dict[Pos, int],
budget: int,
diagonals_used: int,
) -> tuple[int, int, Pos] | None:
"""Pick the adjacent anchor that maximizes distance from the 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)
if nxt_cost is None or nxt_cost <= current_cost:
continue
step = self._grid.step_cost(current, nxt, 0)
step = self._grid.step_cost(current, nxt, diagonals_used)
if step > budget:
continue
score = -(nxt_cost * 100) + step
+3 -1
View File
@@ -358,7 +358,9 @@ def test_run_toward_target_outreaches_normal_move() -> None:
)
eng2.run()
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)
+4 -1
View File
@@ -1071,7 +1071,10 @@ def test_aoo_limit_one_per_round() -> None:
"initiative: guard d20=9+0=9",
"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 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 dest: wait",
"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
]
assert len(withdraw_lines) == 1
# 6 steps from (10,10) — exactly the single-speed budget.
assert withdraw_lines[0].count("->") == 6
# 4 diagonal steps from (10,10) — the single-speed budget of 6 squares
# 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)