From 2cc2ba190744872074d98ecb329c2a7afe6812ba Mon Sep 17 00:00:00 2001 From: ctan Date: Wed, 16 Sep 2026 13:15:49 +0200 Subject: [PATCH] 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). --- src/pf1e_simulator/combat.py | 43 +++++++++++++++++++++++++++----- tests/test_aid_and_run.py | 4 ++- tests/test_combat.py | 5 +++- tests/test_conditions_effects.py | 5 ++-- 4 files changed, 47 insertions(+), 10 deletions(-) diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 97b01f6..1663f93 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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 diff --git a/tests/test_aid_and_run.py b/tests/test_aid_and_run.py index 2228a7c..90b9f7e 100644 --- a/tests/test_aid_and_run.py +++ b/tests/test_aid_and_run.py @@ -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) diff --git a/tests/test_combat.py b/tests/test_combat.py index 3538077..7950e9d 100644 --- a/tests/test_combat.py +++ b/tests/test_combat.py @@ -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", diff --git a/tests/test_conditions_effects.py b/tests/test_conditions_effects.py index 44fd064..762a271 100644 --- a/tests/test_conditions_effects.py +++ b/tests/test_conditions_effects.py @@ -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)