diff --git a/README.md b/README.md index 2c37ad0..d6bbdfd 100644 --- a/README.md +++ b/README.md @@ -273,8 +273,7 @@ Règles modélisées : 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 (AoO, flanquement, attaque à outrance, charge, retraite) ne sont - pas encore modélisées. + spéciales (charge, retraite) ne sont pas encore modélisées. - 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 @@ -298,6 +297,15 @@ Règles modélisées : de mêlée) menace la cible depuis la bordure ou le coin opposé. Les attaques à distance ignorent le flanquement ; un allié désarmé ou à distance ne compte pas pour le flanquement. +- Attaques d'opportunité : un combattant actif doté d'une arme de mêlée menace + les 8 cases adjacentes. Deux déclencheurs sont modélisés : (1) quitter une + case menacée — le mouvement est résolu pas à pas, et chaque ennemi qui menace + la case actuelle porte une AoO avant que le mouvant ne la quitte ; (2) tirer + avec une arme à distance depuis une case menacée — chaque ennemi menaçant + 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. - 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 @@ -307,7 +315,7 @@ Non modélisé en Phase 0 (couches `elevation`/`markers` présentes mais non appliquées dans la résolution) : - Sorts, jets de sauvegarde, conditions et états. -- Attaques d'opportunité, manœuvres de combat. +- Manœuvres de combat. - Effets mécaniques de hauteur/élévation. - Tailles Large+ (2×2), allonge > 5 ft, actions spéciales (charge, pas de placement, retraite) et actions immédiates hors-tour. @@ -344,7 +352,7 @@ appliquées dans la résolution) : La gate de validation complète (tests + lint + types) : ```bash -uv run pytest -q # 206 tests +uv run pytest -q # 210 tests uv run ruff check src tests uv run basedpyright src # mode strict ``` @@ -358,7 +366,8 @@ uv run basedpyright src # mode strict ## Feuille de route - **Phase 1** — magie et états : jets de sauvegarde, sorts modélisés comme - effets paramétrés, conditions, flanquement, attaques d'opportunité. + effets paramétrés, conditions, manœuvres de combat. Flanquement, attaques à + outrance et attaques d'opportunité 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/pyproject.toml b/pyproject.toml index fd0d7f3..dff66c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ allowed-confusables = ["σ"] [tool.ruff.lint.per-file-ignores] # Tests: assert is expected, magic numbers are fine, annotations are noisy, # parametrized golden tests legitimately take many arguments -"tests/**" = ["S101", "PLR2004", "ANN", "PLR0913", "PLR0917"] +"tests/**" = ["S101", "PLR2004", "ANN", "PLR0913", "PLR0917", "ARG001"] # rng.py: `random.Random` is used for reproducible Monte Carlo streams, not cryptography "src/pf1e_simulator/rng.py" = ["S311"] # cli.py: stdout printing is the point of a CLI diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 010f025..8703818 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -10,7 +10,8 @@ Phase 0 documented deviations from PF1e (conventions): `(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 - (AoO, flanking, full attack, charge, withdraw) are deferred. + (charge, withdraw) are deferred; flanking and full attack 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 @@ -35,6 +36,15 @@ Phase 0 documented deviations from PF1e (conventions): - Flanking: +2 on melee attack rolls when an active ally with a melee weapon threatens the target from the opposite border or corner. Ranged attacks do not benefit from flanking; allies without melee weapons do not threaten. +- Attacks of opportunity: a combatant threatens the 8 adjacent squares if + active and wielding a melee weapon. Two triggers are modeled: (1) moving + out of a threatened square — movement is resolved step-by-step, and each + enemy that threatens the current square gets one AoO before the mover + leaves it; (2) making a ranged attack while in a threatened square — each + 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. - 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. @@ -183,6 +193,7 @@ class CombatEngine: self._transcript: list[str] = [] self._stats: dict[str, _LiveStats] = {s.combatant.id: _LiveStats() for s in states} self._current_round = 0 + self._aoo_used: set[str] = set() def _take_turn(self, state: CombatantState) -> bool: """Execute one combatant's full turn; return True if the battle is over.""" @@ -204,6 +215,7 @@ class CombatEngine: round_no = 1 while round_no <= self._round_cap: self._current_round = round_no + self._aoo_used.clear() for state in order: if not state.active: continue @@ -358,6 +370,55 @@ class CombatEngine: return active_sides.pop() return None + def _threatened_squares(self, state: CombatantState) -> frozenset[Pos]: + """Squares this combatant threatens with a melee weapon (8 adjacent for 5 ft reach).""" + if not state.active: + return frozenset() + if not any(w.kind == "melee" for w in state.combatant.attacks): + return frozenset() + r, c = state.pos + return frozenset((r + dr, c + dc) for dr, dc in _STEP_DELTAS) + + def _enemies_threatening(self, state: CombatantState, pos: Pos) -> list[CombatantState]: + """Active enemies whose threatened squares include pos (in self._states order).""" + return [ + s + for s in self._states + if s is not state + and s.active + and s.side != state.side + and pos in self._threatened_squares(s) + ] + + def _resolve_aoo(self, attacker: CombatantState, target: CombatantState) -> bool: + """Resolve one attack of opportunity (single melee attack at normal bonus). + + AoOs are always taken (PF1e allows declining, but the simulator always strikes). + Returns True if the target dropped. + """ + self._aoo_used.add(attacker.combatant.id) + weapon = next((w for w in attacker.combatant.attacks if w.kind == "melee"), None) + if weapon is None: + return False + return self._resolve_swing( + attacker, target, weapon, label="AoO" + ) + + def _check_provoked_aoo(self, attacker: CombatantState, weapon: AttackSpec) -> bool: + """Check for ranged-AoO: firing a ranged weapon in a threatened square provokes. + + Returns True if the attacker dropped (caller must stop attacking). + """ + if weapon.kind != "ranged": + return False + for enemy in self._enemies_threatening(attacker, attacker.pos): + if enemy.combatant.id in self._aoo_used: + continue + self._resolve_aoo(enemy, attacker) + if not attacker.active: + return True + return False + def _execute(self, state: CombatantState, action: Action) -> None: target = next((s for s in self._states if s.combatant.id == action.target_id), None) if action.kind in ("attack", "full_attack"): @@ -429,6 +490,10 @@ class CombatEngine: self, attacker: CombatantState, target: CombatantState, weapon: AttackSpec ) -> None: for swing in range(1, weapon.count + 1): + if not attacker.active: + return + if self._check_provoked_aoo(attacker, weapon): + return label = weapon.name if weapon.count == 1 else f"{weapon.name} #{swing}" if self._resolve_swing(attacker, target, weapon, label=label): return @@ -444,6 +509,10 @@ class CombatEngine: 1 + max(0, (attacker.combatant.bab - 1) // _ITERATIVE_PENALTY), ) for i in range(n): + if not attacker.active: + return + if self._check_provoked_aoo(attacker, weapon): + return label = weapon.name if n == 1 else f"{weapon.name} #{i + 1}" bonus = weapon.attack_bonus - _ITERATIVE_PENALTY * i if self._resolve_swing( @@ -455,9 +524,22 @@ class CombatEngine: path = self._move_path(state, target) if not path: return - coords = "->".join(f"({p[0]},{p[1]})" for p in (state.pos, *path)) - self._log(f"round {self._current_round} {state.combatant.id}: move {coords}") - state.pos = path[-1] + start = state.pos + actual_path: list[Pos] = [] + for step in path: + 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}: move {coords}") def _move_path(self, state: CombatantState, target: CombatantState) -> list[Pos]: """Full movement path toward the target, up to the combatant's speed.""" diff --git a/tests/test_combat.py b/tests/test_combat.py index 0534f8d..ddeae78 100644 --- a/tests/test_combat.py +++ b/tests/test_combat.py @@ -9,6 +9,7 @@ from __future__ import annotations from typing import Literal from pf1e_simulator.combat import ( + Action, CombatantState, CombatantStats, CombatEngine, @@ -536,7 +537,7 @@ def test_ranged_no_line_of_effect_moves_around_wall() -> None: archer = make_combatant("archer", attack_bonus=4, kind="ranged", range_increment_ft=60) target = make_combatant("target", speed=0) states = [make_state(archer, "players", (0, 1)), make_state(target, "monsters", (0, 4))] - rng = ScriptedRng([10, 9, 15, 3, 10, 12, 2, 8, 14, 1]) + rng = ScriptedRng([10, 9, 5, 15, 3, 10, 3, 12, 2, 8, 2, 14, 1]) engine = CombatEngine(rng, grid, states, round_cap=4) result = engine.run() assert result.winner == "players" @@ -544,10 +545,13 @@ def test_ranged_no_line_of_effect_moves_around_wall() -> None: "initiative: archer d20=10+0=10", "initiative: target d20=9+0=9", "round 1 archer: move (0,1)->(1,2)->(0,3)", + "round 1 target: AoO vs archer d20=5+2=7 AC 13 -> MISS", "round 1 archer: short bow vs target d20=15+4=19 AC 13 -> HIT 3 damage (6->3)", "round 1 target: short sword vs archer d20=10+2=12 AC 13 -> MISS", + "round 2 target: AoO vs archer d20=3+2=5 AC 13 -> MISS", "round 2 archer: short bow vs target d20=12+4=16 AC 13 -> HIT 2 damage (3->1)", "round 2 target: short sword vs archer d20=8+2=10 AC 13 -> MISS", + "round 3 target: AoO vs archer d20=2+2=4 AC 13 -> MISS", "round 3 archer: short bow vs target d20=14+4=18 AC 13 -> HIT 1 damage (1->0)", "round 3 archer: target down", "battle over: players win in 3 rounds", @@ -775,7 +779,7 @@ def test_no_flanking_without_opposite_ally() -> None: def test_ranged_attack_ignores_flanking() -> None: - """Ranged attacks get no flanking bonus, and ranged allies don't threaten.""" + """Ranged attacks get no flanking bonus; ranged in melee provokes AoO.""" arc = make_combatant("arc", attack_bonus=2, kind="ranged", range_increment_ft=60, speed=0) aly = make_combatant("aly", attack_bonus=0, speed=0) tgt = make_combatant("tgt", hp=10, ac=15, attack_bonus=2, speed=0) @@ -784,12 +788,13 @@ def test_ranged_attack_ignores_flanking() -> None: make_state(aly, "players", (5, 4)), make_state(tgt, "monsters", (4, 4)), ] - engine = make_engine([10, 9, 8, 12, 10, 5], states, round_cap=1) + engine = make_engine([10, 9, 8, 15, 3, 12, 10, 5], states, round_cap=1) result = engine.run() assert result.transcript == ( "initiative: arc d20=10+0=10", "initiative: aly d20=9+0=9", "initiative: tgt d20=8+0=8", + "round 1 tgt: AoO vs arc d20=15+2=17 AC 13 -> HIT 3 damage (6->3)", "round 1 arc: short bow vs tgt d20=12+2=14 AC 15 -> MISS", "round 1 aly: short sword vs tgt d20=10+0=10 AC 15 -> MISS", "round 1 tgt: short sword vs arc d20=5+2=7 AC 13 -> MISS", @@ -949,3 +954,125 @@ def test_full_attack_low_bab_single_swing() -> None: "round 1 tgt: short sword vs war d20=5+0=5 AC 13 -> MISS", "battle over: draw after 1 rounds", ) + + +# --------------------------------------------------------------------------- # +# Attacks of opportunity (CRB: movement + ranged-in-melee, 1/round) # +# --------------------------------------------------------------------------- # + + +def _move_to_dest(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]: + """Test policy: always move toward the 'dest' combatant.""" + return (Action(kind="move", target_id="dest"),) + + +def test_movement_provokes_aoo() -> None: + """Moving through a threatened square provokes an AoO; movement continues after.""" + runner = make_combatant("runner", hp=10, ac=12, speed=30) + guard = make_combatant("guard", hp=10, ac=15, attack_bonus=5, speed=0) + dest = make_combatant("dest", hp=10, ac=15, attack_bonus=0, speed=0) + states = [ + make_state(runner, "players", (4, 1)), + make_state(guard, "monsters", (3, 1)), + make_state(dest, "monsters", (4, 5)), + ] + engine = CombatEngine( + ScriptedRng([10, 9, 8, 15, 3, 5]), + make_grid(), states, round_cap=1, policy=_move_to_dest, + ) + result = engine.run() + assert result.transcript == ( + "initiative: runner d20=10+0=10", + "initiative: guard d20=9+0=9", + "initiative: dest d20=8+0=8", + "round 1 guard: AoO vs runner d20=15+5=20 AC 12 -> HIT 3 damage (10->7)", + "round 1 runner: move (4,1)->(3,2)->(3,3)->(3,4)", + "round 1 guard: wait", + "round 1 dest: wait", + "battle over: draw after 1 rounds", + ) + + +def test_aoo_drops_mover() -> None: + """If an AoO drops the mover, movement stops and the mover's side loses.""" + runner = make_combatant("runner", hp=3, ac=12, speed=30) + guard = make_combatant("guard", hp=10, ac=15, attack_bonus=5, speed=0) + dest = make_combatant("dest", hp=10, ac=15, attack_bonus=0, speed=0) + states = [ + make_state(runner, "players", (4, 1)), + make_state(guard, "monsters", (3, 1)), + make_state(dest, "monsters", (4, 5)), + ] + engine = CombatEngine( + ScriptedRng([10, 9, 8, 15, 3]), + make_grid(), states, round_cap=1, policy=_move_to_dest, + ) + result = engine.run() + assert result.winner == "monsters" + assert result.transcript == ( + "initiative: runner d20=10+0=10", + "initiative: guard d20=9+0=9", + "initiative: dest d20=8+0=8", + "round 1 guard: AoO vs runner d20=15+5=20 AC 12 -> HIT 3 damage (3->0)", + "round 1 guard: runner down", + "battle over: monsters win in 1 rounds", + ) + + +def test_aoo_limit_one_per_round() -> None: + """A combatant can only make one AoO per round, even if multiple squares are threatened.""" + runner = make_combatant("runner", hp=20, ac=12, speed=30) + guard = make_combatant("guard", hp=20, ac=15, attack_bonus=5, speed=0) + dest = make_combatant("dest", hp=20, ac=15, attack_bonus=0, speed=0) + states = [ + make_state(runner, "players", (0, 0)), + make_state(guard, "monsters", (2, 2)), + make_state(dest, "monsters", (4, 4)), + ] + engine = CombatEngine( + ScriptedRng([10, 9, 8, 15, 3]), + make_grid(), states, round_cap=1, policy=_move_to_dest, + ) + result = engine.run() + assert result.transcript == ( + "initiative: runner d20=10+0=10", + "initiative: guard d20=9+0=9", + "initiative: dest d20=8+0=8", + "round 1 guard: AoO vs runner d20=15+5=20 AC 12 -> HIT 3 damage (20->17)", + "round 1 runner: move (0,0)->(0,1)->(1,2)->(2,3)->(3,3)", + "round 1 guard: wait", + "round 1 dest: wait", + "battle over: draw after 1 rounds", + ) + + +def test_two_guards_each_one_aoo() -> None: + """Two enemies threatening the same square each get their own AoO (1/round each).""" + runner = make_combatant("runner", hp=20, ac=12, speed=30) + g1 = make_combatant("g1", hp=20, ac=15, attack_bonus=5, speed=0) + g2 = make_combatant("g2", hp=20, ac=15, attack_bonus=5, speed=0) + dest = make_combatant("dest", hp=20, ac=15, attack_bonus=0, speed=0) + states = [ + make_state(runner, "players", (4, 1)), + make_state(g1, "monsters", (3, 1)), + make_state(g2, "monsters", (3, 2)), + make_state(dest, "monsters", (4, 6)), + ] + engine = CombatEngine( + ScriptedRng([10, 9, 8, 7, 15, 3, 15, 3]), + make_grid(), states, round_cap=1, policy=_move_to_dest, + ) + result = engine.run() + assert result.transcript == ( + "initiative: runner d20=10+0=10", + "initiative: g1 d20=9+0=9", + "initiative: g2 d20=8+0=8", + "initiative: dest d20=7+0=7", + "round 1 g1: AoO vs runner d20=15+5=20 AC 12 -> HIT 3 damage (20->17)", + "round 1 g2: AoO vs runner d20=15+5=20 AC 12 -> HIT 3 damage (17->14)", + "round 1 runner: move (4,1)->(4,2)->(3,3)->(3,4)->(3,5)", + "round 1 g1: wait", + "round 1 g2: wait", + "round 1 dest: wait", + "battle over: draw after 1 rounds", + )