Files
pf1e-simulator/tests/test_ready_action.py
T
ctan 7810d88488 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>
2026-09-12 13:59:11 +02:00

462 lines
16 KiB
Python

"""The ready action (Package U4).
A combatant spends its standard action preparing a payload (attack, cast_spell
or maneuver, encoded via the Action carrier fields like the immediate action).
When a matching ``ReactionEvent`` from an ENEMY occurs, the engine fires the
payload off-turn through the reaction machinery — no swift cost — and the
readied action is consumed (one-shot). Triggers: ``enemy_enters_reach`` ←
movement_step, ``enemy_casts`` ← spell_cast, ``enemy_attacks`` ←
attack_resolved, ``enemy_hits_ally`` ← damage_taken.
"""
from __future__ import annotations
from collections.abc import Callable
from pf1e_simulator.combat import (
Action,
ActionResult,
CombatantState,
CombatEngine,
)
from pf1e_simulator.dice import parse_dice
from pf1e_simulator.models import (
AbilityScores,
ACProfile,
AttackSpec,
Combatant,
DamageComponent,
Saves,
)
from pf1e_simulator.rng import ScriptedRng
from pf1e_simulator.spells import DamageEffect, SpellSpec
from tests.test_observation_enriched import _snapshot, make_grid, make_state
def _combatant(id_: str, *, spells: tuple[str, ...] = ()) -> Combatant:
return Combatant(
id=id_,
name=id_,
level=1,
abilities=AbilityScores(
str_score=10, dex_score=10, con_score=10,
int_score=10, wis_score=10, cha_score=10,
),
ac=ACProfile(total=10, touch=10, flat_footed=10),
bab=1,
initiative_mod=0,
speed_land_ft=30,
hp_max=100,
saves=Saves(fort=0, ref=0, will=0),
attacks=[
AttackSpec(
id=f"{id_}-sword",
name="short sword",
kind="melee",
attack_bonus=5,
damage=[
DamageComponent(formula=parse_dice("1d6"), types=["slashing"])
],
)
],
spells=list(spells),
)
_BOLT = SpellSpec(
name="Bolt",
level=1,
school="evocation",
range="close",
save="none",
effects=(DamageEffect(formula=parse_dice("1d6"), types=("force",)),),
)
PolicyFn = Callable[[CombatEngine, CombatantState], tuple[Action, ...]]
_WAIT: tuple[Action, ...] = (Action(kind="wait"),)
def _counting(decide: Callable[[str, int], tuple[Action, ...]]) -> PolicyFn:
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
return decide(cid, n)
return policy
def _engine(rng: list[int], policy: PolicyFn, states: list[CombatantState]) -> CombatEngine:
return CombatEngine(ScriptedRng(rng), make_grid(), states, policy=policy, round_cap=2)
def _ready_hero() -> tuple[Action, ...]:
return (
Action(kind="ready", ready_trigger="enemy_enters_reach", target_id="foe"),
)
def _order(transcript: tuple[str, ...], *needles: str) -> tuple[int, ...]:
"""Indices of the first transcript line containing each needle."""
return tuple(
next(i for i, line in enumerate(transcript) if needle in line)
for needle in needles
)
# ── (a) readied attack fires when an enemy moves (movement_step) ──────────────
def test_readied_attack_fires_on_enemy_movement() -> None:
hero = make_state(_combatant("hero"), "players", (0, 0))
foe = make_state(_combatant("foe"), "monsters", (0, 2))
eng = _engine(
# init hero 10, init foe 5, readied attack d20=10 (+5 ≥ AC 10 HIT), dmg 3.
[10, 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, foe],
)
result = eng.run()
t = result.transcript
assert "round 1 hero: ready (enemy_enters_reach)" in t
assert "round 1 hero: ready fires (enemy_enters_reach)" in t
attack = "round 1 hero: short sword vs foe d20=10+5=15 AC 10 -> HIT 3 damage (100->97)"
assert attack in t
# Off-turn: the attack resolves DURING the foe's move, before its log line.
idx = _order(
t,
"round 1 hero: ready (enemy_enters_reach)",
"round 1 hero: ready fires (enemy_enters_reach)",
attack,
"round 1 foe: move (0,2)->(0,1)",
)
assert idx == tuple(sorted(idx))
# Consumed: the readied action is gone after firing.
assert hero.readied is None
assert hero.readied_trigger is None
assert foe.hp == 97
# ── (b) readied cast_spell fires on an enemy spell_cast ───────────────────────
def test_readied_cast_fires_on_enemy_spell_cast() -> None:
hero = make_state(_combatant("hero", spells=("Bolt",)), "players", (0, 0))
hero.spell_slots_left = {1: 1}
foe = make_state(_combatant("foe", spells=("Bolt",)), "monsters", (0, 2))
foe.spell_slots_left = {1: 1}
eng = CombatEngine(
# init hero 10, init foe 5, foe's Bolt dmg 4, readied Bolt dmg 2.
ScriptedRng([10, 5, 4, 2]),
make_grid(),
[hero, foe],
spell_registry={"Bolt": _BOLT},
policy=_counting(
lambda cid, n: (
(
Action(
kind="ready",
ready_trigger="enemy_casts",
target_id="foe",
spell_name="Bolt",
),
)
if (cid, n) == ("hero", 0)
else (Action(kind="cast_spell", target_id="hero", spell_name="Bolt"),)
if (cid, n) == ("foe", 0)
else _WAIT
)
),
round_cap=2,
)
result = eng.run()
t = result.transcript
assert "round 1 hero: ready (enemy_casts)" in t
assert any("round 1 foe: cast Bolt vs hero" in line for line in t)
assert "round 1 hero: ready fires (enemy_casts)" in t
assert any("round 1 hero: cast Bolt vs foe" in line for line in t)
idx = _order(
t,
"round 1 foe: cast Bolt vs hero",
"round 1 hero: ready fires (enemy_casts)",
"round 1 hero: cast Bolt vs foe",
)
assert idx == tuple(sorted(idx))
assert hero.hp == 96
assert foe.hp == 98
# The readied cast spent hero's own slot and was consumed.
assert hero.spell_slots_left == {1: 0}
assert hero.readied is None
# ── (c) no fire on a non-matching trigger nor on a friendly event ─────────────
def test_readied_ignores_non_matching_trigger() -> None:
hero = make_state(_combatant("hero"), "players", (0, 0))
foe = make_state(_combatant("foe"), "monsters", (0, 2))
eng = _engine(
# init hero 10, init foe 5, foe's attack d20=2 (+5=7 < AC 10 MISS).
[10, 5, 2],
_counting(
lambda cid, n: (
(
Action(
kind="ready",
ready_trigger="enemy_casts",
target_id="foe",
),
)
if (cid, n) == ("hero", 0)
else (
Action(kind="move", target_id="hero"),
Action(kind="attack", target_id="hero"),
)
if (cid, n) == ("foe", 0)
else _WAIT
)
),
[hero, foe],
)
result = eng.run()
t = result.transcript
# movement_step AND attack_resolved: neither matches enemy_casts.
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)
# 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:
hero = make_state(_combatant("hero"), "players", (0, 0))
ally = make_state(_combatant("ally"), "players", (2, 0))
foe = make_state(_combatant("foe"), "monsters", (0, 4))
eng = _engine(
[10, 9, 5], # initiative only — nothing ever attacks.
_counting(
lambda cid, n: (
_ready_hero()
if (cid, n) == ("hero", 0)
else (Action(kind="move", target_id="hero"),)
if (cid, n) == ("ally", 0)
else _WAIT
)
),
[hero, ally, foe],
)
result = eng.run()
t = result.transcript
# 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 None
assert any("ready expired" in line for line in t)
# ── (d) the readied action is consumed (one-shot) ─────────────────────────────
def test_readied_action_fires_only_once() -> None:
hero = make_state(_combatant("hero"), "players", (0, 0))
v1 = make_state(_combatant("v1"), "monsters", (0, 2))
v2 = make_state(_combatant("v2"), "monsters", (2, 2))
eng = _engine(
# init hero 10, v1 5, v2 4; readied attack on v1: d20=10 HIT, dmg 3.
[10, 5, 4, 10, 3],
_counting(
lambda cid, n: (
(
Action(
kind="ready",
ready_trigger="enemy_enters_reach",
target_id="v1",
),
)
if (cid, n) == ("hero", 0)
else (Action(kind="move", target_id="hero"),)
if cid in ("v1", "v2") and n == 0
else _WAIT
)
),
[hero, v1, v2],
)
result = eng.run()
t = result.transcript
assert sum(1 for line in t if "ready fires" in line) == 1
assert "round 1 hero: short sword vs v1 d20=10+5=15 AC 10 -> HIT 3 damage (100->97)" in t
# v2's later movement_step is a second matching event: no second fire.
assert "round 1 v2: move (2,2)->(1,1)" in t
assert not any("vs v2" in line for line in t)
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 ─────
def test_snapshot_exposes_readied_until_fired() -> None:
hero = make_state(_combatant("hero"), "players", (0, 0))
foe = make_state(_combatant("foe"), "monsters", (0, 2))
eng = _engine(
[10, 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, foe],
)
eng._roll_initiative()
eng._take_turn(hero)
assert hero.last_action_results == [
ActionResult(kind="ready", ok=True, reason="ready")
]
snap = _snapshot(eng, hero)["combatants"][0]
assert snap["readied"] == {"kind": "attack", "trigger": "enemy_enters_reach"}
eng._take_turn(foe) # the foe's move fires the readied attack.
snap_after = _snapshot(eng, hero)["combatants"][0]
assert snap_after["readied"] is None
assert hero.readied is None
# ── (f) no cascade: a fired readied action cannot trigger another ready ───────
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; 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="move", target_id="hero"),
)
if (cid, n) == ("v1", 0)
else _WAIT
)
),
[hero, v1],
)
result = eng.run()
t = result.transcript
# 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("v1: short sword vs hero" in line for line in t)
assert hero.hp == 100
# 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)