diff --git a/README.md b/README.md index 1cf4e75..11c8324 100644 --- a/README.md +++ b/README.md @@ -665,7 +665,12 @@ Règles modélisées : naturelle — les 8 cases adjacentes pour un Small/Medium sans allonge — ou l'anneau d'une arme à allonge, dont la dead zone n'est pas menacée). 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 + la case actuelle porte une AoO avant que le mouvant ne la quitte ; sortir + de plusieurs cases menacées par le **même** adversaire dans le même round ne + compte qu'**une seule opportunity** pour lui (piste `_move_aoo_provoked`, + réinitialisée au début du round — conformance A1, CRB « Attacks of + Opportunity ») ; le relèvement (stand_up) est un déclencheur distinct et + provoque toujours ; (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) ; le budget restant est exposé diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index b9a42c8..fb23dcb 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -72,7 +72,11 @@ Phase 0 documented deviations from PF1e (conventions): threatening enemy gets one AoO before each ranged swing. One AoO per combatant per round by default (tracked in ``_aoo_used``, cleared at round start); the Combat Reflexes feat raises the allowance to ``max(1, 1 + DEX - mod)`` per round. AoOs always strike (PF1e allows declining, the simulator + mod)`` per round. Moving out of more than one square threatened by the same + opponent in the same round counts as ONE opportunity for that opponent + (tracked in ``_move_aoo_provoked``, cleared at round start); the ranged, + casting, stand-up and maneuver triggers are separate and each still + provokes. AoOs always strike (PF1e allows declining, the simulator does not). A 5-foot step never provokes AoOs (it is not a move action); the withdraw action protects only the starting square (see below). A 5-foot step and any other movement (move, charge, withdraw) are mutually exclusive @@ -976,6 +980,10 @@ class CombatEngine: self._stats: dict[str, _LiveStats] = {s.combatant.id: _LiveStats() for s in states} self._current_round = 0 self._aoo_used: dict[str, int] = {} + # Movement-triggered opportunities already taken this round, keyed by + # mover id: PF1e counts leaving several squares threatened by the same + # opponent as ONE opportunity for that opponent. + self._move_aoo_provoked: dict[str, set[str]] = {} self._initiative_keys: dict[str, tuple[float, int, int]] = {} self._pending_summons: list[CombatantState] = [] @@ -1778,6 +1786,7 @@ class CombatEngine: while round_no <= self._round_cap: self._current_round = round_no self._aoo_used.clear() + self._move_aoo_provoked.clear() self._expire_summons() if self._run_round(): break @@ -2919,16 +2928,31 @@ class CombatEngine: attacker, target, weapon, label="AoO" ) - def _check_move_aoo(self, mover: CombatantState) -> bool: + def _check_move_aoo( + self, mover: CombatantState, *, movement: bool = True + ) -> bool: """Resolve AoOs provoked by leaving any threatened cell of the mover's footprint. Each enemy threatening a cell the mover occupies strikes once (capped - by the per-round AoO allowance). Returns True if the mover dropped. + by the per-round AoO allowance). PF1e (CRB "Attacks of Opportunity — + Combat Reflexes and Additional Attacks of Opportunity"): "Moving out of + more than one square threatened by the same opponent in the same round + doesn't count as more than one opportunity for that opponent" — with + ``movement`` the per-round per-opponent dedupe in + ``_move_aoo_provoked`` skips a second movement-triggered strike from + the same enemy. Stand-up (``movement=False``) is a separate trigger and + always provokes, subject to the allowance. Returns True if the mover + dropped. """ + provoked = self._move_aoo_provoked.setdefault(mover.combatant.id, set()) for cell in mover.occupied_cells(): for enemy in self.enemies_threatening(mover, cell): + if movement and enemy.combatant.id in provoked: + continue if self._aoo_used.get(enemy.combatant.id, 0) >= self._aoo_allowance(enemy): continue + if movement: + provoked.add(enemy.combatant.id) self._resolve_aoo(enemy, mover) if not mover.active: return True @@ -3380,7 +3404,9 @@ class CombatEngine: """Execute the stand up action (PF1e): move action that ends prone. Standing up provokes an AoO from each enemy threatening the - combatant's squares (``_check_move_aoo``, the movement machinery); + combatant's squares (``_check_move_aoo`` with ``movement=False``: + standing is a separate trigger, never deduped against a movement + opportunity already taken this round); the AoOs resolve while the combatant is still prone (-4 AC). The prone condition is then removed from both representations: the duration-tracked ``active_conditions`` (trip/overrun) and the @@ -3395,7 +3421,7 @@ class CombatEngine: f"{kind} rejected ({_REASON_NOT_PRONE})" ) return ActionResult(kind=kind, ok=False, reason=_REASON_NOT_PRONE) - self._check_move_aoo(state) + self._check_move_aoo(state, movement=False) state.active_conditions = [ ac for ac in state.active_conditions if ac.condition.name != "prone" ] diff --git a/tests/test_combat.py b/tests/test_combat.py index fa414c8..eabe218 100644 --- a/tests/test_combat.py +++ b/tests/test_combat.py @@ -1401,7 +1401,13 @@ def _make_guard_cr(dex: int, *, combat_reflexes: bool) -> Combatant: def test_combat_reflexes_multiple_aoos() -> None: - """Guard with Combat Reflexes + DEX 16 takes multiple AoOs when enemies move.""" + """Guard with Combat Reflexes + DEX 16 takes multiple AoOs when enemies move. + + A1 conformance: each moving opponent counts as its own opportunity, so + three runners crossing the guard's threatened zone each provoke once (a + single runner leaving two threatened squares would NOT add a second + opportunity for the guard). + """ runner1 = make_combatant("runner1", hp=20, ac=12, speed=30) runner2 = make_combatant("runner2", hp=20, ac=12, speed=30) runner3 = make_combatant("runner3", hp=20, ac=12, speed=30) @@ -1410,7 +1416,7 @@ def test_combat_reflexes_multiple_aoos() -> None: states = [ make_state(runner1, "players", (2, 3)), make_state(runner2, "players", (4, 3)), - make_state(runner3, "players", (3, 2)), + make_state(runner3, "players", (2, 4)), make_state(guard, "monsters", (3, 5)), make_state(dest, "monsters", (6, 5)), ] @@ -1450,3 +1456,71 @@ def test_no_combat_reflexes_single_aoo() -> None: result = engine.run() aoo_lines = [line for line in result.transcript if "AoO" in line] assert len(aoo_lines) == 1 + + +# ── A1: one movement opportunity per opponent per round (CRB AoO) ─────────── # + + +def test_movement_two_squares_same_guard_one_opportunity() -> None: + """Leaving two squares threatened by the same opponent counts as one opportunity. + + PF1e (CRB "Attacks of Opportunity"): "Moving out of more than one square + threatened by the same opponent in the same round doesn't count as more + than one opportunity for that opponent." The guard holds Combat Reflexes + with DEX 16 (allowance 4), so the per-round budget alone would NOT stop + the second strike — only the dedupe does. + """ + runner = make_combatant("runner", hp=20, ac=12, speed=30) + guard = _make_guard_cr(dex=16, combat_reflexes=True) + states = [make_state(runner, "players", (4, 1)), make_state(guard, "monsters", (3, 1))] + engine = CombatEngine(ScriptedRng([15, 3, 15, 3]), make_grid(), states) + runner_s, _guard_s = engine._states + # Step 1: leaving (4,1) — threatened by the guard — provokes. + assert engine._check_move_aoo(runner_s) is False + runner_s.pos = (4, 2) + # Step 2: leaving (4,2) — still threatened by the SAME guard — is the + # same opponent, so no second movement opportunity. + assert engine._check_move_aoo(runner_s) is False + assert engine._aoo_used.get("guard", 0) == 1 + aoo_lines = [line for line in engine._transcript if "AoO" in line] + assert len(aoo_lines) == 1 + + +def test_movement_dedupe_exempts_stand_up_trigger() -> None: + """A movement opportunity already taken does not absorb the stand-up trigger. + + The dedupe covers ONLY the "leaving a threatened square" trigger; standing + up is a separate trigger (``movement=False``) and still provokes. + """ + runner = make_combatant("runner", hp=20, ac=12, speed=30) + guard = _make_guard_cr(dex=16, combat_reflexes=True) + states = [make_state(runner, "players", (4, 1)), make_state(guard, "monsters", (3, 1))] + engine = CombatEngine(ScriptedRng([15, 3, 15, 3]), make_grid(), states) + runner_s, _guard_s = engine._states + assert engine._check_move_aoo(runner_s) is False # movement: provokes + assert engine._check_move_aoo(runner_s) is False # movement again: deduped + assert engine._check_move_aoo(runner_s, movement=False) is False # stand-up: provokes + assert engine._aoo_used.get("guard", 0) == 2 + aoo_lines = [line for line in engine._transcript if "AoO" in line] + assert len(aoo_lines) == 2 + + +def test_two_guards_each_one_movement_opportunity() -> None: + """Two different opponents each keep their own movement opportunity.""" + 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) + states = [ + make_state(runner, "players", (4, 1)), + make_state(g1, "monsters", (3, 1)), + make_state(g2, "monsters", (3, 2)), + ] + engine = CombatEngine(ScriptedRng([15, 3, 15, 3, 15, 3, 15, 3]), make_grid(), states) + runner_s, _g1, _g2 = engine._states + assert engine._check_move_aoo(runner_s) is False # both guards strike + runner_s.pos = (4, 2) + assert engine._check_move_aoo(runner_s) is False # same squares, deduped + assert engine._aoo_used.get("g1", 0) == 1 + assert engine._aoo_used.get("g2", 0) == 1 + aoo_lines = [line for line in engine._transcript if "AoO" in line] + assert len(aoo_lines) == 2