354 lines
11 KiB
Python
354 lines
11 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
|
|
|
|
from pf1e_simulator.combat import (
|
|
Action,
|
|
CombatantState,
|
|
CombatEngine,
|
|
CombatResult,
|
|
ReactionEvent,
|
|
ReactionPolicy,
|
|
default_policy,
|
|
)
|
|
from pf1e_simulator.dice import parse_dice
|
|
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_immediate_consumes_next_swift() -> None:
|
|
"""An 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 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_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))
|
|
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
|