fix(rules): allow one movement opportunity per opponent per round

PF1e (CRB Attacks of Opportunity): moving out of more than one square
threatened by the same opponent in the same round counts as one
opportunity for that opponent. Track per-round movement provocation in
_move_aoo_provoked (cleared with _aoo_used at round start); the stand-up
trigger passes movement=False and still provokes. Ranged, casting and
maneuver triggers are untouched.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-09-11 23:08:11 +02:00
parent 5568e0f8af
commit 74bfeb5414
3 changed files with 113 additions and 8 deletions
+6 -1
View File
@@ -665,7 +665,12 @@ Règles modélisées :
naturelle — les 8 cases adjacentes pour un Small/Medium sans allonge — ou 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 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 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 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 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é `_aoo_used`, réinitialisée au début du round) ; le budget restant est exposé
+31 -5
View File
@@ -72,7 +72,11 @@ Phase 0 documented deviations from PF1e (conventions):
threatening enemy gets one AoO before each ranged swing. One AoO per threatening enemy gets one AoO before each ranged swing. One AoO per
combatant per round by default (tracked in ``_aoo_used``, cleared at round 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 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 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 withdraw action protects only the starting square (see below). A 5-foot
step and any other movement (move, charge, withdraw) are mutually exclusive 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._stats: dict[str, _LiveStats] = {s.combatant.id: _LiveStats() for s in states}
self._current_round = 0 self._current_round = 0
self._aoo_used: dict[str, int] = {} 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._initiative_keys: dict[str, tuple[float, int, int]] = {}
self._pending_summons: list[CombatantState] = [] self._pending_summons: list[CombatantState] = []
@@ -1778,6 +1786,7 @@ class CombatEngine:
while round_no <= self._round_cap: while round_no <= self._round_cap:
self._current_round = round_no self._current_round = round_no
self._aoo_used.clear() self._aoo_used.clear()
self._move_aoo_provoked.clear()
self._expire_summons() self._expire_summons()
if self._run_round(): if self._run_round():
break break
@@ -2919,16 +2928,31 @@ class CombatEngine:
attacker, target, weapon, label="AoO" 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. """Resolve AoOs provoked by leaving any threatened cell of the mover's footprint.
Each enemy threatening a cell the mover occupies strikes once (capped 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 cell in mover.occupied_cells():
for enemy in self.enemies_threatening(mover, cell): 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): if self._aoo_used.get(enemy.combatant.id, 0) >= self._aoo_allowance(enemy):
continue continue
if movement:
provoked.add(enemy.combatant.id)
self._resolve_aoo(enemy, mover) self._resolve_aoo(enemy, mover)
if not mover.active: if not mover.active:
return True return True
@@ -3380,7 +3404,9 @@ class CombatEngine:
"""Execute the stand up action (PF1e): move action that ends prone. """Execute the stand up action (PF1e): move action that ends prone.
Standing up provokes an AoO from each enemy threatening the 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 the AoOs resolve while the combatant is still prone (-4 AC). The
prone condition is then removed from both representations: the prone condition is then removed from both representations: the
duration-tracked ``active_conditions`` (trip/overrun) and the duration-tracked ``active_conditions`` (trip/overrun) and the
@@ -3395,7 +3421,7 @@ class CombatEngine:
f"{kind} rejected ({_REASON_NOT_PRONE})" f"{kind} rejected ({_REASON_NOT_PRONE})"
) )
return ActionResult(kind=kind, ok=False, reason=_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 = [ state.active_conditions = [
ac for ac in state.active_conditions if ac.condition.name != "prone" ac for ac in state.active_conditions if ac.condition.name != "prone"
] ]
+76 -2
View File
@@ -1401,7 +1401,13 @@ def _make_guard_cr(dex: int, *, combat_reflexes: bool) -> Combatant:
def test_combat_reflexes_multiple_aoos() -> None: 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) runner1 = make_combatant("runner1", hp=20, ac=12, speed=30)
runner2 = make_combatant("runner2", 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) runner3 = make_combatant("runner3", hp=20, ac=12, speed=30)
@@ -1410,7 +1416,7 @@ def test_combat_reflexes_multiple_aoos() -> None:
states = [ states = [
make_state(runner1, "players", (2, 3)), make_state(runner1, "players", (2, 3)),
make_state(runner2, "players", (4, 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(guard, "monsters", (3, 5)),
make_state(dest, "monsters", (6, 5)), make_state(dest, "monsters", (6, 5)),
] ]
@@ -1450,3 +1456,71 @@ def test_no_combat_reflexes_single_aoo() -> None:
result = engine.run() result = engine.run()
aoo_lines = [line for line in result.transcript if "AoO" in line] aoo_lines = [line for line in result.transcript if "AoO" in line]
assert len(aoo_lines) == 1 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