From 625e8c56b980b30fa980d4510ac0a9a9020e98eb Mon Sep 17 00:00:00 2001 From: ctan Date: Fri, 4 Sep 2026 22:55:51 +0200 Subject: [PATCH] feat(runner): wire spell registry in CLI and Monte Carlo --- src/pf1e_simulator/cli.py | 4 ++- src/pf1e_simulator/runner.py | 20 +++++++++-- src/pf1e_simulator/spells.py | 2 ++ tests/test_runner_spells.py | 66 ++++++++++++++++++++++++++++++++++++ 4 files changed, 89 insertions(+), 3 deletions(-) create mode 100644 tests/test_runner_spells.py diff --git a/src/pf1e_simulator/cli.py b/src/pf1e_simulator/cli.py index cf2dbb5..4b83e76 100644 --- a/src/pf1e_simulator/cli.py +++ b/src/pf1e_simulator/cli.py @@ -19,6 +19,7 @@ if TYPE_CHECKING: from pf1e_simulator.loaders import CombatantLoadError, SheetLoadError, load_combatant from pf1e_simulator.map import MapValidationError, load_map from pf1e_simulator.runner import EncounterSpec, Side, run_encounter +from pf1e_simulator.spells import SPELLS_DIR, load_spell_registry class CliError(Exception): @@ -111,7 +112,8 @@ def main(argv: list[str] | None = None) -> int: print(f"pf1e-sim: {exc}", file=sys.stderr) return 2 try: - report = run_encounter(spec, runs=args.runs, seed=args.seed) + registry = load_spell_registry(SPELLS_DIR) + report = run_encounter(spec, runs=args.runs, seed=args.seed, spell_registry=registry) except ValueError as exc: print(f"pf1e-sim: {exc}", file=sys.stderr) return 2 diff --git a/src/pf1e_simulator/runner.py b/src/pf1e_simulator/runner.py index dd66d50..df6b29a 100644 --- a/src/pf1e_simulator/runner.py +++ b/src/pf1e_simulator/runner.py @@ -17,12 +17,14 @@ if TYPE_CHECKING: from pf1e_simulator.grid import Grid from pf1e_simulator.map import MapSpec from pf1e_simulator.models import Combatant + from pf1e_simulator.spells import SpellSpec from pf1e_simulator.combat import CombatantState, CombatantStats, CombatEngine from pf1e_simulator.grid import Grid from pf1e_simulator.map import zone_cells from pf1e_simulator.metrics import win_rate, win_rate_band, win_rate_sigma from pf1e_simulator.rng import SeededRng +from pf1e_simulator.spells import SPELLS_DIR, load_spell_registry @dataclass(frozen=True) @@ -123,13 +125,20 @@ def run_encounter( seed: int, *, policy: Policy | None = None, + spell_registry: dict[str, SpellSpec] | None = None, ) -> EncounterReport: - """Run `runs` battles (one SeededRng per run) and aggregate balance metrics.""" + """Run `runs` battles (one SeededRng per run) and aggregate balance metrics. + + The spell registry is loaded once (from ``data/spells/`` unless overridden) + and passed into every ``CombatEngine`` so caster PCs actually cast. + """ if runs <= 0: msg = "runs must be positive" raise ValueError(msg) grid = Grid.from_spec(spec.map) states = build_states(spec) + if spell_registry is None: + spell_registry = load_spell_registry(SPELLS_DIR) ids = [s.combatant.id for s in states] wins = {side.name: 0 for side in spec.sides} draws = 0 @@ -139,7 +148,14 @@ def run_encounter( if index > 0: states = build_states(spec) rng = SeededRng(seed + index) - result = CombatEngine(rng, grid, states, round_cap=spec.round_cap, policy=policy).run() + result = CombatEngine( + rng, + grid, + states, + round_cap=spec.round_cap, + policy=policy, + spell_registry=spell_registry, + ).run() if result.winner is None: draws += 1 else: diff --git a/src/pf1e_simulator/spells.py b/src/pf1e_simulator/spells.py index 8511b60..7b5e4c4 100644 --- a/src/pf1e_simulator/spells.py +++ b/src/pf1e_simulator/spells.py @@ -25,6 +25,8 @@ from pf1e_simulator.effects import BonusType, StatModifier, StatTarget if TYPE_CHECKING: from pf1e_simulator.loaders.foundry import Json +SPELLS_DIR = Path(__file__).resolve().parent.parent.parent / "data" / "spells" + SpellRange = Literal["personal", "touch", "close", "medium", "long"] SaveType = Literal["fort", "ref", "will", "none"] diff --git a/tests/test_runner_spells.py b/tests/test_runner_spells.py new file mode 100644 index 0000000..069800a --- /dev/null +++ b/tests/test_runner_spells.py @@ -0,0 +1,66 @@ +"""Wiring tests: the Monte Carlo runner loads the real spell registry and +spellcasting PCs cast against ``data/spells/``. + +The engine's ``spell_registry`` defaults to ``{}`` for retro-compat with pure +unit tests; the runner must therefore load the registry from ``SPELLS_DIR`` and +pass it into every ``CombatEngine`` so a caster PC actually produces +``cast_spell`` actions in Monte Carlo. +""" + +from __future__ import annotations + +from pathlib import Path + +from pf1e_simulator.combat import CombatEngine +from pf1e_simulator.grid import Grid +from pf1e_simulator.loaders import load_combatant +from pf1e_simulator.map import load_map +from pf1e_simulator.rng import SeededRng +from pf1e_simulator.runner import EncounterSpec, Side, build_states +from pf1e_simulator.spells import SPELLS_DIR, load_spell_registry + +ROOT = Path(__file__).resolve().parents[1] +MAP = ROOT / "data" / "maps" / "sample_arena.yaml" +ONI = ROOT / "data" / "pcs" / "oni.json" +GOBLIN = ROOT / "data" / "monsters" / "goblin.json" + +_NUM_SPELLS = 11 + + +def test_runner_loads_realtime_spell_registry() -> None: + """The runner's registry source holds all 11 real spells from data/spells/.""" + registry = load_spell_registry(SPELLS_DIR) + assert len(registry) == _NUM_SPELLS + + +def test_monte_carlo_caster_pc_casts_spell() -> None: + """oni (cleric) casts against data/spells/ once it drops below 50% HP. + + oni's known spells overlap the registry's heal spells (Cure Light/Moderate + Wounds); with enough damage taken it heals via cast_spell. The Monte Carlo + loop mirrors run_encounter: build fresh states and one CombatEngine per run + with the registry from SPELLS_DIR. + """ + spec = EncounterSpec( + map=load_map(MAP), + round_cap=100, + sides=( + Side(name="players", combatants=(load_combatant(ONI),), zone="A"), + Side(name="monsters", combatants=(load_combatant(GOBLIN),) * 4, zone="B"), + ), + ) + registry = load_spell_registry(SPELLS_DIR) + grid = Grid.from_spec(spec.map) + casts = 0 + for i in range(100): + states = build_states(spec) + rng = SeededRng(7 + i) + result = CombatEngine( + rng, grid, states, round_cap=spec.round_cap, spell_registry=registry + ).run() + casts += sum( + 1 + for line in result.transcript + if " vs " in line and "cast " in line + ) + assert casts > 0