From 3cc0502228d20d9f22f1098f61e60e2064aebdc8 Mon Sep 17 00:00:00 2001 From: ctan Date: Sun, 13 Sep 2026 13:20:37 +0200 Subject: [PATCH] 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 --- README.md | 28 +++- src/pf1e_simulator/combat.py | 105 ++++++++++++++- src/pf1e_simulator/loaders/combatant.py | 2 + src/pf1e_simulator/models.py | 5 + tests/test_surprise_round.py | 167 ++++++++++++++++++++++++ 5 files changed, 301 insertions(+), 6 deletions(-) create mode 100644 tests/test_surprise_round.py diff --git a/README.md b/README.md index b3ff5a0..d08122b 100644 --- a/README.md +++ b/README.md @@ -364,6 +364,26 @@ Règles modélisées : appliqué. - `hp < 0` = inactive (mourante ou morte) ; `hp = 0` = active mais invalide. - Initiative : triée sur (total, modificateur, ordre de liste), sans re-jet. +- Round de surprise (SRD « Surprise ») : le champ booléen `aware` du JSON de + combattant (défaut `true`) encode la conscience des adversaires au début du + combat. Si certains combattants actifs sont `aware` et d'autres non, un + round de surprise (round 0) précède le round 1 : « 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. » Le filtre + `_surprise_filter` applique cette allowance : une seule action standard + **ou** de mouvement (la première de la séquence gagne), les actions libres + conservées, les actions swift et à round complet retirées + (`downgraded:surprise`) ; un `full_attack` se dégrade en attaque standard + unique (même convention que le filtre accablé) et `withdraw` survit comme + retraite restreinte standard (SRD « Full-Round Actions »). « Combatants who + are unaware at the start of battle don't get to act in the surprise round » + — les non-`aware` sautent leur tour (journalisé). « If no one or everyone is + surprised, no surprise round occurs » — avec le défaut `aware: true`, le + comportement est bit-identique à l'absence de la règle. Simplification + documentée : les combattants non avertis ne sont pas marqués `flat_footed` + pendant le round de surprise (le moteur n'injecte jamais cette condition + lui-même — voir « Actions immédiates »). - Économie d'action : un tour normal donne une action standard + une action de mouvement (ou une action à round complet), plus swift/free/immediate. La politique renvoie une séquence ordonnée d'actions exécutées dans l'ordre ; @@ -1321,7 +1341,8 @@ Règles modélisées : - Par combattant : id, side, pos, occupied_cells, hp/hp_max, active/dead, dying/stabilized, conditions (noms + rounds_left), resources_left (+ `spell_slot_{level}`), weapon_broken/weapon_stolen, grappled_with, moved_this_turn, power_attack, - name, level, size, dr {amount, bypass} ou None (C1), creature_type (C3), + name, level, size, aware (SRD « Surprise » : agit au round de surprise), + dr {amount, bypass} ou None (C1), creature_type (C3), bab (E1), initiative_mod (E2) et initiative_total (U2 : total du jet d'initiative courant = d20 + mod, 0 si pas encore de clé — invocation fraîche), casting_ability ou None pour non-jeteur @@ -1432,9 +1453,6 @@ la lecture mais non appliquée dans la résolution. Limites hors périmètre (arrêtées lors de la conformance L1-L10, documentées plutôt que bricolées) : -- **Pas de round de surprise** : le modèle de combattant n'a pas de champ de - perception/attention (conscience du combat), donc le SRD « Surprise » ne - peut pas être résolu — le combat démarre toujours au round 1 complet. - **Incantation `one_round` bornée à un round complet** : le moteur ne tient pas d'incantation en cours entre les tours ; un sort d'1 round se résout à la fin du tour courant au lieu du suivant (approximation documentée dans @@ -1561,7 +1579,7 @@ Limites de sorts (conformance D6-D9, documentées plutôt que bricolées) : La gate de validation complète (tests + lint + types) : ```bash -uv run pytest -q # 1393 tests +uv run pytest -q # 1402 tests uv run ruff check src tests uv run basedpyright src # mode strict ``` diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index bc6f936..5fd3b8a 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -928,12 +928,15 @@ class Action: # standard+move). The cast-time package adds downgraded:cast_time for # cast_spell actions whose spell has a long casting time (more than 1 round): the # engine cannot schedule them within a turn, so they are dropped before the -# budget filter runs. +# budget filter runs. S3a adds downgraded:surprise for actions the surprise +# round allowance (SRD "Surprise": one standard OR move plus free) drops from +# an aware combatant's sequence during the surprise round. 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", "downgraded:action_economy", "downgraded:cast_time", + "downgraded:surprise", "delayed", "already_delayed", "ready", "not_prone", "already_prone", "unreachable_destination", "weapon_unavailable", "invalid_destination", @@ -946,6 +949,7 @@ _REASON_DOWNGRADED_NAUSEATED = "downgraded:nauseated" _REASON_DOWNGRADED_IMMEDIATE_SWIFT = "downgraded:immediate_swift" _REASON_DOWNGRADED_ACTION_ECONOMY = "downgraded:action_economy" _REASON_DOWNGRADED_CAST_TIME = "downgraded:cast_time" +_REASON_DOWNGRADED_SURPRISE = "downgraded:surprise" _REASON_DELAYED = "delayed" _REASON_ALREADY_DELAYED = "already_delayed" _REASON_READY = "ready" @@ -1083,6 +1087,10 @@ class CombatEngine: self._move_aoo_provoked: dict[str, set[str]] = {} self._initiative_keys: dict[str, tuple[float, int, int]] = {} self._pending_summons: list[CombatantState] = [] + # True while the surprise round runs (SRD "Surprise"): the turn filter + # then degrades every aware combatant's sequence to a single standard + # OR move action plus free actions. + self._surprise_round = False @property def states(self) -> list[CombatantState]: @@ -1341,6 +1349,7 @@ class CombatEngine: "name": state.combatant.name, "level": state.combatant.level, "size": state.combatant.size, + "aware": state.combatant.aware, "creature_type": state.combatant.creature_type, "bab": state.combatant.bab, "initiative_mod": state.combatant.initiative_mod, @@ -1689,6 +1698,17 @@ class CombatEngine: for kind in _dropped_kinds_multiset(actions, kept) ) actions = kept + if self._surprise_round: + # PF1e (SRD "Surprise"): the surprise round allowance replaces the + # normal budget — one standard OR move action plus free actions. + kept = _surprise_filter( + actions, cast_time=self._cast_time_of, free_draw=free_draw + ) + results.extend( + ActionResult(kind=kind, ok=False, reason=_REASON_DOWNGRADED_SURPRISE) + for kind in _dropped_kinds(actions, kept) + ) + return kept if state.immediate_used: kept = _without_swift_bonus(actions, cast_time=self._cast_time_of) results.extend( @@ -1971,6 +1991,8 @@ class CombatEngine: def run(self) -> CombatResult: """Run the battle and return the final result and transcript.""" self._roll_initiative() + if self._surprise_round_applies(): + self._run_surprise_round() round_no = 1 while round_no <= self._round_cap: self._current_round = round_no @@ -1993,6 +2015,45 @@ class CombatEngine: stats=stats, ) + def _surprise_round_applies(self) -> bool: + """PF1e (SRD "Surprise"): "If some but not all of the combatants are + aware of their opponents, a surprise round happens before regular + rounds begin"; if no one or everyone is surprised, none occurs.""" + active = [s for s in self._states if s.active] + return any(not s.combatant.aware for s in active) and any( + s.combatant.aware for s in active + ) + + def _run_surprise_round(self) -> None: + """PF1e (SRD "Surprise"): one partial round before round 1. In + initiative order (highest to lowest), aware combatants each take a + standard OR move action plus free actions (``_surprise_filter``); + "Combatants who are unaware at the start of battle don't get to act + in the surprise round." + Simplification: unaware combatants are not flagged flat-footed during + the surprise round — the engine never injects the flat-footed + condition itself (see README limitations). + """ + self._current_round = 0 + self._aoo_used.clear() + self._move_aoo_provoked.clear() + self._log("surprise round") + self._surprise_round = True + try: + for state in self._build_round_order(): + if not state.active: + continue + if not state.combatant.aware: + self._log( + f"round 0 {state.combatant.id}: unaware — " + "no action in surprise round" + ) + continue + if self._take_turn(state): + return + finally: + self._surprise_round = False + def _run_round(self) -> bool: """Execute one round; return True if the battle ended. @@ -7208,6 +7269,48 @@ def _staggered_filter( return tuple(kept) +def _surprise_filter( + actions: tuple[Action, ...], + *, + cast_time: CastTimeResolver = _default_cast_time, + free_draw: bool = False, +) -> tuple[Action, ...]: + """Surprise round: one standard OR move action, plus every free action. + + PF1e (SRD "Surprise"): "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." The rule text + grants neither a swift action nor a full-round action, so swift and + full-round kinds are dropped — with the same conventions as + ``_staggered_filter``: a ``full_attack`` collapses to a single standard + ``attack``, and ``withdraw`` survives as the standard-action Restricted + Withdraw (SRD "Full-Round Actions": legal "if you are limited to taking + only a standard action each round"). + """ + kept: list[Action] = [] + slot_used = False # the single standard-or-move slot is spent + for action in actions: + if action.kind == "full_attack": + if not slot_used: + kept.append(Action(kind="attack", target_id=action.target_id)) + slot_used = True + continue + slot = _effective_slot(action, cast_time, free_draw=free_draw) + if slot == "full_round": + if action.kind == "withdraw" and not slot_used: + kept.append(action) + slot_used = True + continue + if slot in ("standard", "move"): + if slot_used: + continue + kept.append(action) + slot_used = True + elif slot == "free": + kept.append(action) + return tuple(kept) + + def _nauseated_filter(actions: tuple[Action, ...]) -> tuple[Action, ...]: """PF1e (CRB) nauseated: a single move action per turn, nothing else. diff --git a/src/pf1e_simulator/loaders/combatant.py b/src/pf1e_simulator/loaders/combatant.py index 22b46e9..74cb469 100644 --- a/src/pf1e_simulator/loaders/combatant.py +++ b/src/pf1e_simulator/loaders/combatant.py @@ -208,6 +208,8 @@ def load_combatant(path: Path) -> Combatant: ``features`` in the JSON are a list of feat name strings resolved via ``_resolve_feat``. ``spells`` are passed through as-is (list of strings). + The ``aware`` flag (SRD "Surprise") is a plain boolean of the + ``Combatant`` schema (default ``true``), so it parses directly here. """ try: raw = path.read_text(encoding="utf-8") diff --git a/src/pf1e_simulator/models.py b/src/pf1e_simulator/models.py index f376ec0..9333846 100644 --- a/src/pf1e_simulator/models.py +++ b/src/pf1e_simulator/models.py @@ -309,3 +309,8 @@ class Combatant(_FrozenModel): spell_resistance: int = 0 resistances: dict[str, int] = Field(default_factory=dict) immunities: tuple[str, ...] = () + # PF1e (SRD "Surprise"): "If some but not all of the combatants are aware + # of their opponents, a surprise round happens before regular rounds + # begin." Unaware combatants do not act in the surprise round. Default + # True keeps every existing encounter on a full first round. + aware: bool = True diff --git a/tests/test_surprise_round.py b/tests/test_surprise_round.py new file mode 100644 index 0000000..c80d538 --- /dev/null +++ b/tests/test_surprise_round.py @@ -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,)