feat(combat): add withdraw action (double move with AoO protection on first square)

This commit is contained in:
2026-08-17 22:49:50 +02:00
parent 7543c55ed4
commit 5b1957152c
3 changed files with 172 additions and 14 deletions
+99 -5
View File
@@ -9,9 +9,9 @@ Phase 0 documented deviations from PF1e (conventions):
full speed along the Dijkstra path, then attack if a weapon is usable) and
`(attack,)` when already in range. The move action may replace the standard
action (e.g. a double move) but the default policy does not need this.
Immediate actions (off-turn, consume next swift) and special actions
(withdraw) are deferred; flanking, full attack, and charge are modeled;
attacks of opportunity are resolved inline (see below).
Immediate actions (off-turn, consume next swift) are deferred; flanking,
full attack, charge, and withdraw are modeled; attacks of opportunity are
resolved inline (see below).
- A weapon with `count` > 1 ("2x Talons") resolves `count` independent attacks
in one attack action; remaining swings are lost once the target drops
(down or dead) mid-routine. BAB iterative attacks (full-round action
@@ -44,7 +44,8 @@ Phase 0 documented deviations from PF1e (conventions):
threatening enemy gets one AoO before each ranged swing. One AoO per
combatant per round (tracked in ``_aoo_used``, cleared at round start);
AoOs always strike (PF1e allows declining, the simulator does not). A
5-foot step and the withdraw action (deferred) avoid AoOs from movement.
5-foot step (deferred) avoids AoOs from movement; the withdraw action
protects only the starting square (see below).
- Charge: a full-round action that moves in a straight line (Bresenham) up to
2x speed toward the closest square from which the charger can melee the
target, then makes a single melee attack at +2. The charger takes -2 AC
@@ -53,6 +54,12 @@ Phase 0 documented deviations from PF1e (conventions):
obstacles, and creatures; minimum 2 cells (10 ft). ``default_policy``
chooses a charge when the enemy is out of reach but a valid charge path
exists.
- Withdraw: a full-round action that moves up to 2x speed away from the
nearest enemy. The starting square is not threatened — no AoO when leaving
it. Subsequent squares provoke AoOs normally (resolved step-by-step, as for
move). The path is a greedy ascent of the Dijkstra cost field from the
threat. ``default_policy`` does not choose withdraw (it is only available
via a custom policy).
- Ranged: cumulative -2 per full range increment beyond the first, up to 10
range increments; the penalty applies to attack and crit-confirm rolls.
Thrown weapons (5 increments max) are not distinguished.
@@ -185,7 +192,7 @@ class Action:
kind: Literal[
"attack", "move", "wait", "swift", "free", "immediate", "full_round",
"full_attack", "charge",
"full_attack", "charge", "withdraw",
]
target_id: str | None = None
@@ -601,6 +608,8 @@ class CombatEngine:
self._execute_attack(state, target, action.kind)
elif action.kind == "charge":
self._execute_charge(state, target)
elif action.kind == "withdraw":
self._withdraw(state, target)
elif action.kind == "move":
if target is None:
return
@@ -768,6 +777,91 @@ class CombatEngine:
budget -= best[1]
return path
def _flee_path(self, state: CombatantState, threat: CombatantState) -> list[Pos]:
"""Movement path away from ``threat``, up to double speed.
Uses the Dijkstra cost field from the threat and greedily picks the
step that maximizes distance (cost) from the threat each cell.
"""
max_cells = 2 * (state.combatant.speed_land_ft // _SQUARE_FT)
if max_cells <= 0:
return []
blocked = frozenset(s.pos for s in self._states if s is not state and s.active)
from_threat = self._grid.reachable(threat.pos, None, blocked)
if state.pos not in from_threat:
return []
path: list[Pos] = []
current = state.pos
budget = max_cells
while budget > 0:
best = self._best_flee_step(current, from_threat, blocked, budget)
if best is None:
break
_, step_cost, nxt = best
current = nxt
path.append(current)
budget -= step_cost
return path
def _best_flee_step(
self,
current: Pos,
from_threat: dict[Pos, int],
blocked: frozenset[Pos],
budget: int,
) -> tuple[int, int, Pos] | None:
"""Pick the adjacent square that maximizes distance from the threat."""
current_cost = from_threat[current]
best: tuple[int, int, Pos] | None = None
row, col = current
for d_row, d_col in _STEP_DELTAS:
nxt = (row + d_row, col + d_col)
if nxt in blocked or not self._grid.passable(nxt):
continue
if not self._grid.diagonal_allowed(current, nxt):
continue
nxt_cost = from_threat.get(nxt)
if nxt_cost is None or nxt_cost <= current_cost:
continue
step = self._grid.step_cost(current, nxt, 0)
if step > budget:
continue
score = -(nxt_cost * 100) + step
if best is None or score < best[0]:
best = (score, step, nxt)
return best
def _withdraw(self, state: CombatantState, threat: CombatantState | None) -> None:
"""Full-round action: move up to 2x speed away from the nearest enemy.
The starting square is not threatened — no AoO when leaving it.
Subsequent squares provoke AoOs normally (step-by-step, as for move).
"""
if threat is None:
return
path = self._flee_path(state, threat)
if not path:
return
start = state.pos
actual_path: list[Pos] = []
for i, step in enumerate(path):
if i > 0:
for enemy in self._enemies_threatening(state, state.pos):
if enemy.combatant.id in self._aoo_used:
continue
self._resolve_aoo(enemy, state)
if not state.active:
break
if not state.active:
break
state.pos = step
actual_path.append(step)
if actual_path:
coords = "->".join(f"({p[0]},{p[1]})" for p in (start, *actual_path))
self._log(
f"round {self._current_round} {state.combatant.id}: withdraw {coords}"
)
def _log(self, line: str) -> None:
self._transcript.append(line)