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