feat(runner): wire spell registry in CLI and Monte Carlo
This commit is contained in:
@@ -19,6 +19,7 @@ if TYPE_CHECKING:
|
|||||||
from pf1e_simulator.loaders import CombatantLoadError, SheetLoadError, load_combatant
|
from pf1e_simulator.loaders import CombatantLoadError, SheetLoadError, load_combatant
|
||||||
from pf1e_simulator.map import MapValidationError, load_map
|
from pf1e_simulator.map import MapValidationError, load_map
|
||||||
from pf1e_simulator.runner import EncounterSpec, Side, run_encounter
|
from pf1e_simulator.runner import EncounterSpec, Side, run_encounter
|
||||||
|
from pf1e_simulator.spells import SPELLS_DIR, load_spell_registry
|
||||||
|
|
||||||
|
|
||||||
class CliError(Exception):
|
class CliError(Exception):
|
||||||
@@ -111,7 +112,8 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
print(f"pf1e-sim: {exc}", file=sys.stderr)
|
print(f"pf1e-sim: {exc}", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
try:
|
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:
|
except ValueError as exc:
|
||||||
print(f"pf1e-sim: {exc}", file=sys.stderr)
|
print(f"pf1e-sim: {exc}", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
|
|||||||
@@ -17,12 +17,14 @@ if TYPE_CHECKING:
|
|||||||
from pf1e_simulator.grid import Grid
|
from pf1e_simulator.grid import Grid
|
||||||
from pf1e_simulator.map import MapSpec
|
from pf1e_simulator.map import MapSpec
|
||||||
from pf1e_simulator.models import Combatant
|
from pf1e_simulator.models import Combatant
|
||||||
|
from pf1e_simulator.spells import SpellSpec
|
||||||
|
|
||||||
from pf1e_simulator.combat import CombatantState, CombatantStats, CombatEngine
|
from pf1e_simulator.combat import CombatantState, CombatantStats, CombatEngine
|
||||||
from pf1e_simulator.grid import Grid
|
from pf1e_simulator.grid import Grid
|
||||||
from pf1e_simulator.map import zone_cells
|
from pf1e_simulator.map import zone_cells
|
||||||
from pf1e_simulator.metrics import win_rate, win_rate_band, win_rate_sigma
|
from pf1e_simulator.metrics import win_rate, win_rate_band, win_rate_sigma
|
||||||
from pf1e_simulator.rng import SeededRng
|
from pf1e_simulator.rng import SeededRng
|
||||||
|
from pf1e_simulator.spells import SPELLS_DIR, load_spell_registry
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -123,13 +125,20 @@ def run_encounter(
|
|||||||
seed: int,
|
seed: int,
|
||||||
*,
|
*,
|
||||||
policy: Policy | None = None,
|
policy: Policy | None = None,
|
||||||
|
spell_registry: dict[str, SpellSpec] | None = None,
|
||||||
) -> EncounterReport:
|
) -> 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:
|
if runs <= 0:
|
||||||
msg = "runs must be positive"
|
msg = "runs must be positive"
|
||||||
raise ValueError(msg)
|
raise ValueError(msg)
|
||||||
grid = Grid.from_spec(spec.map)
|
grid = Grid.from_spec(spec.map)
|
||||||
states = build_states(spec)
|
states = build_states(spec)
|
||||||
|
if spell_registry is None:
|
||||||
|
spell_registry = load_spell_registry(SPELLS_DIR)
|
||||||
ids = [s.combatant.id for s in states]
|
ids = [s.combatant.id for s in states]
|
||||||
wins = {side.name: 0 for side in spec.sides}
|
wins = {side.name: 0 for side in spec.sides}
|
||||||
draws = 0
|
draws = 0
|
||||||
@@ -139,7 +148,14 @@ def run_encounter(
|
|||||||
if index > 0:
|
if index > 0:
|
||||||
states = build_states(spec)
|
states = build_states(spec)
|
||||||
rng = SeededRng(seed + index)
|
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:
|
if result.winner is None:
|
||||||
draws += 1
|
draws += 1
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ from pf1e_simulator.effects import BonusType, StatModifier, StatTarget
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pf1e_simulator.loaders.foundry import Json
|
from pf1e_simulator.loaders.foundry import Json
|
||||||
|
|
||||||
|
SPELLS_DIR = Path(__file__).resolve().parent.parent.parent / "data" / "spells"
|
||||||
|
|
||||||
SpellRange = Literal["personal", "touch", "close", "medium", "long"]
|
SpellRange = Literal["personal", "touch", "close", "medium", "long"]
|
||||||
SaveType = Literal["fort", "ref", "will", "none"]
|
SaveType = Literal["fort", "ref", "will", "none"]
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user