175 lines
7.2 KiB
Python
175 lines
7.2 KiB
Python
"""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,)
|