From 5b1957152c63fba79ca34e170ac64320f634cfbc Mon Sep 17 00:00:00 2001 From: Thien An Date: Mon, 17 Aug 2026 22:49:50 +0200 Subject: [PATCH] feat(combat): add withdraw action (double move with AoO protection on first square) --- README.md | 25 ++++++--- src/pf1e_simulator/combat.py | 104 +++++++++++++++++++++++++++++++++-- tests/test_combat.py | 57 +++++++++++++++++++ 3 files changed, 172 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 9764686..69ea52d 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 3af0832..d44f4eb 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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) diff --git a/tests/test_combat.py b/tests/test_combat.py index 3337bab..3a6f6f2 100644 --- a/tests/test_combat.py +++ b/tests/test_combat.py @@ -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", + )