fix(rules): expire a readied action at the combatant's next turn
SRD 'Ready - Initiative Consequences of Readying': 'If you come to your next action and have not yet performed your readied action, you don't get to take the readied action (though you can ready the same action again).' _take_turn's start-of-turn block now clears state.readied/readied_trigger (logged 'ready expired'), before the policy call; the ready still survives the round boundary and can fire in the next round as long as the readier's own turn has not arrived. Updated the two tests that pinned the old infinite persistence and added cross-round fire + expiry tests. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -482,7 +482,15 @@ Règles modélisées :
|
||||
de swift** (au plus une action préparée par événement, sans cascade), puis
|
||||
l'action préparée est **consommée** (one-shot). Une nouvelle préparation
|
||||
remplace la précédente ; le reste du tour (action de mouvement) reste
|
||||
exécutable. Le snapshot expose l'état via la clé `readied`
|
||||
exécutable. Conformance L4 (SRD « Ready — Initiative Consequences of
|
||||
Readying ») : « If you come to your next action and have not yet performed
|
||||
your readied action, you don't get to take the readied action (though you
|
||||
can ready the same action again) » — la préparation non déclenchée
|
||||
**expire au début du tour suivant** du préparant (bloc de début de tour de
|
||||
`_take_turn`, journalisé `ready expired (trigger never occurred)`), avant
|
||||
l'appel de politique ; elle survit donc à la fin du round courant et peut
|
||||
se déclencher au round suivant tant que le tour du préparant n'est pas
|
||||
arrivé. Le snapshot expose l'état via la clé `readied`
|
||||
(`{kind, trigger}` ou `None`). `default_policy` ne se prépare pas
|
||||
(disponible via une politique personnalisée).
|
||||
- Défense totale (`Action(kind="total_defense")`, action standard) : le
|
||||
|
||||
@@ -1511,6 +1511,7 @@ class CombatEngine:
|
||||
# entry (it was cleared at round start and only a delay action sets it).
|
||||
# The delayed turn still gets its policy call, budget and execution.
|
||||
if not state.delayed_this_round:
|
||||
self._expire_readied_action(state)
|
||||
self._decrement_durations(state)
|
||||
gate = self._turn_gate(state)
|
||||
if gate is not None:
|
||||
@@ -3933,6 +3934,22 @@ class CombatEngine:
|
||||
self._in_reaction = False
|
||||
return
|
||||
|
||||
def _expire_readied_action(self, state: CombatantState) -> None:
|
||||
"""PF1e (SRD "Ready — Initiative Consequences of Readying"): "If you
|
||||
come to your next action and have not yet performed your readied
|
||||
action, you don't get to take the readied action (though you can
|
||||
ready the same action again)." An untriggered readied action expires
|
||||
at the START of the combatant's next turn, before the policy runs (a
|
||||
forfeited turn still expires it)."""
|
||||
if state.readied is None:
|
||||
return
|
||||
self._log(
|
||||
f"round {self._current_round} {state.combatant.id}: "
|
||||
"ready expired (trigger never occurred)"
|
||||
)
|
||||
state.readied = None
|
||||
state.readied_trigger = None
|
||||
|
||||
def _check_readied(self, event: ReactionEvent) -> bool:
|
||||
"""Fire the first matching readied action; True when one fired.
|
||||
|
||||
|
||||
+99
-14
@@ -237,8 +237,11 @@ def test_readied_ignores_non_matching_trigger() -> None:
|
||||
assert "round 1 foe: move (0,2)->(0,1)" in t
|
||||
assert "round 1 foe: short sword vs hero d20=2+5=7 AC 10 -> MISS" in t
|
||||
assert not any("ready fires" in line for line in t)
|
||||
assert hero.readied is not None
|
||||
assert hero.readied_trigger == "enemy_casts"
|
||||
# SRD "Ready": the trigger never occurred before the hero's next action
|
||||
# (round 2), so the readied action expires at the start of that turn.
|
||||
assert any("ready expired" in line for line in t)
|
||||
assert hero.readied is None
|
||||
assert hero.readied_trigger is None
|
||||
|
||||
|
||||
def test_readied_ignores_friendly_movement() -> None:
|
||||
@@ -261,10 +264,14 @@ def test_readied_ignores_friendly_movement() -> None:
|
||||
result = eng.run()
|
||||
t = result.transcript
|
||||
|
||||
# The ally's movement_step is a friendly event: the ready holds.
|
||||
# The ally's movement_step is a friendly event: the ready holds through
|
||||
# round 1. At the start of the hero's round 2 turn it expires (SRD
|
||||
# "Ready": "If you come to your next action and have not yet performed
|
||||
# your readied action, you don't get to take the readied action").
|
||||
assert "round 1 ally: move (2,0)->(1,0)" in t
|
||||
assert not any("ready fires" in line for line in t)
|
||||
assert hero.readied is not None
|
||||
assert hero.readied is None
|
||||
assert any("ready expired" in line for line in t)
|
||||
|
||||
|
||||
# ── (d) the readied action is consumed (one-shot) ─────────────────────────────
|
||||
@@ -305,6 +312,74 @@ def test_readied_action_fires_only_once() -> None:
|
||||
assert hero.readied is None
|
||||
|
||||
|
||||
# ── (d2) expiry at the combatant's next turn (SRD "Ready") ────────────────────
|
||||
|
||||
|
||||
def test_readied_action_fires_in_the_next_round_before_own_turn() -> None:
|
||||
# Given: the foe (init 10) waits in round 1; the hero (init 5) readies;
|
||||
# in round 2 the foe moves before the hero's own turn comes
|
||||
# When: the battle runs two rounds
|
||||
# Then: the readied action survives the round boundary and fires on the
|
||||
# foe's round-2 move (it expires only when the hero's own turn arrives).
|
||||
hero = make_state(_combatant("hero"), "players", (0, 0))
|
||||
foe = make_state(_combatant("foe"), "monsters", (0, 2))
|
||||
eng = _engine(
|
||||
# init hero 5, foe 10; round 1: foe wait, hero ready; round 2: foe
|
||||
# moves BEFORE the hero's turn and fires the readied attack
|
||||
# (d20=10 +5 = 15 >= AC 10, dmg 3).
|
||||
[5, 10, 10, 3],
|
||||
_counting(
|
||||
lambda cid, n: (
|
||||
_WAIT
|
||||
if cid == "foe" and n == 0
|
||||
else (Action(kind="move", target_id="hero"),)
|
||||
if cid == "foe"
|
||||
else _ready_hero()
|
||||
if n == 0
|
||||
else _WAIT
|
||||
)
|
||||
),
|
||||
[hero, foe],
|
||||
)
|
||||
result = eng.run()
|
||||
t = result.transcript
|
||||
|
||||
assert "round 2 foe: move (0,2)->(0,1)" in t
|
||||
assert any("round 2" in line and "ready fires (enemy_enters_reach)" in line for line in t)
|
||||
assert "round 2 hero: short sword vs foe d20=10+5=15 AC 10 -> HIT 3 damage (100->97)" in t
|
||||
assert hero.readied is None
|
||||
assert not any("ready expired" in line for line in t)
|
||||
|
||||
|
||||
def test_readied_action_expires_at_start_of_own_turn() -> None:
|
||||
# Given: the hero readies with a trigger that never occurs
|
||||
# When: the hero's next turn starts
|
||||
# Then: the readied action is cleared and logged as expired, and the
|
||||
# hero may ready again that turn.
|
||||
hero = make_state(_combatant("hero"), "players", (0, 0))
|
||||
foe = make_state(_combatant("foe"), "monsters", (0, 6))
|
||||
eng = _engine(
|
||||
[10, 5], # initiative only — the foe never comes close.
|
||||
_counting(
|
||||
lambda cid, _n: (
|
||||
_ready_hero()
|
||||
if cid == "hero"
|
||||
else _WAIT
|
||||
)
|
||||
),
|
||||
[hero, foe],
|
||||
)
|
||||
result = eng.run()
|
||||
t = result.transcript
|
||||
|
||||
assert sum(1 for line in t if "ready expired" in line) == 1
|
||||
assert "round 2 hero: ready expired (trigger never occurred)" in t
|
||||
# Round 2's policy re-readies (n == 1 still returns the ready action),
|
||||
# so the state is re-armed after the expiry.
|
||||
assert hero.readied is not None
|
||||
assert hero.readied_trigger == "enemy_enters_reach"
|
||||
|
||||
|
||||
# ── (e) the snapshot exposes readied = {kind, trigger}, None after firing ─────
|
||||
|
||||
|
||||
@@ -346,18 +421,22 @@ def test_readied_payload_events_do_not_cascade() -> None:
|
||||
hero = make_state(_combatant("hero"), "players", (0, 0))
|
||||
v1 = make_state(_combatant("v1"), "monsters", (0, 2))
|
||||
eng = _engine(
|
||||
# init hero 10, v1 5; round 2 readied attack: d20=10 HIT, dmg 3.
|
||||
# init hero 10, v1 5; the readied attack fires on v1's round-1 move:
|
||||
# d20=10 HIT, dmg 3.
|
||||
[10, 5, 10, 3],
|
||||
_counting(
|
||||
lambda cid, n: (
|
||||
_ready_hero()
|
||||
if (cid, n) == ("hero", 0)
|
||||
else (
|
||||
Action(kind="ready", ready_trigger="enemy_attacks", target_id="hero"),
|
||||
Action(
|
||||
kind="ready",
|
||||
ready_trigger="enemy_attacks",
|
||||
target_id="hero",
|
||||
),
|
||||
Action(kind="move", target_id="hero"),
|
||||
)
|
||||
if (cid, n) == ("v1", 0)
|
||||
else (Action(kind="move", target_id="hero"),)
|
||||
if (cid, n) == ("v1", 1)
|
||||
else _WAIT
|
||||
)
|
||||
),
|
||||
@@ -366,11 +445,17 @@ def test_readied_payload_events_do_not_cascade() -> None:
|
||||
result = eng.run()
|
||||
t = result.transcript
|
||||
|
||||
# Round 2: v1's move fires hero's readied attack; the attack_resolved
|
||||
# event it generates must NOT fire v1's own readied counter-attack.
|
||||
assert "round 2 hero: ready fires (enemy_enters_reach)" in t
|
||||
# Round 1: v1's move (its move action, right after its own ready) fires
|
||||
# hero's readied attack; the attack_resolved event it generates must NOT
|
||||
# fire v1's own readied counter-attack (both readys are still live —
|
||||
# neither has come to its next action yet).
|
||||
assert "round 1 hero: ready fires (enemy_enters_reach)" in t
|
||||
assert sum(1 for line in t if "ready fires" in line) == 1
|
||||
assert not any("round 2 v1: short sword vs hero" in line for line in t)
|
||||
assert not any("v1: short sword vs hero" in line for line in t)
|
||||
assert hero.hp == 100
|
||||
assert v1.readied is not None
|
||||
assert v1.readied_trigger == "enemy_attacks"
|
||||
# Round 2: v1's counter-ready expires at the start of v1's own turn
|
||||
# (SRD "Ready": "If you come to your next action and have not yet
|
||||
# performed your readied action, you don't get to take the readied
|
||||
# action").
|
||||
assert v1.readied is None
|
||||
assert any("round 2 v1: ready expired" in line for line in t)
|
||||
|
||||
Reference in New Issue
Block a user