Files
pf1e-simulator/tests/test_determinism.py
T

96 lines
3.4 KiB
Python

"""Determinism regression and class-ability action routing tests.
Every die roll in the engine flows through the injected ``Rng`` stream, never
a module-global ``random``. Two runs with the same seed must produce identical
transcripts — including a channel-energy caster that draws from the engine RNG
via ``roll_die``. The class-ability ``Action`` kinds (deed, spell_combat,
inspire_courage, hex, channel_energy) must route through ``_execute`` and read
their carrier fields without raising AttributeError.
"""
from __future__ import annotations
from pathlib import Path
from pf1e_simulator.combat import (
Action,
CombatantState,
CombatEngine,
CombatResult,
default_policy,
)
from pf1e_simulator.grid import Grid
from pf1e_simulator.loaders.combatant import load_combatant
from pf1e_simulator.map import MapSpec, TerrainType
from pf1e_simulator.rng import SeededRng
_DATA_DIR = Path(__file__).resolve().parent.parent / "data"
_ONI_JSON = _DATA_DIR / "pcs" / "oni.json"
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 _make_channel_policy(_engine: CombatEngine, state: CombatantState) -> tuple[Action, ...]:
"""Emit a channel_energy (heal mode) on an alpha, otherwise the default policy."""
if state.combatant.id == "oni-a":
return (Action(kind="channel_energy", target_id="oni-b", channel_mode="heal"),)
return default_policy(_engine, state)
def _make_state(combatant_id: str, side: str, pos: tuple[int, int]) -> CombatantState:
combatant = load_combatant(_ONI_JSON)
effective = combatant if combatant.id == combatant_id else combatant.model_copy(
update={"id": combatant_id}
)
return CombatantState(
combatant=effective,
side=side,
pos=pos,
hp=effective.hp_max,
resources_left=dict(effective.resources),
deed_up_close_and_deadly=False,
)
def test_channel_energy_draws_from_engine_rng_same_seed_identical_transcripts() -> None:
"""Given the same seed, two runs of a channel-energy caster have identical transcripts."""
def make_run() -> CombatResult:
states = [
_make_state("oni-a", "players", (1, 2)),
_make_state("oni-b", "monsters", (2, 4)),
]
return CombatEngine(
SeededRng(7), _make_grid(), states, policy=_make_channel_policy
).run()
result_one = make_run()
result_two = make_run()
assert result_one.transcript == result_two.transcript
assert result_one.transcript
assert any("channel" in line for line in result_one.transcript)
def test_class_ability_action_kinds_route_without_attribute_error() -> None:
"""Given class-ability actions, _execute dispatches them reading carrier fields."""
attacker = _make_state("oni-att", "players", (1, 2))
target = _make_state("oni-tgt", "monsters", (2, 4))
states = [attacker, target]
engine = CombatEngine(SeededRng(9), _make_grid(), states)
cases: list[Action] = [
Action(kind="deed", target_id="oni-tgt", deed_name="up_close_and_deadly"),
Action(kind="spell_combat", target_id="oni-tgt"),
Action(kind="inspire_courage", target_id="oni-tgt"),
Action(kind="hex", target_id="oni-tgt", hex_name="evil_eye"),
Action(kind="channel_energy", target_id="oni-tgt"),
]
for action in cases:
engine._execute(attacker, action)