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:
2026-09-12 13:59:11 +02:00
parent ec2088ab90
commit 7810d88488
3 changed files with 131 additions and 21 deletions
+99 -14
View File
@@ -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)