diff --git a/README.md b/README.md index d140e09..a1af29e 100644 --- a/README.md +++ b/README.md @@ -306,6 +306,24 @@ Règles modélisées : 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 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` 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 @@ -315,7 +333,8 @@ Règles modélisées : par les filtres staggered/nauseated (ou swift consommé par une immédiate) → `downgraded:staggered` / `downgraded:nauseated` / `downgraded:immediate_swift` ; 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 ») 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 @@ -350,7 +369,9 @@ Règles modélisées : 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 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 peut pas être attaquée — la politique se déplace jusqu'à gagner une ligne de visée. @@ -570,7 +591,9 @@ Règles modélisées : inconsciente), stabilized (bool — stabilisée pendant son agonie, ne perd 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 - 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, range_penalty, can_charge, can_step5_to_attack, has_line_of_effect, flanking, movement_cost_ft, target_threatens_me, enemies_threatening_me. diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 0cc0983..2265d22 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -13,7 +13,10 @@ Phase 0 documented deviations from PF1e (conventions): hook: each resolved attack, movement step, spell cast, and damage application emits a ``ReactionEvent``; the first combatant whose policy returns an ``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 below). - 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"] + ReadyTrigger = Literal[ + "enemy_enters_reach", "enemy_casts", "enemy_attacks", "enemy_hits_ally" + ] + from pf1e_simulator.los import ( cover_bonus_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. delayed_this_round: bool = False 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]: return [ @@ -509,7 +521,10 @@ class Action: 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 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[ @@ -517,7 +532,7 @@ class Action: "full_attack", "charge", "withdraw", "5foot_step", "cast_spell", "maneuver", "channel_energy", "hex", "deed", "spell_combat", "inspire_courage", "retrieve_weapon", "repair_weapon", - "activate_power_attack", "delay", + "activate_power_attack", "delay", "ready", ] target_id: str | None = None spell_name: str | None = None @@ -530,18 +545,19 @@ class Action: direction: tuple[int, int] | None = None # steering for directional maneuvers trick_condition: TrickCondition | None = None # dirty trick condition choice 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, # missing_target and the downgraded:* reasons; no_weapon, out_of_range, # no_line_of_effect, no_slot and insufficient_resource are declared here for # 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({ "ok", "unsupported_kind", "missing_target", "no_weapon", "out_of_range", "no_line_of_effect", "no_slot", "insufficient_resource", "downgraded:staggered", "downgraded:nauseated", - "delayed", "already_delayed", + "delayed", "already_delayed", "ready", }) _REASON_OK = "ok" _REASON_UNSUPPORTED_KIND = "unsupported_kind" @@ -551,6 +567,7 @@ _REASON_DOWNGRADED_NAUSEATED = "downgraded:nauseated" _REASON_DOWNGRADED_IMMEDIATE_SWIFT = "downgraded:immediate_swift" _REASON_DELAYED = "delayed" _REASON_ALREADY_DELAYED = "already_delayed" +_REASON_READY = "ready" # Action kinds that are declared but have no engine dispatch (U1 surfaces # them as explicit rejections instead of silent no-ops). @@ -940,6 +957,11 @@ class CombatEngine: {"kind": r.kind, "ok": r.ok, "reason": r.reason} 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( @@ -2386,6 +2408,8 @@ class CombatEngine: return ActionResult(kind=kind, ok=False, reason=_REASON_MISSING_TARGET) if kind == "delay": return self._execute_delay(state, target, action) + if kind == "ready": + return self._execute_ready(state, action) self._dispatch_action(state, target, action) 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}") 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( self, state: CombatantState, target: CombatantState | None, action: Action ) -> None: @@ -2536,13 +2580,20 @@ class CombatEngine: ) 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) - whose policy returns an action gets to act. Reactions never cascade — - while an immediate is executing, further events are ignored. + A pending readied action fires independently of ``reaction_policy``; + at most one trigger resolves per event (a fired readied action + 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 for state in self._states: if not state.active or state.immediate_used: @@ -2555,6 +2606,66 @@ class CombatEngine: self._in_reaction = False 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( self, state: CombatantState, @@ -4259,6 +4370,30 @@ def _nauseated_filter(actions: tuple[Action, ...]) -> tuple[Action, ...]: 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, ...]] LLMPolicy = Callable[[Observation, CombatantState], tuple[Action, ...]] diff --git a/tests/test_ready_action.py b/tests/test_ready_action.py new file mode 100644 index 0000000..9d4a183 --- /dev/null +++ b/tests/test_ready_action.py @@ -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"