Files
pf1e-simulator/tests/test_immediate_actions.py
T

515 lines
17 KiB
Python

"""Tests for off-turn immediate actions (B5.4).
A ``reaction_policy`` hook lets a combatant act outside its own turn with an
``Action(kind="immediate")``. Per PF1e, an immediate action consumes the swift
action of the combatant's next turn. The hook is opt-in: without a
``reaction_policy`` the engine behaves exactly as before.
"""
from __future__ import annotations
from typing import Literal
import pf1e_simulator.combat as combat_mod
from pf1e_simulator.combat import (
ACTION_RESULT_REASONS,
Action,
CombatantState,
CombatEngine,
CombatResult,
ReactionEvent,
ReactionPolicy,
default_policy,
)
from pf1e_simulator.conditions import CONDITIONS_BY_NAME
from pf1e_simulator.dice import parse_dice
from pf1e_simulator.effects import ActiveCondition
from pf1e_simulator.grid import Grid
from pf1e_simulator.map import MapSpec, TerrainType
from pf1e_simulator.models import (
AbilityScores,
ACProfile,
AttackSpec,
Combatant,
DamageComponent,
Saves,
)
from pf1e_simulator.rng import SeededRng
from pf1e_simulator.spells import DamageEffect, SpellSpec
def make_combatant(
cid: str,
*,
hp: int = 30,
ac: int = 15,
attack_bonus: int = 5,
damage: str = "1d6",
initiative_mod: int = 0,
speed: int = 30,
kind: Literal["melee", "ranged"] = "melee",
) -> Combatant:
attack = AttackSpec(
id=f"{cid}-w",
name="short bow" if kind == "ranged" else "short sword",
kind=kind,
attack_bonus=attack_bonus,
damage=[
DamageComponent(
formula=parse_dice(damage),
types=["piercing"] if kind == "ranged" else ["slashing"],
)
],
range_increment_ft=60 if kind == "ranged" else None,
)
return Combatant(
id=cid,
name=cid,
level=1,
size="Medium",
abilities=AbilityScores(
str_score=10,
dex_score=10,
con_score=12,
int_score=10,
wis_score=10,
cha_score=10,
),
hp_max=hp,
ac=ACProfile(total=ac, touch=ac, flat_footed=ac),
bab=attack_bonus,
initiative_mod=initiative_mod,
speed_land_ft=speed,
saves=Saves(fort=0, ref=0, will=0),
attacks=[attack],
)
def make_state(combatant: Combatant, side: str, pos: tuple[int, int]) -> CombatantState:
return CombatantState(combatant=combatant, side=side, pos=pos, hp=combatant.hp_max)
def make_grid() -> Grid:
legend = {".": TerrainType(type="floor", move_cost=1)}
spec = MapSpec(name="test", terrain=tuple(["." * 8] * 8), legend=legend)
return Grid.from_spec(spec)
def test_reaction_policy_records_events() -> None:
"""A reaction policy is invoked for movement and attack events."""
hero = make_combatant("hero", initiative_mod=5)
goblin = make_combatant("goblin")
states = [make_state(hero, "players", (1, 1)), make_state(goblin, "monsters", (1, 6))]
events: list[ReactionEvent] = []
def approach_policy(
_engine: CombatEngine, state: CombatantState
) -> tuple[Action, ...]:
if state.combatant.id == "hero":
return (
Action(kind="move", target_id="goblin"),
Action(kind="attack", target_id="goblin"),
)
return default_policy(_engine, state)
def recording_policy(
_engine: CombatEngine,
_state: CombatantState,
event: ReactionEvent,
) -> Action | None:
events.append(event)
return None
engine = CombatEngine(
SeededRng(3),
make_grid(),
states,
policy=approach_policy,
reaction_policy=recording_policy,
)
engine.run()
assert any(e.kind == "movement_step" for e in events)
assert any(e.kind == "attack_resolved" for e in events)
def test_no_reaction_policy_runs_unchanged() -> None:
"""A no-op reaction policy leaves the transcript bit-identical."""
def make_run(reaction_policy: ReactionPolicy | None = None) -> CombatResult:
states = [
make_state(make_combatant("hero", initiative_mod=5), "players", (1, 1)),
make_state(make_combatant("goblin"), "monsters", (1, 2)),
]
return CombatEngine(
SeededRng(9), make_grid(), states, reaction_policy=reaction_policy
).run()
def noop_policy(
_engine: CombatEngine,
_state: CombatantState,
_event: ReactionEvent,
) -> Action | None:
return None
assert make_run(reaction_policy=noop_policy).transcript == make_run().transcript
def test_immediate_attack_reaction_executes() -> None:
"""A waiting ally can react to an enemy attack with an immediate attack."""
hero = make_combatant("hero", initiative_mod=5)
goblin = make_combatant("goblin")
hero_state = make_state(hero, "players", (1, 1))
goblin_state = make_state(goblin, "monsters", (1, 2))
states = [hero_state, goblin_state]
def wait_policy(
_engine: CombatEngine, state: CombatantState
) -> tuple[Action, ...]:
if state.combatant.id == "hero":
return (Action(kind="wait"),)
return default_policy(_engine, state)
def reaction_policy(
_engine: CombatEngine, state: CombatantState, event: ReactionEvent
) -> Action | None:
if (
state.combatant.id == "hero"
and event.kind == "attack_resolved"
and event.target is not None
and event.target.combatant.id == "hero"
):
return Action(kind="immediate", target_id="goblin")
return None
engine = CombatEngine(
SeededRng(4),
make_grid(),
states,
policy=wait_policy,
reaction_policy=reaction_policy,
)
result = engine.run()
assert any("hero: short sword vs goblin" in line for line in result.transcript)
assert goblin_state.hp < 30
def test_flat_footed_cannot_use_immediate_action() -> None:
"""PF1e (SRD "Immediate Actions"): "You also cannot use an immediate
action if you are flat-footed." Given: a flat-footed ally whose reaction
policy would fire an immediate attack When: the goblin attacks the hero
Then: no immediate fires and the ally never strikes."""
hero = make_combatant("hero", initiative_mod=5)
goblin = make_combatant("goblin")
ally = make_combatant("ally")
ally_state = make_state(ally, "players", (1, 2))
ally_state.active_conditions.append(
ActiveCondition(
condition=CONDITIONS_BY_NAME["flat_footed"],
rounds_left=100,
source="test",
)
)
states = [
ally_state,
make_state(hero, "players", (1, 0)),
make_state(goblin, "monsters", (1, 3)),
]
def wait_policy(
_engine: CombatEngine, state: CombatantState
) -> tuple[Action, ...]:
if state.combatant.id in ("hero", "ally"):
return (Action(kind="wait"),)
return default_policy(_engine, state)
def reaction_policy(
_engine: CombatEngine, state: CombatantState, event: ReactionEvent
) -> Action | None:
if state.combatant.id == "ally" and event.kind == "attack_resolved":
return Action(kind="immediate", target_id="goblin")
return None
engine = CombatEngine(
SeededRng(4),
make_grid(),
states,
policy=wait_policy,
reaction_policy=reaction_policy,
)
result = engine.run()
assert not any("ally: short sword vs goblin" in line for line in result.transcript)
def test_not_flat_footed_ally_immediate_fires_control() -> None:
"""Control: the same ally WITHOUT the flat_footed condition does fire the
immediate reaction — the block above is the condition, not the setup."""
hero = make_combatant("hero", initiative_mod=5)
goblin = make_combatant("goblin")
ally = make_combatant("ally")
ally_state = make_state(ally, "players", (1, 2))
states = [
ally_state,
make_state(hero, "players", (1, 0)),
make_state(goblin, "monsters", (1, 3)),
]
def wait_policy(
_engine: CombatEngine, state: CombatantState
) -> tuple[Action, ...]:
if state.combatant.id in ("hero", "ally"):
return (Action(kind="wait"),)
return default_policy(_engine, state)
def reaction_policy(
_engine: CombatEngine, state: CombatantState, event: ReactionEvent
) -> Action | None:
if state.combatant.id == "ally" and event.kind == "attack_resolved":
return Action(kind="immediate", target_id="goblin")
return None
engine = CombatEngine(
SeededRng(4),
make_grid(),
states,
policy=wait_policy,
reaction_policy=reaction_policy,
)
result = engine.run()
assert any("ally: short sword vs goblin" in line for line in result.transcript)
def test_immediate_consumes_next_swift() -> None:
"""An OFF-TURN immediate action consumes the swift action of the next turn."""
hero = make_combatant("hero", initiative_mod=5)
goblin = make_combatant("goblin")
hero_state = make_state(hero, "players", (1, 1))
hero_state.resources_left = {"grit": 2}
states = [hero_state, make_state(goblin, "monsters", (1, 2))]
reacted = False
def deed_policy(
_engine: CombatEngine, state: CombatantState
) -> tuple[Action, ...]:
if state.combatant.id == "hero":
if state.resources_left.get("grit", 0) > 0:
return (
Action(
kind="deed",
target_id="goblin",
deed_name="up_close_and_deadly",
),
Action(kind="attack", target_id="goblin"),
)
return (Action(kind="attack", target_id="goblin"),)
return default_policy(_engine, state)
def reaction_policy(
_engine: CombatEngine, state: CombatantState, event: ReactionEvent
) -> Action | None:
nonlocal reacted
if (
state.combatant.id == "hero"
and event.kind == "attack_resolved"
and event.actor.combatant.id == "goblin"
and not reacted
):
reacted = True
return Action(kind="immediate", target_id="goblin")
return None
engine = CombatEngine(
SeededRng(4),
make_grid(),
states,
policy=deed_policy,
reaction_policy=reaction_policy,
)
result = engine.run()
deed_rounds = [
int(line.split()[1])
for line in result.transcript
if "hero: deed Up Close and Deadly activated" in line
]
assert deed_rounds == [1, 3]
def test_in_turn_immediate_consumes_current_swift() -> None:
"""SRD Immediate Actions: "Using an immediate action on your turn is the
same as using a swift action and counts as your swift action for that
turn." The hero attacks; its own attack_resolved event triggers its
in-turn reaction, spending the current swift — so the deed that follows
in the same sequence is dropped, and the next turn's swift stays free."""
hero = make_combatant("hero", initiative_mod=5)
goblin = make_combatant("goblin")
hero_state = make_state(hero, "players", (1, 1))
hero_state.resources_left = {"grit": 2}
states = [hero_state, make_state(goblin, "monsters", (1, 2))]
reacted = False
def deed_policy(
_engine: CombatEngine, state: CombatantState
) -> tuple[Action, ...]:
if state.combatant.id == "hero":
return (
Action(kind="attack", target_id="goblin"),
Action(kind="deed", target_id="goblin", deed_name="up_close_and_deadly"),
)
return (Action(kind="wait"),)
def reaction_policy(
_engine: CombatEngine, state: CombatantState, event: ReactionEvent
) -> Action | None:
nonlocal reacted
if (
state.combatant.id == "hero"
and event.kind == "attack_resolved"
and event.actor.combatant.id == "hero"
and not reacted
):
reacted = True
return Action(kind="immediate", target_id="goblin")
return None
engine = CombatEngine(
SeededRng(4),
make_grid(),
states,
policy=deed_policy,
reaction_policy=reaction_policy,
)
engine._roll_initiative()
engine._take_turn(hero_state)
# The in-turn immediate spent the current swift: the deed is dropped...
reasons = [(r.kind, r.reason) for r in hero_state.last_action_results]
assert ("deed", "downgraded:immediate_swift") in reasons
# ...and the NEXT turn's swift is NOT consumed (unlike the off-turn rule).
assert hero_state.immediate_used is False
def test_immediate_in_turn_sequence_executes() -> None:
"""An immediate action in the turn sequence executes like an attack."""
hero = make_combatant("hero", initiative_mod=5)
goblin = make_combatant("goblin")
hero_state = make_state(hero, "players", (1, 1))
goblin_state = make_state(goblin, "monsters", (1, 2))
states = [hero_state, goblin_state]
def immediate_policy(
_engine: CombatEngine, state: CombatantState
) -> tuple[Action, ...]:
if state.combatant.id == "hero":
return (Action(kind="immediate", target_id="goblin"),)
return default_policy(_engine, state)
engine = CombatEngine(
SeededRng(4),
make_grid(),
states,
policy=immediate_policy,
)
result = engine.run()
assert any("hero: short sword vs goblin" in line for line in result.transcript)
assert goblin_state.hp < 30
def test_reaction_determinism_same_seed_identical_transcripts() -> None:
"""Reactions draw from the engine RNG: same seed, identical transcripts."""
def make_run() -> CombatResult:
hero = make_combatant("hero", initiative_mod=5)
goblin = make_combatant("goblin")
states = [make_state(hero, "players", (1, 1)), make_state(goblin, "monsters", (1, 2))]
def wait_policy(
_engine: CombatEngine, state: CombatantState
) -> tuple[Action, ...]:
if state.combatant.id == "hero":
return (Action(kind="wait"),)
return default_policy(_engine, state)
def reaction_policy(
_engine: CombatEngine, state: CombatantState, event: ReactionEvent
) -> Action | None:
if (
state.combatant.id == "hero"
and event.kind == "attack_resolved"
and event.target is not None
and event.target.combatant.id == "hero"
):
return Action(kind="immediate", target_id="goblin")
return None
return CombatEngine(
SeededRng(7),
make_grid(),
states,
policy=wait_policy,
reaction_policy=reaction_policy,
).run()
result_one = make_run()
result_two = make_run()
assert result_one.transcript == result_two.transcript
assert result_one.transcript
assert any("hero: short sword vs goblin" in line for line in result_one.transcript)
def test_damage_taken_reaction_fires() -> None:
"""A reaction policy can react to spell damage taken."""
caster = make_combatant("caster", hp=50)
reactor = make_combatant("reactor", hp=50, kind="ranged")
caster_state = make_state(caster, "players", (1, 1))
caster_state.spell_slots_left = {1: 9}
reactor_state = make_state(reactor, "monsters", (1, 3))
states = [caster_state, reactor_state]
bolt = SpellSpec(
name="Bolt",
level=1,
school="evocation",
range="close",
save="none",
effects=(DamageEffect(formula=parse_dice("1d6"), types=("force",)),),
)
def cast_policy(
_engine: CombatEngine, state: CombatantState
) -> tuple[Action, ...]:
if state.combatant.id == "caster":
return (Action(kind="cast_spell", target_id="reactor", spell_name="Bolt"),)
if state.combatant.id == "reactor":
return (Action(kind="wait"),)
return default_policy(_engine, state)
def reaction_policy(
_engine: CombatEngine, state: CombatantState, event: ReactionEvent
) -> Action | None:
if state.combatant.id == "reactor" and event.kind == "damage_taken":
return Action(kind="immediate", target_id="caster")
return None
engine = CombatEngine(
SeededRng(4),
make_grid(),
states,
policy=cast_policy,
spell_registry={"Bolt": bolt},
reaction_policy=reaction_policy,
)
result = engine.run()
assert any("reactor: short bow vs caster" in line for line in result.transcript)
assert caster_state.hp < 50
def test_downgraded_immediate_swift_is_in_action_result_reasons() -> None:
"""The reason vocabulary published to policies must contain every reason the
engine emits: downgraded:immediate_swift (swift dropped because an
in-turn immediate spent it) was emitted but missing from the frozenset."""
assert "downgraded:immediate_swift" in ACTION_RESULT_REASONS
emitted = {
value
for name, value in vars(combat_mod).items()
if name.startswith("_REASON_") and isinstance(value, str)
}
assert emitted <= set(ACTION_RESULT_REASONS)