feat(rules): add the surprise round

SRD Surprise: when some but not all combatants are aware of their
opponents, a surprise round precedes round 1. Aware combatants take a
single standard OR move action plus free actions (swift and full-round
are dropped, full_attack collapses to one standard attack, withdraw
survives as the Restricted Withdraw standard); unaware combatants skip
their turn. New Combatant.aware flag (default true, parsed at the JSON
boundary) keeps every existing encounter bit-identical, exposed in the
observation snapshot. Unaware combatants are not flagged flat-footed
(documented simplification).

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-09-13 13:20:37 +02:00
parent 7a8351e3b2
commit 3cc0502228
5 changed files with 301 additions and 6 deletions
+167
View File
@@ -0,0 +1,167 @@
"""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); charge/run and a one-round cast are full-round
# and illegal; withdraw survives as the Restricted Withdraw standard.
full = Action(kind="full_attack", target_id="foe")
assert _surprise_filter((full,)) == (Action(kind="attack", target_id="foe"),)
assert _surprise_filter((Action(kind="charge", target_id="foe"),)) == ()
assert _surprise_filter((Action(kind="run", target_id="foe"),)) == ()
withdraw = Action(kind="withdraw", target_id="foe")
assert _surprise_filter((withdraw,)) == (withdraw,)
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,)