"""S3a conformance: the surprise round (SRD "Surprise"). "If some but not all of the combatants are aware of their opponents, a surprise round happens before regular rounds begin. In initiative order (highest to lowest), combatants who started the battle aware of their opponents each take a standard or move action during the surprise round. You can also take free actions during the surprise round. [...] Combatants who are unaware at the start of battle don't get to act in the surprise round. [...] If no one or everyone is surprised, no surprise round occurs." """ from __future__ import annotations from pf1e_simulator.combat import Action, CombatantState, CombatEngine, _surprise_filter from pf1e_simulator.rng import ScriptedRng from tests.test_combat import make_combatant, make_grid, make_state _WAIT: tuple[Action, ...] = (Action(kind="wait"),) def _unaware(state: CombatantState) -> CombatantState: state.combatant = state.combatant.model_copy(update={"aware": False}) return state def _surprise_duel() -> CombatEngine: """Aware hero (init 15) vs unaware foe (init 10), foe 3 cells away. The hero's policy is (move, attack): in the surprise round only the move is legal; round 1 resumes the full economy (the attack is made). Scripted rolls: initiative, then the round-1 attack (miss). """ hero = make_state(make_combatant("hero"), "players", (0, 0)) foe = make_state(make_combatant("foe"), "monsters", (0, 3)) _unaware(foe) def policy(_e: CombatEngine, s: CombatantState) -> tuple[Action, ...]: if s.combatant.id == "hero": return ( Action(kind="move", target_id="foe"), Action(kind="attack", target_id="foe"), ) return _WAIT return CombatEngine( ScriptedRng([15, 10, 10]), make_grid(), [hero, foe], policy=policy, round_cap=1, ) def test_surprise_round_runs_when_some_are_unaware() -> None: # Given: an aware hero and an unaware foe # When: the battle starts # Then: a surprise round runs before round 1. transcript = _surprise_duel().run().transcript assert "surprise round" in transcript def test_aware_combatant_acts_once_in_the_surprise_round() -> None: # Given: the hero proposes move + attack (both legal in a full round) # When: the surprise round runs # Then: the hero takes the single allowed standard-or-move action (the # move) and the second action is never executed in round 0. transcript = _surprise_duel().run().transcript hero_round0 = [ln for ln in transcript if ln.startswith("round 0 hero:")] assert any("move" in ln for ln in hero_round0) assert not any("vs foe" in ln for ln in hero_round0) def test_unaware_combatant_skips_the_surprise_round() -> None: # Given: a foe unaware at the start of battle # When: the surprise round runs # Then: the foe does not act, and the skip is logged. transcript = _surprise_duel().run().transcript foe_round0 = [ln for ln in transcript if ln.startswith("round 0 foe:")] assert foe_round0 == ["round 0 foe: unaware — no action in surprise round"] def test_full_rounds_resume_after_the_surprise_round() -> None: # Given: the same surprise duel # When: round 1 begins # Then: the aware hero gets its full economy back (the attack is made). transcript = _surprise_duel().run().transcript assert any(ln.startswith("round 1 hero:") and "vs foe" in ln for ln in transcript) def _wait_policy(_e: CombatEngine, _s: CombatantState) -> tuple[Action, ...]: return _WAIT def test_no_surprise_round_when_everyone_is_aware() -> None: # Given: two aware combatants (the default) # When: the battle starts # Then: no surprise round occurs. hero = make_state(make_combatant("hero"), "players", (0, 0)) foe = make_state(make_combatant("foe"), "monsters", (0, 3)) engine = CombatEngine( ScriptedRng([15, 10]), make_grid(), [hero, foe], policy=_wait_policy, round_cap=1, ) assert "surprise round" not in engine.run().transcript def test_no_surprise_round_when_everyone_is_unaware() -> None: # Given: both sides unaware ("If no one or everyone is surprised, no # surprise round occurs") # When: the battle starts # Then: combat opens directly on a full round 1. hero = _unaware(make_state(make_combatant("hero"), "players", (0, 0))) foe = _unaware(make_state(make_combatant("foe"), "monsters", (0, 3))) engine = CombatEngine( ScriptedRng([15, 10]), make_grid(), [hero, foe], policy=_wait_policy, round_cap=1, ) transcript = engine.run().transcript assert "surprise round" not in transcript assert not any(ln.startswith("round 0 ") for ln in transcript) def test_observation_exposes_aware() -> None: # Given: an aware hero and an unaware foe # When: the observation snapshot is built # Then: each combatant dict carries its aware flag. engine = _surprise_duel() hero, foe = engine._states snapshot = engine._build_observation(hero) assert snapshot.combatants[0]["aware"] is True assert snapshot.combatants[1]["aware"] is False def test_surprise_filter_keeps_one_standard_or_move_plus_free() -> None: # Given/When/Then per SRD "Surprise": a single standard OR move action # survives (first wins), free actions ride, swift and full-round go. attack = Action(kind="attack", target_id="foe") move = Action(kind="move", target_id="foe") step = Action(kind="5foot_step", target_id="foe") drop = Action(kind="drop_prone") assert _surprise_filter((attack, move)) == (attack,) assert _surprise_filter((move, attack)) == (move,) assert _surprise_filter((step, attack)) == (step, attack) assert _surprise_filter((attack, drop)) == (attack, drop) assert _surprise_filter((attack, Action(kind="deed"))) == (attack,) def test_surprise_filter_collapses_full_attack_and_drops_full_rounds() -> None: # full_attack collapses to a single standard attack (same convention as # the staggered filter); run and a one-round cast are full-round and # illegal; withdraw survives as the Restricted Withdraw standard, and # charge as the Restricted Charge (SRD "Charge": "If you are able to take # only a standard action on your turn, you can still charge, but you are # only allowed to move up to your speed"). full = Action(kind="full_attack", target_id="foe") assert _surprise_filter((full,)) == (Action(kind="attack", target_id="foe"),) charge = Action(kind="charge", target_id="foe") assert _surprise_filter((charge,)) == (charge,) assert _surprise_filter((Action(kind="run", target_id="foe"),)) == () withdraw = Action(kind="withdraw", target_id="foe") assert _surprise_filter((withdraw,)) == (withdraw,) attack = Action(kind="attack", target_id="foe") assert _surprise_filter((charge, attack)) == (charge,) assert _surprise_filter((attack, charge)) == (attack,) summon = Action(kind="cast_spell", target_id="foe", spell_name="Summon") assert _surprise_filter((summon,), cast_time=lambda _a: "one_round") == () bolt = Action(kind="cast_spell", target_id="foe", spell_name="Bolt") assert _surprise_filter((bolt,), cast_time=lambda _a: "standard") == (bolt,)