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