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:
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user