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:
+76
-2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user