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:
@@ -364,6 +364,26 @@ Règles modélisées :
|
|||||||
appliqué.
|
appliqué.
|
||||||
- `hp < 0` = inactive (mourante ou morte) ; `hp = 0` = active mais invalide.
|
- `hp < 0` = inactive (mourante ou morte) ; `hp = 0` = active mais invalide.
|
||||||
- Initiative : triée sur (total, modificateur, ordre de liste), sans re-jet.
|
- 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
|
- Économie d'action : un tour normal donne une action standard + une action de
|
||||||
mouvement (ou une action à round complet), plus swift/free/immediate. La
|
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 ;
|
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,
|
- Par combattant : id, side, pos, occupied_cells, hp/hp_max, active/dead,
|
||||||
dying/stabilized, conditions (noms + rounds_left), resources_left (+ `spell_slot_{level}`),
|
dying/stabilized, conditions (noms + rounds_left), resources_left (+ `spell_slot_{level}`),
|
||||||
weapon_broken/weapon_stolen, grappled_with, moved_this_turn, power_attack,
|
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
|
bab (E1), initiative_mod (E2) et initiative_total (U2 : total du jet
|
||||||
d'initiative courant = d20 + mod, 0 si pas encore de clé — invocation
|
d'initiative courant = d20 + mod, 0 si pas encore de clé — invocation
|
||||||
fraîche), casting_ability ou None pour non-jeteur
|
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
|
Limites hors périmètre (arrêtées lors de la conformance L1-L10, documentées
|
||||||
plutôt que bricolé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
|
- **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 à
|
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
|
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) :
|
La gate de validation complète (tests + lint + types) :
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv run pytest -q # 1393 tests
|
uv run pytest -q # 1402 tests
|
||||||
uv run ruff check src tests
|
uv run ruff check src tests
|
||||||
uv run basedpyright src # mode strict
|
uv run basedpyright src # mode strict
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -928,12 +928,15 @@ class Action:
|
|||||||
# standard+move). The cast-time package adds downgraded:cast_time for
|
# 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
|
# 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
|
# 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({
|
ACTION_RESULT_REASONS: frozenset[str] = frozenset({
|
||||||
"ok", "unsupported_kind", "missing_target", "no_weapon", "out_of_range",
|
"ok", "unsupported_kind", "missing_target", "no_weapon", "out_of_range",
|
||||||
"no_line_of_effect", "no_slot", "insufficient_resource",
|
"no_line_of_effect", "no_slot", "insufficient_resource",
|
||||||
"downgraded:staggered", "downgraded:nauseated",
|
"downgraded:staggered", "downgraded:nauseated",
|
||||||
"downgraded:action_economy", "downgraded:cast_time",
|
"downgraded:action_economy", "downgraded:cast_time",
|
||||||
|
"downgraded:surprise",
|
||||||
"delayed", "already_delayed", "ready",
|
"delayed", "already_delayed", "ready",
|
||||||
"not_prone", "already_prone", "unreachable_destination",
|
"not_prone", "already_prone", "unreachable_destination",
|
||||||
"weapon_unavailable", "invalid_destination",
|
"weapon_unavailable", "invalid_destination",
|
||||||
@@ -946,6 +949,7 @@ _REASON_DOWNGRADED_NAUSEATED = "downgraded:nauseated"
|
|||||||
_REASON_DOWNGRADED_IMMEDIATE_SWIFT = "downgraded:immediate_swift"
|
_REASON_DOWNGRADED_IMMEDIATE_SWIFT = "downgraded:immediate_swift"
|
||||||
_REASON_DOWNGRADED_ACTION_ECONOMY = "downgraded:action_economy"
|
_REASON_DOWNGRADED_ACTION_ECONOMY = "downgraded:action_economy"
|
||||||
_REASON_DOWNGRADED_CAST_TIME = "downgraded:cast_time"
|
_REASON_DOWNGRADED_CAST_TIME = "downgraded:cast_time"
|
||||||
|
_REASON_DOWNGRADED_SURPRISE = "downgraded:surprise"
|
||||||
_REASON_DELAYED = "delayed"
|
_REASON_DELAYED = "delayed"
|
||||||
_REASON_ALREADY_DELAYED = "already_delayed"
|
_REASON_ALREADY_DELAYED = "already_delayed"
|
||||||
_REASON_READY = "ready"
|
_REASON_READY = "ready"
|
||||||
@@ -1083,6 +1087,10 @@ class CombatEngine:
|
|||||||
self._move_aoo_provoked: dict[str, set[str]] = {}
|
self._move_aoo_provoked: dict[str, set[str]] = {}
|
||||||
self._initiative_keys: dict[str, tuple[float, int, int]] = {}
|
self._initiative_keys: dict[str, tuple[float, int, int]] = {}
|
||||||
self._pending_summons: list[CombatantState] = []
|
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
|
@property
|
||||||
def states(self) -> list[CombatantState]:
|
def states(self) -> list[CombatantState]:
|
||||||
@@ -1341,6 +1349,7 @@ class CombatEngine:
|
|||||||
"name": state.combatant.name,
|
"name": state.combatant.name,
|
||||||
"level": state.combatant.level,
|
"level": state.combatant.level,
|
||||||
"size": state.combatant.size,
|
"size": state.combatant.size,
|
||||||
|
"aware": state.combatant.aware,
|
||||||
"creature_type": state.combatant.creature_type,
|
"creature_type": state.combatant.creature_type,
|
||||||
"bab": state.combatant.bab,
|
"bab": state.combatant.bab,
|
||||||
"initiative_mod": state.combatant.initiative_mod,
|
"initiative_mod": state.combatant.initiative_mod,
|
||||||
@@ -1689,6 +1698,17 @@ class CombatEngine:
|
|||||||
for kind in _dropped_kinds_multiset(actions, kept)
|
for kind in _dropped_kinds_multiset(actions, kept)
|
||||||
)
|
)
|
||||||
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:
|
if state.immediate_used:
|
||||||
kept = _without_swift_bonus(actions, cast_time=self._cast_time_of)
|
kept = _without_swift_bonus(actions, cast_time=self._cast_time_of)
|
||||||
results.extend(
|
results.extend(
|
||||||
@@ -1971,6 +1991,8 @@ class CombatEngine:
|
|||||||
def run(self) -> CombatResult:
|
def run(self) -> CombatResult:
|
||||||
"""Run the battle and return the final result and transcript."""
|
"""Run the battle and return the final result and transcript."""
|
||||||
self._roll_initiative()
|
self._roll_initiative()
|
||||||
|
if self._surprise_round_applies():
|
||||||
|
self._run_surprise_round()
|
||||||
round_no = 1
|
round_no = 1
|
||||||
while round_no <= self._round_cap:
|
while round_no <= self._round_cap:
|
||||||
self._current_round = round_no
|
self._current_round = round_no
|
||||||
@@ -1993,6 +2015,45 @@ class CombatEngine:
|
|||||||
stats=stats,
|
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:
|
def _run_round(self) -> bool:
|
||||||
"""Execute one round; return True if the battle ended.
|
"""Execute one round; return True if the battle ended.
|
||||||
|
|
||||||
@@ -7208,6 +7269,48 @@ def _staggered_filter(
|
|||||||
return tuple(kept)
|
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, ...]:
|
def _nauseated_filter(actions: tuple[Action, ...]) -> tuple[Action, ...]:
|
||||||
"""PF1e (CRB) nauseated: a single move action per turn, nothing else.
|
"""PF1e (CRB) nauseated: a single move action per turn, nothing else.
|
||||||
|
|
||||||
|
|||||||
@@ -208,6 +208,8 @@ def load_combatant(path: Path) -> Combatant:
|
|||||||
|
|
||||||
``features`` in the JSON are a list of feat name strings resolved via
|
``features`` in the JSON are a list of feat name strings resolved via
|
||||||
``_resolve_feat``. ``spells`` are passed through as-is (list of strings).
|
``_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:
|
try:
|
||||||
raw = path.read_text(encoding="utf-8")
|
raw = path.read_text(encoding="utf-8")
|
||||||
|
|||||||
@@ -309,3 +309,8 @@ class Combatant(_FrozenModel):
|
|||||||
spell_resistance: int = 0
|
spell_resistance: int = 0
|
||||||
resistances: dict[str, int] = Field(default_factory=dict)
|
resistances: dict[str, int] = Field(default_factory=dict)
|
||||||
immunities: tuple[str, ...] = ()
|
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
|
||||||
|
|||||||
@@ -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,)
|
||||||
Reference in New Issue
Block a user