From 073da40fca338b94a07ee510168d345f88e5a3ea Mon Sep 17 00:00:00 2001 From: ctan Date: Fri, 4 Sep 2026 22:55:54 +0200 Subject: [PATCH] feat(combat): class-ability action kinds, engine RNG only --- src/pf1e_simulator/combat.py | 37 ++++++++++---- tests/test_determinism.py | 95 ++++++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 10 deletions(-) create mode 100644 tests/test_determinism.py diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 013e8c8..fe2dc33 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -277,12 +277,15 @@ class Action: kind: Literal[ "attack", "move", "wait", "swift", "free", "immediate", "full_round", "full_attack", "charge", "withdraw", "5foot_step", "cast_spell", - "maneuver", "channel_energy", "hex", + "maneuver", "channel_energy", "hex", "deed", "spell_combat", + "inspire_courage", ] target_id: str | None = None spell_name: str | None = None maneuver: str | None = None # "trip", "disarm", "bull_rush", "sunder", "grapple", "escape" hex_name: str | None = None # "evil_eye", "slumber", "cackle" + deed_name: str | None = None # "up_close_and_deadly", "quick_clear" + class_ability: str | None = None # "deed"|"spell_combat"|"inspire_courage"|"hex"|"channel" @dataclass(frozen=True) @@ -951,22 +954,36 @@ class CombatEngine: def _execute(self, state: CombatantState, action: Action) -> None: target = next((s for s in self._states if s.combatant.id == action.target_id), None) kind = action.kind - if kind in ("attack", "full_attack"): - self._execute_attack(state, target, kind) + if kind == "cast_spell": + self._execute_cast_spell(state, target, action.spell_name) + elif kind == "maneuver": + self._execute_maneuver(state, target, action.maneuver) + elif kind in ("channel_energy", "hex", "deed", "spell_combat", "inspire_courage"): + self._execute_class_ability(state, target, action) elif kind == "charge": self._execute_charge(state, target) elif kind == "withdraw": self._withdraw(state, target) - elif kind == "cast_spell": - self._execute_cast_spell(state, target, action.spell_name) - elif kind == "maneuver": - self._execute_maneuver(state, target, action.maneuver) - elif kind == "channel_energy": + elif kind in ("attack", "full_attack"): + self._execute_attack(state, target, kind) + else: + self._execute_movement(state, target, kind) + + def _execute_class_ability( + self, state: CombatantState, target: CombatantState | None, action: Action + ) -> None: + """Dispatch a class-ability action by kind, reading its carrier field.""" + kind = action.kind + if kind == "channel_energy": self._execute_channel_energy(state, target) elif kind == "hex": self._execute_hex(state, target, action.hex_name) - else: - self._execute_movement(state, target, kind) + elif kind == "deed": + self._execute_deed(state, target, action.deed_name) + elif kind == "spell_combat": + self._execute_spell_combat(state, target, action.spell_name) + elif kind == "inspire_courage": + self._execute_inspire_courage(state, target) def _execute_movement( self, state: CombatantState, target: CombatantState | None, kind: str diff --git a/tests/test_determinism.py b/tests/test_determinism.py new file mode 100644 index 0000000..b0c85ae --- /dev/null +++ b/tests/test_determinism.py @@ -0,0 +1,95 @@ +"""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)