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
+15 -1
View File
@@ -537,7 +537,21 @@ Règles modélisées :
de swift** (au plus une action préparée par événement, sans cascade), puis 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 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 remplace la précédente ; le reste du tour (action de mouvement) reste
exécutable. Conformance L4 (SRD « Ready — Initiative Consequences of exécutable. **S4b — l'action préparée change l'initiative** (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 » — quand
la préparation se déclenche, `_check_readied` appelle `_ready_shift_initiative`
qui réécrit la clé d'initiative du préparant dans `_initiative_keys` (même
forme de tuple que `_execute_delay` : total du déclencheur ± le pas de 0,5,
en conservant le modificateur d'initiative et l'index de liste du préparant),
si bien que `_build_round_order` conserve la nouvelle position pour **tous
les rounds suivants**. Le pas est **ajouté** (le préparant agit juste AVANT
le déclencheur), à l'inverse du retard qui le **retranche** (agir après) —
les deux intentions sont opposées. Déterministe : aucun dé consommé, l'ordre
du round courant (déjà construit) n'est pas modifié, seuls les rounds
suivants se réordonnent. Conformance L4 (SRD « Ready — Initiative
Consequences of
Readying ») : « If you come to your next action and have not yet performed 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 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 can ready the same action again) » — la préparation non déclenchée
+30
View File
@@ -4242,6 +4242,7 @@ class CombatEngine:
f"round {self._current_round} {state.combatant.id}: " f"round {self._current_round} {state.combatant.id}: "
f"ready fires ({trigger})" f"ready fires ({trigger})"
) )
self._ready_shift_initiative(state, event.actor)
target = next( target = next(
(s for s in self._states if s.combatant.id == payload.target_id), (s for s in self._states if s.combatant.id == payload.target_id),
None, None,
@@ -4252,6 +4253,35 @@ class CombatEngine:
return True return True
return False return False
def _ready_shift_initiative(
self, state: CombatantState, trigger_actor: CombatantState
) -> None:
"""Move the readier's initiative to the count it acted on (PF1e).
PF1e (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" — for the rest of the encounter. The
slot is written into ``_initiative_keys`` with the same tuple shape
``_execute_delay`` uses (a 0.5 step off the anchor total, keeping the
readier's initiative modifier and prior list index) so
``_build_round_order`` holds the new position for every following
round. The step is ADDED (not subtracted as for delay): a readied
action interrupts and acts immediately AHEAD of the trigger, whereas a
delay postpones to act after — the two intents are opposite.
Deterministic: no RNG is consumed and the current round's order
(already built) is untouched — only subsequent rounds reorder.
"""
key = self._initiative_keys.get(state.combatant.id)
prior_index = key[2] if key is not None else self._states.index(state)
trigger_key = self._initiative_keys.get(trigger_actor.combatant.id)
trigger_total = trigger_key[0] if trigger_key is not None else 0.0
self._initiative_keys[state.combatant.id] = (
trigger_total + _DELAY_INITIATIVE_STEP,
state.combatant.initiative_mod,
prior_index,
)
def _ready_trigger_matches( def _ready_trigger_matches(
self, state: CombatantState, event: ReactionEvent self, state: CombatantState, event: ReactionEvent
) -> bool: ) -> bool:
+98
View File
@@ -459,3 +459,101 @@ def test_readied_payload_events_do_not_cascade() -> None:
# action"). # action").
assert v1.readied is None assert v1.readied is None
assert any("round 2 v1: ready expired" in line for line in t) 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)