"""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 on an alpha, otherwise the default policy.""" if state.combatant.id == "oni-a": return (Action(kind="channel_energy", target_id="oni-b"),) 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)