feat(combat): add ready action with triggers

This commit is contained in:
2026-09-10 12:39:28 +02:00
parent f83e4b8016
commit ab02597bc4
3 changed files with 547 additions and 13 deletions
+26 -3
View File
@@ -306,6 +306,24 @@ Règles modélisées :
round est rejeté (`already_delayed`), et le drapeau se réinitialise au début round est rejeté (`already_delayed`), et le drapeau se réinitialise au début
de chaque round. `default_policy` ne choisit pas le retard (disponible via de chaque round. `default_policy` ne choisit pas le retard (disponible via
une politique personnalisée). une politique personnalisée).
- Préparation (`Action(kind="ready", ready_trigger=...)`, action standard) : le
combattant se prépare à agir quand un événement déclencheur survient. Le
payload est encodé dans les champs porteurs de l'action, comme l'immédiate :
`spell_name` → incantation, `maneuver` → manœuvre (avec `direction` /
`trick_condition`), sinon attaque simple sur `target_id` (les capacités de
classe en payload ne sont pas modélisées). Quatre déclencheurs, mappés aux
événements de réaction existants : `enemy_enters_reach` ← pas de mouvement
d'un ennemi (simplification documentée : la portée menacée n'est pas
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).
- Retour d'exécution des actions : chaque action produit un `ActionResult` - Retour d'exécution des actions : chaque action produit un `ActionResult`
explicite (kind, ok, reason) consigné dans le snapshot (`last_turn_results` explicite (kind, ok, reason) consigné dans le snapshot (`last_turn_results`
au tour suivant). Les actions invalides sont rejetées et journalisées au lieu au tour suivant). Les actions invalides sont rejetées et journalisées au lieu
@@ -315,7 +333,8 @@ Règles modélisées :
par les filtres staggered/nauseated (ou swift consommé par une immédiate) → par les filtres staggered/nauseated (ou swift consommé par une immédiate) →
`downgraded:staggered` / `downgraded:nauseated` / `downgraded:immediate_swift` ; `downgraded:staggered` / `downgraded:nauseated` / `downgraded:immediate_swift` ;
retard valide → `ok=True, reason="delayed"`, second retard du même round → retard valide → `ok=True, reason="delayed"`, second retard du même round →
`ok=False, reason="already_delayed"`. `ok=False, reason="already_delayed"` ; préparation valide →
`ok=True, reason="ready"`.
- Attaques multiples par action : une arme avec `count` > 1 (« 2x Talons ») - Attaques multiples par action : une arme avec `count` > 1 (« 2x Talons »)
résout `count` balayages indépendants dans la même action ; les balayages résout `count` balayages indépendants dans la même action ; les balayages
restants sont perdus si la cible tombe (inconsciente ou morte) en cours de restants sont perdus si la cible tombe (inconsciente ou morte) en cours de
@@ -350,7 +369,9 @@ Règles modélisées :
réaction `Action(kind="immediate")` peut être exécutée hors tour — une par réaction `Action(kind="immediate")` peut être exécutée hors tour — une par
événement — et consomme le prochain swift de l'état (immédiate pendant le événement — et consomme le prochain swift de l'état (immédiate pendant le
tour : exécutée, consomme aussi le prochain swift — simplification tour : exécutée, consomme aussi le prochain swift — simplification
documentée). documentée). Les actions préparées (`ready`) sont traitées sur ces mêmes
événements **avant** et indépendamment de la politique de réaction (voir
« Préparation » plus haut).
- Ligne d'effet : une cible entièrement derrière un terrain `blocks_los` ne - Ligne d'effet : une cible entièrement derrière un terrain `blocks_los` ne
peut pas être attaquée — la politique se déplace jusqu'à gagner une ligne de peut pas être attaquée — la politique se déplace jusqu'à gagner une ligne de
visée. visée.
@@ -570,7 +591,9 @@ Règles modélisées :
inconsciente), stabilized (bool — stabilisée pendant son agonie, ne perd inconsciente), stabilized (bool — stabilisée pendant son agonie, ne perd
plus de pv tant qu'aucune nouvelle blessure ne la remet en agonie), plus de pv tant qu'aucune nouvelle blessure ne la remet en agonie),
last_turn_results (tuple de {kind, ok, reason} — résultat d'exécution du last_turn_results (tuple de {kind, ok, reason} — résultat d'exécution du
dernier tour, pour que la policy apprenne les actions rejetées/dégradées). dernier tour, pour que la policy apprenne les actions rejetées/dégradées),
readied ({kind, trigger} de l'action préparée ou None — préparation en
cours, consommée quand le déclencheur la fait tirer).
- Par paire combattant actif × cible : target_index, distance_ft, weapon_for, - Par paire combattant actif × cible : target_index, distance_ft, weapon_for,
range_penalty, can_charge, can_step5_to_attack, has_line_of_effect, range_penalty, can_charge, can_step5_to_attack, has_line_of_effect,
flanking, movement_cost_ft, target_threatens_me, enemies_threatening_me. flanking, movement_cost_ft, target_threatens_me, enemies_threatening_me.
+145 -10
View File
@@ -13,7 +13,10 @@ Phase 0 documented deviations from PF1e (conventions):
hook: each resolved attack, movement step, spell cast, and damage application hook: each resolved attack, movement step, spell cast, and damage application
emits a ``ReactionEvent``; the first combatant whose policy returns an emits a ``ReactionEvent``; the first combatant whose policy returns an
``Action(kind="immediate")`` executes it immediately, and its next turn's ``Action(kind="immediate")`` executes it immediately, and its next turn's
swift action is consumed. Flanking, full attack, charge, withdraw, and swift action is consumed. A readied action (``Action(kind="ready")``,
standard) stores a carrier-field payload that fires off-turn through the
same ``ReactionEvent`` machinery when its ``ready_trigger`` matches an
enemy event, then is consumed. Flanking, full attack, charge, withdraw, and
5-foot step are modeled; attacks of opportunity are resolved inline (see 5-foot step are modeled; attacks of opportunity are resolved inline (see
below). below).
- A weapon with `count` > 1 ("2x Talons") resolves `count` independent attacks - A weapon with `count` > 1 ("2x Talons") resolves `count` independent attacks
@@ -114,6 +117,10 @@ if TYPE_CHECKING:
TrickCondition = Literal["dazzled", "blinded", "deafened", "entangled", "nauseated"] TrickCondition = Literal["dazzled", "blinded", "deafened", "entangled", "nauseated"]
ReadyTrigger = Literal[
"enemy_enters_reach", "enemy_casts", "enemy_attacks", "enemy_hits_ally"
]
from pf1e_simulator.los import ( from pf1e_simulator.los import (
cover_bonus_cells, cover_bonus_cells,
has_line_of_effect_cells, has_line_of_effect_cells,
@@ -350,6 +357,11 @@ class CombatantState:
# chose to act just before (None = end of the round). Reset at round start. # chose to act just before (None = end of the round). Reset at round start.
delayed_this_round: bool = False delayed_this_round: bool = False
delay_before: str | None = None delay_before: str | None = None
# Ready action (not in the frozen data model): the concrete payload the
# combatant prepared with its standard action, and the trigger event that
# fires it. Cleared when the readied action executes (one-shot).
readied: Action | None = None
readied_trigger: ReadyTrigger | None = None
def usable_attacks(self) -> list[AttackSpec]: def usable_attacks(self) -> list[AttackSpec]:
return [ return [
@@ -509,7 +521,10 @@ class Action:
actions, ``immediate`` is an off-turn reaction (deferred). ``wait`` is a actions, ``immediate`` is an off-turn reaction (deferred). ``wait`` is a
no-op. ``delay`` is a standard action that forgoes the turn to act later no-op. ``delay`` is a standard action that forgoes the turn to act later
in the same round (end of order, or just before ``target_id``). The engine in the same round (end of order, or just before ``target_id``). The engine
executes a policy-returned sequence per turn. executes a policy-returned sequence per turn. ``ready`` is a standard
action that prepares the carrier-field payload (spell / maneuver / attack
on ``target_id``) to fire off-turn when ``ready_trigger`` matches a
``ReactionEvent``; the readied action is then consumed.
""" """
kind: Literal[ kind: Literal[
@@ -517,7 +532,7 @@ class Action:
"full_attack", "charge", "withdraw", "5foot_step", "cast_spell", "full_attack", "charge", "withdraw", "5foot_step", "cast_spell",
"maneuver", "channel_energy", "hex", "deed", "spell_combat", "maneuver", "channel_energy", "hex", "deed", "spell_combat",
"inspire_courage", "retrieve_weapon", "repair_weapon", "inspire_courage", "retrieve_weapon", "repair_weapon",
"activate_power_attack", "delay", "activate_power_attack", "delay", "ready",
] ]
target_id: str | None = None target_id: str | None = None
spell_name: str | None = None spell_name: str | None = None
@@ -530,18 +545,19 @@ class Action:
direction: tuple[int, int] | None = None # steering for directional maneuvers direction: tuple[int, int] | None = None # steering for directional maneuvers
trick_condition: TrickCondition | None = None # dirty trick condition choice trick_condition: TrickCondition | None = None # dirty trick condition choice
weapon_id: str | None = None # weapon id for retrieve_weapon / repair_weapon weapon_id: str | None = None # weapon id for retrieve_weapon / repair_weapon
ready_trigger: ReadyTrigger | None = None # ready action trigger event
# Normalized action-result reason vocabulary. U1 emits ok, unsupported_kind, # Normalized action-result reason vocabulary. U1 emits ok, unsupported_kind,
# missing_target and the downgraded:* reasons; no_weapon, out_of_range, # missing_target and the downgraded:* reasons; no_weapon, out_of_range,
# no_line_of_effect, no_slot and insufficient_resource are declared here for # no_line_of_effect, no_slot and insufficient_resource are declared here for
# the deeper execution-feedback package (U6b). U3 adds delayed / # the deeper execution-feedback package (U6b). U3 adds delayed /
# already_delayed for the delay action. # already_delayed for the delay action. U4 adds ready for the ready action.
ACTION_RESULT_REASONS: frozenset[str] = frozenset({ ACTION_RESULT_REASONS: frozenset[str] = frozenset({
"ok", "unsupported_kind", "missing_target", "no_weapon", "out_of_range", "ok", "unsupported_kind", "missing_target", "no_weapon", "out_of_range",
"no_line_of_effect", "no_slot", "insufficient_resource", "no_line_of_effect", "no_slot", "insufficient_resource",
"downgraded:staggered", "downgraded:nauseated", "downgraded:staggered", "downgraded:nauseated",
"delayed", "already_delayed", "delayed", "already_delayed", "ready",
}) })
_REASON_OK = "ok" _REASON_OK = "ok"
_REASON_UNSUPPORTED_KIND = "unsupported_kind" _REASON_UNSUPPORTED_KIND = "unsupported_kind"
@@ -551,6 +567,7 @@ _REASON_DOWNGRADED_NAUSEATED = "downgraded:nauseated"
_REASON_DOWNGRADED_IMMEDIATE_SWIFT = "downgraded:immediate_swift" _REASON_DOWNGRADED_IMMEDIATE_SWIFT = "downgraded:immediate_swift"
_REASON_DELAYED = "delayed" _REASON_DELAYED = "delayed"
_REASON_ALREADY_DELAYED = "already_delayed" _REASON_ALREADY_DELAYED = "already_delayed"
_REASON_READY = "ready"
# Action kinds that are declared but have no engine dispatch (U1 surfaces # Action kinds that are declared but have no engine dispatch (U1 surfaces
# them as explicit rejections instead of silent no-ops). # them as explicit rejections instead of silent no-ops).
@@ -940,6 +957,11 @@ class CombatEngine:
{"kind": r.kind, "ok": r.ok, "reason": r.reason} {"kind": r.kind, "ok": r.ok, "reason": r.reason}
for r in state.last_action_results for r in state.last_action_results
), ),
"readied": (
{"kind": state.readied.kind, "trigger": state.readied_trigger}
if state.readied is not None
else None
),
} }
def _pair_snapshot( def _pair_snapshot(
@@ -2386,6 +2408,8 @@ class CombatEngine:
return ActionResult(kind=kind, ok=False, reason=_REASON_MISSING_TARGET) return ActionResult(kind=kind, ok=False, reason=_REASON_MISSING_TARGET)
if kind == "delay": if kind == "delay":
return self._execute_delay(state, target, action) return self._execute_delay(state, target, action)
if kind == "ready":
return self._execute_ready(state, action)
self._dispatch_action(state, target, action) self._dispatch_action(state, target, action)
return ActionResult(kind=kind, ok=True, reason=_REASON_OK) return ActionResult(kind=kind, ok=True, reason=_REASON_OK)
@@ -2437,6 +2461,26 @@ class CombatEngine:
self._log(f"round {self._current_round} {state.combatant.id}: {kind}") self._log(f"round {self._current_round} {state.combatant.id}: {kind}")
return ActionResult(kind=kind, ok=True, reason=_REASON_DELAYED) return ActionResult(kind=kind, ok=True, reason=_REASON_DELAYED)
def _execute_ready(self, state: CombatantState, action: Action) -> ActionResult:
"""Execute the ready action (PF1e): spend the standard action preparing a
payload that fires off-turn when its trigger event occurs.
The payload is derived from the carrier fields like
``_execute_immediate`` (spell / maneuver / attack on ``target_id``;
class-ability payloads are out of scope for U4). A new ready replaces
any pending one. The engine fires it through ``_check_readied``, then
the readied action is consumed. The turn loop keeps running (a policy
may still spend the move action).
"""
kind = action.kind
state.readied = _ready_payload(action)
state.readied_trigger = action.ready_trigger
label = action.ready_trigger or "no trigger"
self._log(
f"round {self._current_round} {state.combatant.id}: {kind} ({label})"
)
return ActionResult(kind=kind, ok=True, reason=_REASON_READY)
def _dispatch_action( def _dispatch_action(
self, state: CombatantState, target: CombatantState | None, action: Action self, state: CombatantState, target: CombatantState | None, action: Action
) -> None: ) -> None:
@@ -2536,13 +2580,20 @@ class CombatEngine:
) )
def _check_reactions(self, event: ReactionEvent) -> None: def _check_reactions(self, event: ReactionEvent) -> None:
"""Offer an event to the reaction policy; execute the first immediate. """Fire readied actions first, then offer the event to the reaction policy.
One immediate per event: the first active combatant (in state order) A pending readied action fires independently of ``reaction_policy``;
whose policy returns an action gets to act. Reactions never cascade — at most one trigger resolves per event (a fired readied action
while an immediate is executing, further events are ignored. suppresses the policy loop). One immediate per event: the first active
combatant (in state order) whose policy returns an action gets to act.
Reactions never cascade — while a reaction is executing, further
events are ignored.
""" """
if self._reaction_policy is None or self._in_reaction: if self._in_reaction:
return
if self._check_readied(event):
return
if self._reaction_policy is None:
return return
for state in self._states: for state in self._states:
if not state.active or state.immediate_used: if not state.active or state.immediate_used:
@@ -2555,6 +2606,66 @@ class CombatEngine:
self._in_reaction = False self._in_reaction = False
return return
def _check_readied(self, event: ReactionEvent) -> bool:
"""Fire the first matching readied action; True when one fired.
Any active combatant (in state order) whose pending trigger matches
the event executes its payload off-turn with no swift cost. The
pending action is cleared BEFORE the payload runs, so a readied
action is one-shot and never re-fires on its own events; the payload's
own events are additionally suppressed by ``_in_reaction``.
"""
for state in self._states:
payload = state.readied
if not state.active or payload is None:
continue
if not self._ready_trigger_matches(state, event):
continue
trigger = state.readied_trigger
state.readied = None
state.readied_trigger = None
self._log(
f"round {self._current_round} {state.combatant.id}: "
f"ready fires ({trigger})"
)
target = next(
(s for s in self._states if s.combatant.id == payload.target_id),
None,
)
self._in_reaction = True
self._dispatch_action(state, target, payload)
self._in_reaction = False
return True
return False
def _ready_trigger_matches(
self, state: CombatantState, event: ReactionEvent
) -> bool:
"""True when the event matches the combatant's pending readied trigger.
Firing requires the event actor to be an ENEMY of the readier
(different side); ``enemy_hits_ally`` additionally requires the
damage target to share the readier's side. ``enemy_enters_reach``
fires on any enemy movement step — documented simplification: reach
is not re-computed per step.
"""
trigger = state.readied_trigger
if trigger is None:
return False
hostile = event.actor.side != state.side
if trigger == "enemy_enters_reach":
return hostile and event.kind == "movement_step"
if trigger == "enemy_casts":
return hostile and event.kind == "spell_cast"
if trigger == "enemy_attacks":
return hostile and event.kind == "attack_resolved"
return (
hostile
and event.kind == "damage_taken"
and event.target is not None
and event.target.side == state.side
)
def _execute_cast_spell( def _execute_cast_spell(
self, self,
state: CombatantState, state: CombatantState,
@@ -4259,6 +4370,30 @@ def _nauseated_filter(actions: tuple[Action, ...]) -> tuple[Action, ...]:
return () return ()
def _ready_payload(action: Action) -> Action:
"""Derive the concrete payload of a ready action from its carrier fields.
Mirrors ``_execute_immediate``: ``spell_name`` casts, ``maneuver``
performs the maneuver (carrying direction/trick_condition), anything
else attacks ``target_id``. Class-ability payloads are out of scope for U4.
"""
if action.spell_name is not None:
return Action(
kind="cast_spell",
target_id=action.target_id,
spell_name=action.spell_name,
)
if action.maneuver is not None:
return Action(
kind="maneuver",
target_id=action.target_id,
maneuver=action.maneuver,
direction=action.direction,
trick_condition=action.trick_condition,
)
return Action(kind="attack", target_id=action.target_id)
Policy = Callable[[CombatEngine, CombatantState], tuple[Action, ...]] Policy = Callable[[CombatEngine, CombatantState], tuple[Action, ...]]
LLMPolicy = Callable[[Observation, CombatantState], tuple[Action, ...]] LLMPolicy = Callable[[Observation, CombatantState], tuple[Action, ...]]
+376
View File
@@ -0,0 +1,376 @@
"""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)
assert hero.readied is not None
assert hero.readied_trigger == "enemy_casts"
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.
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
# ── (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
# ── (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; round 2 readied attack: 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"),
)
if (cid, n) == ("v1", 0)
else (Action(kind="move", target_id="hero"),)
if (cid, n) == ("v1", 1)
else _WAIT
)
),
[hero, v1],
)
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
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 hero.hp == 100
assert v1.readied is not None
assert v1.readied_trigger == "enemy_attacks"