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