"""Monte Carlo runner: many deterministic battles aggregated into balance metrics. Each run builds fresh combatant states (duplicate ids get -N suffixes), places each side inside its deployment zone, and plays one battle with a dedicated SeededRng stream derived from the encounter seed. Results are aggregated into an EncounterReport: wins per side, draws, average rounds, and per-combatant attrition averages. All statistical helpers live in metrics.py. """ from __future__ import annotations from dataclasses import dataclass from typing import TYPE_CHECKING, Literal, overload if TYPE_CHECKING: from collections.abc import Callable from pf1e_simulator.combat import LegacyLLMPolicy, LLMPolicy, Policy 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 ( SIZE_CELLS, CombatantState, CombatantStats, CombatEngine, CombatResult, ) from pf1e_simulator.grid import Grid from pf1e_simulator.map import Pos, 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) class Side: """One faction in an encounter: combatants deployed inside one zone letter.""" name: str combatants: tuple[Combatant, ...] zone: str @dataclass(frozen=True) class EncounterSpec: """A full encounter definition: map, sides, and a battle round cap.""" map: MapSpec sides: tuple[Side, ...] round_cap: int = 100 @dataclass(frozen=True) class CombatantAttrition: """Average per-combatant outcomes over a Monte Carlo run.""" hits: float crits: float damage_dealt: float damage_taken: float healing_done: float @dataclass(frozen=True) class CombatantOutcome: """Final state of one combatant at the end of one battle (per-run record). ``dead`` is the engine's own death condition (``CombatantState.dead``: hp at or below the combatant's death threshold - CON for living PCs, 0 for undead/constructs and under the simple monster-death rule). A dying PC (hp < 0 but above -CON) is NOT dead here. ``hp`` may be negative; ``hp_max`` is carried so objectives can compute hit points lost. """ id: str side: str hp: int hp_max: int dead: bool @dataclass(frozen=True) class RunOutcome: """Minimal per-battle outcome for the danger/optimization objectives. Collected only when ``run_encounter`` is called with ``collect_runs=True``; the aggregate ``EncounterReport`` stays the default (cheap) output. ``combatants`` covers every creature that existed at battle end, including summons. """ winner: str | None rounds: int combatants: tuple[CombatantOutcome, ...] @dataclass(frozen=True) class EncounterReport: """Aggregated results of a Monte Carlo encounter. `transcript` holds the turn-by-turn log of the first battle when `run_encounter` is called with ``capture_first_transcript=True``; it is None otherwise (the default keeps the report cheap for pure Monte Carlo). """ runs: int seed: int wins: dict[str, int] draws: int avg_rounds: float attrition: dict[str, CombatantAttrition] transcript: tuple[str, ...] | None = None def win_rate(self, side: str) -> float: """Fraction of runs won by `side` (closed form, see metrics).""" return win_rate(self.wins.get(side, 0), self.runs) def win_rate_sigma(self, side: str) -> float: """Standard error of the side's win rate: sqrt(p(1-p)/n).""" return win_rate_sigma(self.win_rate(side), self.runs) def win_rate_band(self, side: str) -> tuple[float, float]: """3-sigma confidence band for the side's win rate.""" return win_rate_band(self.win_rate(side), self.runs) def _first_fit_anchor( cells: list[Pos], placed: set[Pos], n: int, grid: Grid ) -> Pos | None: """First zone cell whose n x n footprint is in bounds, passable, and unplaced.""" for anchor in cells: r, c = anchor footprint = {(r + dr, c + dc) for dr in range(n) for dc in range(n)} if any(not grid.in_bounds(p) or not grid.passable(p) for p in footprint): continue if footprint & placed: continue return anchor return None def build_states(spec: EncounterSpec) -> list[CombatantState]: """Fresh states for one battle; duplicate combatant ids get -N suffixes. The suffix keeps incrementing until the candidate id is not already taken, so an explicit id like ``goblin-2`` never collides with a disambiguated duplicate of ``goblin`` (the engine keys stats by id). Combatants of a side fill the zone's cells in (row, col) order. A side with more combatants than zone cells, or a zone with no cells, is a spec error. Creatures larger than 1x1 occupy an n x n footprint anchored at their cell. """ states: list[CombatantState] = [] taken: set[str] = set() grid = Grid.from_spec(spec.map) for side in spec.sides: cells = zone_cells(spec.map, side.zone) if not cells: msg = f"side {side.name!r}: zone {side.zone!r} has no cells" raise ValueError(msg) placed: set[Pos] = set() for combatant in side.combatants: n = SIZE_CELLS.get(combatant.size, 1) anchor = _first_fit_anchor(cells, placed, n, grid) if anchor is None: if n == 1: msg = ( f"side {side.name!r}: zone {side.zone!r} has {len(cells)} cells " f"for {len(side.combatants)} combatants" ) else: msg = ( f"side {side.name!r}: zone {side.zone!r} has no room for " f"{combatant.id!r} ({combatant.size}, {n}x{n})" ) raise ValueError(msg) cid = combatant.id suffix = 2 while cid in taken: cid = f"{combatant.id}-{suffix}" suffix += 1 taken.add(cid) effective = ( combatant if cid == combatant.id else combatant.model_copy(update={"id": cid}) ) placed |= { (anchor[0] + dr, anchor[1] + dc) for dr in range(n) for dc in range(n) } states.append( CombatantState( combatant=effective, side=side.name, pos=anchor, hp=effective.hp_max, resources_left=dict(effective.resources), ) ) return states def _accumulate_stats(totals: dict[str, CombatantStats], result: CombatResult) -> None: """Add one battle's per-combatant stats into the running totals, in place. Accumulate whatever ids the engine reports: summoned creatures act in the battle and appear in result.stats, so seeding from the initial states only would KeyError on them. Ids seen for the first time get a fresh accumulator. Duplicate summons of the same creature are disambiguated by the engine the same way build_states does (celestial_wolf, celestial_wolf-2, ...), so each summoned creature owns its own slot and their attrition is split, not merged. """ for cid, stats in result.stats.items(): current = totals.get(cid) if current is None: totals[cid] = stats continue totals[cid] = CombatantStats( hits=current.hits + stats.hits, crits=current.crits + stats.crits, damage_dealt=current.damage_dealt + stats.damage_dealt, damage_taken=current.damage_taken + stats.damage_taken, healing_done=current.healing_done + stats.healing_done, ) def _run_outcome(engine: CombatEngine, result: CombatResult) -> RunOutcome: """One battle's per-run summary, read from the engine's final live states.""" return RunOutcome( winner=result.winner, rounds=result.rounds, combatants=tuple( CombatantOutcome( id=state.combatant.id, side=state.side, hp=state.hp, hp_max=state.combatant.hp_max, dead=state.dead, ) for state in engine.states ), ) def _check_policy_args( policy: Policy | None, llm_policy: LLMPolicy | LegacyLLMPolicy | None, policy_factory: Callable[[int], Policy | None] | None, ) -> None: """Reject conflicting policy entry points (P0.4). ``policy_factory`` owns the per-run policy slot, so it cannot be combined with ``policy`` or ``llm_policy``. ``policy`` + ``llm_policy`` is left to ``CombatEngine`` (single source of truth for the mutual-exclusion error). """ if policy_factory is not None and policy is not None: msg = "policy_factory and policy are mutually exclusive" raise ValueError(msg) if policy_factory is not None and llm_policy is not None: msg = "policy_factory and llm_policy are mutually exclusive" raise ValueError(msg) @overload def run_encounter( spec: EncounterSpec, runs: int, seed: int, *, policy: Policy | None = None, llm_policy: LLMPolicy | LegacyLLMPolicy | None = None, policy_factory: Callable[[int], Policy | None] | None = None, spell_registry: dict[str, SpellSpec] | None = None, capture_first_transcript: bool = False, monsters_die_at_zero: bool = False, collect_runs: Literal[False] = False, ) -> EncounterReport: ... @overload def run_encounter( spec: EncounterSpec, runs: int, seed: int, *, policy: Policy | None = None, llm_policy: LLMPolicy | LegacyLLMPolicy | None = None, policy_factory: Callable[[int], Policy | None] | None = None, spell_registry: dict[str, SpellSpec] | None = None, capture_first_transcript: bool = False, monsters_die_at_zero: bool = False, collect_runs: Literal[True], ) -> tuple[EncounterReport, tuple[RunOutcome, ...]]: ... def run_encounter( spec: EncounterSpec, runs: int, seed: int, *, policy: Policy | None = None, llm_policy: LLMPolicy | LegacyLLMPolicy | None = None, policy_factory: Callable[[int], Policy | None] | None = None, spell_registry: dict[str, SpellSpec] | None = None, capture_first_transcript: bool = False, monsters_die_at_zero: bool = False, collect_runs: bool = False, ) -> EncounterReport | tuple[EncounterReport, tuple[RunOutcome, ...]]: """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. When ``capture_first_transcript`` is True, the turn-by-turn log of the first battle (``seed + 0``) is stored in ``report.transcript``. ``monsters_die_at_zero`` enables the optional simple monster-death rule (CLI ``--simple-monster-death``): monsters die at hp <= 0; PCs keep the PF1e hp <= -CON rule. Default False keeps behavior bit-identical. ``collect_runs`` (Phase 2, danger cartography) additionally returns a tuple ``(report, run_outcomes)`` where ``run_outcomes`` holds one ``RunOutcome`` per battle (winner, rounds, final per-combatant id/side/hp/dead). Default False keeps the signature and behavior of every existing caller unchanged (the report alone). ``llm_policy`` (Phase 2, P0.4) forwards an observation-only (or legacy two-argument) policy straight to every ``CombatEngine``. It is mutually exclusive with ``policy``; the engine raises ``ValueError`` when both are given, so that conflict surfaces here. ``policy_factory`` (Phase 2, P0.4) builds the engine-style ``Policy`` per run: it is called once per battle with the run index (``0..runs-1``) and its result is passed as that run's ``policy`` (``None`` falls back to ``default_policy``). It is mutually exclusive with both ``policy`` and ``llm_policy``. Note the per-run callable is a ``Policy`` (it receives the live engine), not an ``LLMPolicy``; use ``llm_policy`` for the observation-only entry point. """ if runs <= 0: msg = "runs must be positive" raise ValueError(msg) _check_policy_args(policy, llm_policy, policy_factory) grid = Grid.from_spec(spec.map) states = build_states(spec) if spell_registry is None: spell_registry = load_spell_registry(SPELLS_DIR) wins = {side.name: 0 for side in spec.sides} draws = 0 total_rounds = 0 totals: dict[str, CombatantStats] = {} transcript: tuple[str, ...] | None = None outcomes: list[RunOutcome] = [] for index in range(runs): if index > 0: states = build_states(spec) rng = SeededRng(seed + index) run_policy = policy if policy_factory is None else policy_factory(index) engine = CombatEngine( rng, grid, states, round_cap=spec.round_cap, policy=run_policy, llm_policy=llm_policy, spell_registry=spell_registry, monsters_die_at_zero=monsters_die_at_zero, ) result = engine.run() if collect_runs: outcomes.append(_run_outcome(engine, result)) if capture_first_transcript and index == 0: transcript = result.transcript if result.winner is None: draws += 1 else: wins[result.winner] = wins.get(result.winner, 0) + 1 total_rounds += result.rounds _accumulate_stats(totals, result) attrition = { cid: CombatantAttrition( hits=stats.hits / runs, crits=stats.crits / runs, damage_dealt=stats.damage_dealt / runs, damage_taken=stats.damage_taken / runs, healing_done=stats.healing_done / runs, ) for cid, stats in totals.items() } report = EncounterReport( runs=runs, seed=seed, wins=wins, draws=draws, avg_rounds=total_rounds / runs, attrition=attrition, transcript=transcript, ) if collect_runs: return report, tuple(outcomes) return report