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
+16 -9
View File
@@ -272,10 +272,9 @@ Règles modélisées :
`default_policy` renvoie `(move, attack)` quand l'ennemi le plus proche est
hors de portée (déplacement à pleine vitesse le long du chemin Dijkstra,
puis attaque si une arme est utilisable) et `(attack,)` sinon. Les actions
immédiates (hors-tour, consomment le prochain swift) et les actions
spéciales (retraite) ne sont pas encore modélisées. `default_policy`
choisit la charge quand l'ennemi est hors de portée mais joignable par
une ligne droite (voir ci-dessous).
immédiates (hors-tour, consomment le prochain swift) ne sont pas encore
modélisées. `default_policy` choisit la charge quand l'ennemi est hors de
portée mais joignable par une ligne droite (voir ci-dessous).
- Attaques multiples par action : une arme avec `count` > 1 (« 2x Talons »)
résout `count` balayages indépendants dans la même action ; les balayages
restants sont perdus si la cible tombe (inconsciente ou morte) en cours de
@@ -307,7 +306,8 @@ Règles modélisées :
porte une AoO avant chaque tir. Une AoO par combattant par round (piste
`_aoo_used`, réinitialisée au début du round) ; les AoO sont toujours portées
(PF1e permet de les décliner, le simulateur ne le fait pas). Le pas de
placement (5 ft) et la retraite (à venir) évitent les AoO de mouvement.
placement (5 ft, à venir) évite les AoO de mouvement ; la retraite ne
protège que la case de départ (voir ci-dessous).
- Charge : action à round complet. Le combattant se déplace en ligne droite
(Bresenham) jusqu'à 2× sa vitesse vers la case la plus proche d'où il peut
frapper la cible en mêlée, puis porte une seule attaque de mêlée à +2. La
@@ -315,6 +315,13 @@ Règles modélisées :
ligne droite doit être dégagée (pas de terrain difficile, d'obstacles ni de
créatures) ; distance minimum 2 cases (10 ft). `default_policy` choisit la
charge quand l'ennemi est hors de portée mais joignable en ligne droite.
- Retraite : action à round complet. Le combattant se déplace jusqu'à 2× sa
vitesse en s'éloignant de l'ennemi le plus proche (ascension gloutonne du
champ de coût Dijkstra depuis la menace). La case de départ n'est pas
considérée comme menacée — aucune AoO en la quittant. Les cases
ultérieures provoquent des AoO normalement (résolues pas à pas, comme pour
le mouvement). `default_policy` ne choisit pas la retraite (disponible via
une politique personnalisée).
- Distance : pénalité cumulative de 2 par incrément de portée complet au-delà
du premier, appliquée au jet d'attaque et à la confirmation de critique ;
l'arme ranged reste utilisable jusqu'à 10 incréments. Les armes de jet
@@ -326,8 +333,8 @@ appliquées dans la résolution) :
- Sorts, jets de sauvegarde, conditions et états.
- Manœuvres de combat.
- Effets mécaniques de hauteur/élévation.
- Tailles Large+ (2×2), allonge > 5 ft, actions spéciales
(pas de placement, retraite) et actions immédiates hors-tour.
- Tailles Large+ (2×2), allonge > 5 ft, pas de placement (5 ft)
et actions immédiates hors-tour.
## Architecture
@@ -361,7 +368,7 @@ appliquées dans la résolution) :
La gate de validation complète (tests + lint + types) :
```bash
uv run pytest -q # 213 tests
uv run pytest -q # 215 tests
uv run ruff check src tests
uv run basedpyright src # mode strict
```
@@ -376,7 +383,7 @@ uv run basedpyright src # mode strict
- **Phase 1** — magie et états : jets de sauvegarde, sorts modélisés comme
effets paramétrés, conditions, manœuvres de combat. Flanquement, attaques à
outrance et attaques d'opportunité sont déjà modélisés.
outrance, attaques d'opportunité, charge et retraite sont déjà modélisés.
- **Phase 2** — couche tactique LLM : stratégies en langage naturel traduites
en politiques, balayage de matrices de positionnement.
- **Phase 3** — rapporteur LLM local : agrégation des statistiques et
+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)
+57
View File
@@ -1145,3 +1145,60 @@ def test_charge_too_close_full_attacks_instead() -> None:
"round 1 orc: short sword vs gob d20=3+0=3 AC 15 -> MISS",
"battle over: draw after 1 rounds",
)
# --------------------------------------------------------------------------- #
# Withdraw (CRB: double move, first square protected, subsequent provoke) #
# --------------------------------------------------------------------------- #
def _withdraw_from_foe(
engine: CombatEngine, state: CombatantState
) -> tuple[Action, ...]:
return (Action(kind="withdraw", target_id="foe"),)
def test_withdraw_protects_first_square() -> None:
"""Withdraw moves 2x speed away; the starting square does not provoke AoO."""
flee = make_combatant("flee", hp=20, ac=15, attack_bonus=0, speed=30)
foe = make_combatant("foe", hp=20, ac=15, attack_bonus=5, speed=0)
states = [make_state(flee, "players", (4, 1)), make_state(foe, "monsters", (3, 1))]
engine = CombatEngine(
ScriptedRng([10, 9, 15, 3]),
make_grid(), states, round_cap=1, policy=_withdraw_from_foe,
)
result = engine.run()
assert result.transcript == (
"initiative: flee d20=10+0=10",
"initiative: foe d20=9+0=9",
"round 1 flee: withdraw (4,1)->(5,0)->(6,0)->(7,0)",
"round 1 foe: wait",
"battle over: draw after 1 rounds",
)
def test_withdraw_provokes_after_first_square() -> None:
"""Subsequent squares during withdraw provoke AoOs from other enemies."""
flee = make_combatant("flee", hp=20, ac=12, attack_bonus=0, speed=30)
foe = make_combatant("foe", hp=20, ac=15, attack_bonus=5, speed=0)
foe2 = make_combatant("foe2", hp=20, ac=15, attack_bonus=5, speed=0)
states = [
make_state(flee, "players", (4, 1)),
make_state(foe, "monsters", (3, 1)),
make_state(foe2, "monsters", (6, 1)),
]
engine = CombatEngine(
ScriptedRng([10, 9, 8, 15, 3, 5]),
make_grid(), states, round_cap=1, policy=_withdraw_from_foe,
)
result = engine.run()
assert result.transcript == (
"initiative: flee d20=10+0=10",
"initiative: foe d20=9+0=9",
"initiative: foe2 d20=8+0=8",
"round 1 foe2: AoO vs flee d20=15+5=20 AC 12 -> HIT 3 damage (20->17)",
"round 1 flee: withdraw (4,1)->(5,0)->(6,0)->(7,1)",
"round 1 foe: wait",
"round 1 foe2: wait",
"battle over: draw after 1 rounds",
)