diff --git a/README.md b/README.md index 1681092..3112f67 100644 --- a/README.md +++ b/README.md @@ -537,7 +537,21 @@ 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. 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 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 diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 4c028a3..7972e04 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -4242,6 +4242,7 @@ class CombatEngine: f"round {self._current_round} {state.combatant.id}: " f"ready fires ({trigger})" ) + self._ready_shift_initiative(state, event.actor) target = next( (s for s in self._states if s.combatant.id == payload.target_id), None, @@ -4252,6 +4253,35 @@ class CombatEngine: return True 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( self, state: CombatantState, event: ReactionEvent ) -> bool: diff --git a/tests/test_ready_action.py b/tests/test_ready_action.py index 2f9c5bf..7f8fc80 100644 --- a/tests/test_ready_action.py +++ b/tests/test_ready_action.py @@ -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) +