diff --git a/README.md b/README.md index af34cef..1567bdc 100644 --- a/README.md +++ b/README.md @@ -478,13 +478,21 @@ Règles modélisées : recalculée, tout pas ennemi déclenche), `enemy_casts` ← incantation d'un ennemi, `enemy_attacks` ← attaque résolue d'un ennemi, `enemy_hits_ally` ← dégâts subis par un allié du préparant infligés par un ennemi. Quand - l'événement correspond, le moteur exécute le payload **hors-tour, sans coût - 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. Le snapshot expose l'état via la clé `readied` - (`{kind, trigger}` ou `None`). `default_policy` ne se prépare pas - (disponible via une politique personnalisée). + l'événement correspond, le moteur exécute le payload **hors-tour, sans coût + 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 + 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 + **expire au début du tour suivant** du préparant (bloc de début de tour de + `_take_turn`, journalisé `ready expired (trigger never occurred)`), avant + l'appel de politique ; elle survit donc à la fin du round courant et peut + se déclencher au round suivant tant que le tour du préparant n'est pas + arrivé. Le snapshot expose l'état via la clé `readied` + (`{kind, trigger}` ou `None`). `default_policy` ne se prépare pas + (disponible via une politique personnalisée). - Défense totale (`Action(kind="total_defense")`, action standard) : le combattant se couvre entièrement et gagne **+4 d'esquive à la CA** jusqu'au début de son prochain tour (champ runtime `defensive_ac_bonus`, réinitialisé diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 29589e4..3262e8c 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -1511,6 +1511,7 @@ class CombatEngine: # entry (it was cleared at round start and only a delay action sets it). # The delayed turn still gets its policy call, budget and execution. if not state.delayed_this_round: + self._expire_readied_action(state) self._decrement_durations(state) gate = self._turn_gate(state) if gate is not None: @@ -3933,6 +3934,22 @@ class CombatEngine: self._in_reaction = False return + def _expire_readied_action(self, state: CombatantState) -> None: + """PF1e (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)." An untriggered readied action expires + at the START of the combatant's next turn, before the policy runs (a + forfeited turn still expires it).""" + if state.readied is None: + return + self._log( + f"round {self._current_round} {state.combatant.id}: " + "ready expired (trigger never occurred)" + ) + state.readied = None + state.readied_trigger = None + def _check_readied(self, event: ReactionEvent) -> bool: """Fire the first matching readied action; True when one fired. diff --git a/tests/test_ready_action.py b/tests/test_ready_action.py index 9d4a183..2f9c5bf 100644 --- a/tests/test_ready_action.py +++ b/tests/test_ready_action.py @@ -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)