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