feat(combat): add withdraw action (double move with AoO protection on first square)
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user