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
+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
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"
]