"""Tests for off-turn immediate actions (B5.4). A ``reaction_policy`` hook lets a combatant act outside its own turn with an ``Action(kind="immediate")``. Per PF1e, an immediate action consumes the swift action of the combatant's next turn. The hook is opt-in: without a ``reaction_policy`` the engine behaves exactly as before. """ from __future__ import annotations from typing import Literal from pf1e_simulator.combat import ( Action, CombatantState, CombatEngine, CombatResult, ReactionEvent, ReactionPolicy, default_policy, ) from pf1e_simulator.dice import parse_dice from pf1e_simulator.grid import Grid from pf1e_simulator.map import MapSpec, TerrainType from pf1e_simulator.models import ( AbilityScores, ACProfile, AttackSpec, Combatant, DamageComponent, Saves, ) from pf1e_simulator.rng import SeededRng from pf1e_simulator.spells import DamageEffect, SpellSpec def make_combatant( cid: str, *, hp: int = 30, ac: int = 15, attack_bonus: int = 5, damage: str = "1d6", initiative_mod: int = 0, speed: int = 30, kind: Literal["melee", "ranged"] = "melee", ) -> Combatant: attack = AttackSpec( id=f"{cid}-w", name="short bow" if kind == "ranged" else "short sword", kind=kind, attack_bonus=attack_bonus, damage=[ DamageComponent( formula=parse_dice(damage), types=["piercing"] if kind == "ranged" else ["slashing"], ) ], range_increment_ft=60 if kind == "ranged" else None, ) return Combatant( id=cid, name=cid, level=1, size="Medium", abilities=AbilityScores( str_score=10, dex_score=10, con_score=12, int_score=10, wis_score=10, cha_score=10, ), hp_max=hp, ac=ACProfile(total=ac, touch=ac, flat_footed=ac), bab=attack_bonus, initiative_mod=initiative_mod, speed_land_ft=speed, saves=Saves(fort=0, ref=0, will=0), attacks=[attack], ) def make_state(combatant: Combatant, side: str, pos: tuple[int, int]) -> CombatantState: return CombatantState(combatant=combatant, side=side, pos=pos, hp=combatant.hp_max) def make_grid() -> Grid: legend = {".": TerrainType(type="floor", move_cost=1)} spec = MapSpec(name="test", terrain=tuple(["." * 8] * 8), legend=legend) return Grid.from_spec(spec) def test_reaction_policy_records_events() -> None: """A reaction policy is invoked for movement and attack events.""" hero = make_combatant("hero", initiative_mod=5) goblin = make_combatant("goblin") states = [make_state(hero, "players", (1, 1)), make_state(goblin, "monsters", (1, 6))] events: list[ReactionEvent] = [] def approach_policy( _engine: CombatEngine, state: CombatantState ) -> tuple[Action, ...]: if state.combatant.id == "hero": return ( Action(kind="move", target_id="goblin"), Action(kind="attack", target_id="goblin"), ) return default_policy(_engine, state) def recording_policy( _engine: CombatEngine, _state: CombatantState, event: ReactionEvent, ) -> Action | None: events.append(event) return None engine = CombatEngine( SeededRng(3), make_grid(), states, policy=approach_policy, reaction_policy=recording_policy, ) engine.run() assert any(e.kind == "movement_step" for e in events) assert any(e.kind == "attack_resolved" for e in events) def test_no_reaction_policy_runs_unchanged() -> None: """A no-op reaction policy leaves the transcript bit-identical.""" def make_run(reaction_policy: ReactionPolicy | None = None) -> CombatResult: states = [ make_state(make_combatant("hero", initiative_mod=5), "players", (1, 1)), make_state(make_combatant("goblin"), "monsters", (1, 2)), ] return CombatEngine( SeededRng(9), make_grid(), states, reaction_policy=reaction_policy ).run() def noop_policy( _engine: CombatEngine, _state: CombatantState, _event: ReactionEvent, ) -> Action | None: return None assert make_run(reaction_policy=noop_policy).transcript == make_run().transcript def test_immediate_attack_reaction_executes() -> None: """A waiting ally can react to an enemy attack with an immediate attack.""" hero = make_combatant("hero", initiative_mod=5) goblin = make_combatant("goblin") hero_state = make_state(hero, "players", (1, 1)) goblin_state = make_state(goblin, "monsters", (1, 2)) states = [hero_state, goblin_state] def wait_policy( _engine: CombatEngine, state: CombatantState ) -> tuple[Action, ...]: if state.combatant.id == "hero": return (Action(kind="wait"),) return default_policy(_engine, state) def reaction_policy( _engine: CombatEngine, state: CombatantState, event: ReactionEvent ) -> Action | None: if ( state.combatant.id == "hero" and event.kind == "attack_resolved" and event.target is not None and event.target.combatant.id == "hero" ): return Action(kind="immediate", target_id="goblin") return None engine = CombatEngine( SeededRng(4), make_grid(), states, policy=wait_policy, reaction_policy=reaction_policy, ) result = engine.run() assert any("hero: short sword vs goblin" in line for line in result.transcript) assert goblin_state.hp < 30 def test_immediate_consumes_next_swift() -> None: """An immediate action consumes the swift action of the next turn.""" hero = make_combatant("hero", initiative_mod=5) goblin = make_combatant("goblin") hero_state = make_state(hero, "players", (1, 1)) hero_state.resources_left = {"grit": 2} states = [hero_state, make_state(goblin, "monsters", (1, 2))] reacted = False def deed_policy( _engine: CombatEngine, state: CombatantState ) -> tuple[Action, ...]: if state.combatant.id == "hero": if state.resources_left.get("grit", 0) > 0: return ( Action( kind="deed", target_id="goblin", deed_name="up_close_and_deadly", ), Action(kind="attack", target_id="goblin"), ) return (Action(kind="attack", target_id="goblin"),) return default_policy(_engine, state) def reaction_policy( _engine: CombatEngine, state: CombatantState, event: ReactionEvent ) -> Action | None: nonlocal reacted if state.combatant.id == "hero" and event.kind == "attack_resolved" and not reacted: reacted = True return Action(kind="immediate", target_id="goblin") return None engine = CombatEngine( SeededRng(4), make_grid(), states, policy=deed_policy, reaction_policy=reaction_policy, ) result = engine.run() deed_rounds = [ int(line.split()[1]) for line in result.transcript if "hero: deed Up Close and Deadly activated" in line ] assert deed_rounds == [1, 3] def test_immediate_in_turn_sequence_executes() -> None: """An immediate action in the turn sequence executes like an attack.""" hero = make_combatant("hero", initiative_mod=5) goblin = make_combatant("goblin") hero_state = make_state(hero, "players", (1, 1)) goblin_state = make_state(goblin, "monsters", (1, 2)) states = [hero_state, goblin_state] def immediate_policy( _engine: CombatEngine, state: CombatantState ) -> tuple[Action, ...]: if state.combatant.id == "hero": return (Action(kind="immediate", target_id="goblin"),) return default_policy(_engine, state) engine = CombatEngine( SeededRng(4), make_grid(), states, policy=immediate_policy, ) result = engine.run() assert any("hero: short sword vs goblin" in line for line in result.transcript) assert goblin_state.hp < 30 def test_reaction_determinism_same_seed_identical_transcripts() -> None: """Reactions draw from the engine RNG: same seed, identical transcripts.""" def make_run() -> CombatResult: hero = make_combatant("hero", initiative_mod=5) goblin = make_combatant("goblin") states = [make_state(hero, "players", (1, 1)), make_state(goblin, "monsters", (1, 2))] def wait_policy( _engine: CombatEngine, state: CombatantState ) -> tuple[Action, ...]: if state.combatant.id == "hero": return (Action(kind="wait"),) return default_policy(_engine, state) def reaction_policy( _engine: CombatEngine, state: CombatantState, event: ReactionEvent ) -> Action | None: if ( state.combatant.id == "hero" and event.kind == "attack_resolved" and event.target is not None and event.target.combatant.id == "hero" ): return Action(kind="immediate", target_id="goblin") return None return CombatEngine( SeededRng(7), make_grid(), states, policy=wait_policy, reaction_policy=reaction_policy, ).run() result_one = make_run() result_two = make_run() assert result_one.transcript == result_two.transcript assert result_one.transcript assert any("hero: short sword vs goblin" in line for line in result_one.transcript) def test_damage_taken_reaction_fires() -> None: """A reaction policy can react to spell damage taken.""" caster = make_combatant("caster", hp=50) reactor = make_combatant("reactor", hp=50, kind="ranged") caster_state = make_state(caster, "players", (1, 1)) reactor_state = make_state(reactor, "monsters", (1, 3)) states = [caster_state, reactor_state] bolt = SpellSpec( name="Bolt", level=1, school="evocation", range="close", save="none", effects=(DamageEffect(formula=parse_dice("1d6"), types=("force",)),), ) def cast_policy( _engine: CombatEngine, state: CombatantState ) -> tuple[Action, ...]: if state.combatant.id == "caster": return (Action(kind="cast_spell", target_id="reactor", spell_name="Bolt"),) if state.combatant.id == "reactor": return (Action(kind="wait"),) return default_policy(_engine, state) def reaction_policy( _engine: CombatEngine, state: CombatantState, event: ReactionEvent ) -> Action | None: if state.combatant.id == "reactor" and event.kind == "damage_taken": return Action(kind="immediate", target_id="caster") return None engine = CombatEngine( SeededRng(4), make_grid(), states, policy=cast_policy, spell_registry={"Bolt": bolt}, reaction_policy=reaction_policy, ) result = engine.run() assert any("reactor: short bow vs caster" in line for line in result.transcript) assert caster_state.hp < 50