fix(rules): update initiative when a readied action fires

SRD Ready - Initiative Consequences of Readying: 'your initiative result becomes the count on which you took the readied action' and you 'act immediately ahead of the character whose action triggered your readied action'. _check_readied fired the payload off-turn but never touched the readier's initiative. It now calls _ready_shift_initiative, which rewrites the readier's _initiative_keys entry (trigger total + 0.5, keeping its mod and prior index) using the same tuple shape _execute_delay writes, so _build_round_order holds the new position for all following rounds. Deterministic: no RNG consumed, current round order 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-13 14:54:15 +02:00
parent 6702efc652
commit 7fb7ec55dd
3 changed files with 143 additions and 1 deletions
+98
View File
@@ -459,3 +459,101 @@ def test_readied_payload_events_do_not_cascade() -> None:
# action").
assert v1.readied is None
assert any("round 2 v1: ready expired" in line for line in t)
# ── (g) S4b: a readied action changes the readier's initiative (SRD "Ready") ──
def test_readied_action_shifts_initiative_and_persists() -> None:
# Given: hero (init 10) readies, mid (init 8) waits, foe (init 5) moves
# When: the foe's round-1 move fires hero's readied attack off-turn
# Then: hero's initiative becomes the trigger's count + 0.5 (SRD "Ready —
# Initiative Consequences of Readying": "your initiative result is the
# count on which you took the readied action, and you act immediately ahead
# of the character whose action triggered your readied action") — hero drops
# from first (10) to just ahead of the foe (5.5), behind mid (8), and the
# new order persists into round 2.
hero = make_state(_combatant("hero"), "players", (0, 0))
mid = make_state(_combatant("mid"), "monsters", (5, 5))
foe = make_state(_combatant("foe"), "monsters", (0, 2))
eng = _engine(
# init hero 10, mid 8, foe 5; readied attack d20=10 HIT, dmg 3.
[10, 8, 5, 10, 3],
_counting(
lambda cid, n: (
_ready_hero()
if (cid, n) == ("hero", 0)
else (Action(kind="move", target_id="hero"),)
if (cid, n) == ("foe", 0)
else _WAIT
)
),
[hero, mid, foe],
)
result = eng.run()
t = result.transcript
assert "round 1 hero: ready fires (enemy_enters_reach)" in t
# Hero's initiative key is rewritten to the trigger's count + 0.5, keeping
# its own modifier (0) and prior list index (0).
assert eng._initiative_keys["hero"] == (5.5, 0, 0)
# The trigger's own key is untouched.
assert eng._initiative_keys["foe"] == (5, 0, 2)
# Persistence: round 2 order is mid (8), hero (5.5), foe (5) — hero fell
# from first to second, now acting immediately ahead of the foe.
idx = _order(t, "round 2 mid:", "round 2 hero:", "round 2 foe:")
assert idx == tuple(sorted(idx))
def test_readied_initiative_shift_is_visible_in_order_snapshot() -> None:
# Given/When: the same trigger; the readier's initiative moves to just
# ahead of the trigger's count.
# Then: the Observation's initiative_order reflects the new position in the
# following round (mid, hero, foe — hero no longer first).
hero = make_state(_combatant("hero"), "players", (0, 0))
mid = make_state(_combatant("mid"), "monsters", (5, 5))
foe = make_state(_combatant("foe"), "monsters", (0, 2))
seen: dict[int, tuple[str, ...]] = {}
calls: dict[str, int] = {}
def policy(engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
cid = state.combatant.id
n = calls.get(cid, 0)
calls[cid] = n + 1
if cid == "hero":
obs = _snapshot(engine, state)
seen[engine._current_round] = tuple(obs["initiative_order"])
return _ready_hero() if n == 0 else _WAIT
if cid == "foe":
return (Action(kind="move", target_id="hero"),) if n == 0 else _WAIT
return _WAIT
eng = CombatEngine(
ScriptedRng([10, 8, 5, 10, 3]), make_grid(), [hero, mid, foe],
policy=policy, round_cap=2,
)
eng.run()
# Round 1 order had hero first; round 2 (after the shift) has hero second,
# immediately ahead of the foe that triggered the readied action.
assert seen[1] == ("hero", "mid", "foe")
assert seen[2] == ("mid", "hero", "foe")
def test_untriggered_ready_does_not_shift_initiative() -> None:
# Control: a readied action that never fires leaves the initiative key
# unchanged (the shift happens only when the readied action is taken).
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],
)
eng.run()
assert eng._initiative_keys["hero"] == (10, 0, 0)
assert eng._initiative_keys["foe"] == (5, 0, 1)