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