fix(rules): expire a readied action at the combatant's next turn
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).' _take_turn's start-of-turn block now clears state.readied/readied_trigger (logged 'ready expired'), before the policy call; the ready still survives the round boundary and can fire in the next round as long as the readier's own turn has not arrived. Updated the two tests that pinned the old infinite persistence and added cross-round fire + expiry tests. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -478,13 +478,21 @@ Règles modélisées :
|
|||||||
recalculée, tout pas ennemi déclenche), `enemy_casts` ← incantation d'un
|
recalculée, tout pas ennemi déclenche), `enemy_casts` ← incantation d'un
|
||||||
ennemi, `enemy_attacks` ← attaque résolue d'un ennemi, `enemy_hits_ally` ←
|
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
|
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
|
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
|
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. Le snapshot expose l'état via la clé `readied`
|
exécutable. Conformance L4 (SRD « Ready — Initiative Consequences of
|
||||||
(`{kind, trigger}` ou `None`). `default_policy` ne se prépare pas
|
Readying ») : « If you come to your next action and have not yet performed
|
||||||
(disponible via une politique personnalisée).
|
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
|
- Défense totale (`Action(kind="total_defense")`, action standard) : le
|
||||||
combattant se couvre entièrement et gagne **+4 d'esquive à la CA** jusqu'au
|
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é
|
début de son prochain tour (champ runtime `defensive_ac_bonus`, réinitialisé
|
||||||
|
|||||||
@@ -1511,6 +1511,7 @@ class CombatEngine:
|
|||||||
# entry (it was cleared at round start and only a delay action sets it).
|
# 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.
|
# The delayed turn still gets its policy call, budget and execution.
|
||||||
if not state.delayed_this_round:
|
if not state.delayed_this_round:
|
||||||
|
self._expire_readied_action(state)
|
||||||
self._decrement_durations(state)
|
self._decrement_durations(state)
|
||||||
gate = self._turn_gate(state)
|
gate = self._turn_gate(state)
|
||||||
if gate is not None:
|
if gate is not None:
|
||||||
@@ -3933,6 +3934,22 @@ class CombatEngine:
|
|||||||
self._in_reaction = False
|
self._in_reaction = False
|
||||||
return
|
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:
|
def _check_readied(self, event: ReactionEvent) -> bool:
|
||||||
"""Fire the first matching readied action; True when one fired.
|
"""Fire the first matching readied action; True when one fired.
|
||||||
|
|
||||||
|
|||||||
+99
-14
@@ -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: 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 "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 not any("ready fires" in line for line in t)
|
||||||
assert hero.readied is not None
|
# SRD "Ready": the trigger never occurred before the hero's next action
|
||||||
assert hero.readied_trigger == "enemy_casts"
|
# (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:
|
def test_readied_ignores_friendly_movement() -> None:
|
||||||
@@ -261,10 +264,14 @@ def test_readied_ignores_friendly_movement() -> None:
|
|||||||
result = eng.run()
|
result = eng.run()
|
||||||
t = result.transcript
|
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 "round 1 ally: move (2,0)->(1,0)" in t
|
||||||
assert not any("ready fires" in line for line 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) ─────────────────────────────
|
# ── (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
|
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 ─────
|
# ── (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))
|
hero = make_state(_combatant("hero"), "players", (0, 0))
|
||||||
v1 = make_state(_combatant("v1"), "monsters", (0, 2))
|
v1 = make_state(_combatant("v1"), "monsters", (0, 2))
|
||||||
eng = _engine(
|
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],
|
[10, 5, 10, 3],
|
||||||
_counting(
|
_counting(
|
||||||
lambda cid, n: (
|
lambda cid, n: (
|
||||||
_ready_hero()
|
_ready_hero()
|
||||||
if (cid, n) == ("hero", 0)
|
if (cid, n) == ("hero", 0)
|
||||||
else (
|
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)
|
if (cid, n) == ("v1", 0)
|
||||||
else (Action(kind="move", target_id="hero"),)
|
|
||||||
if (cid, n) == ("v1", 1)
|
|
||||||
else _WAIT
|
else _WAIT
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
@@ -366,11 +445,17 @@ def test_readied_payload_events_do_not_cascade() -> None:
|
|||||||
result = eng.run()
|
result = eng.run()
|
||||||
t = result.transcript
|
t = result.transcript
|
||||||
|
|
||||||
# Round 2: v1's move fires hero's readied attack; the attack_resolved
|
# Round 1: v1's move (its move action, right after its own ready) fires
|
||||||
# event it generates must NOT fire v1's own readied counter-attack.
|
# hero's readied attack; the attack_resolved event it generates must NOT
|
||||||
assert "round 2 hero: ready fires (enemy_enters_reach)" in t
|
# 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 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 hero.hp == 100
|
||||||
assert v1.readied is not None
|
# Round 2: v1's counter-ready expires at the start of v1's own turn
|
||||||
assert v1.readied_trigger == "enemy_attacks"
|
# (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)
|
||||||
|
|||||||
Reference in New Issue
Block a user