"""Scenario search space and deterministic sampling (docs/phase-2-llm-architecture.md sections 3.1, 6, 10; P3.1). ``ScenarioSpec`` is the reusable scenario search space of section 6: a map file, the sides that fill its deployment zones, and a battle round cap. It is compiled deterministically into a runner ``EncounterSpec`` by ``build_encounter``, which loads the map and every combatant file and applies each side's initial-HP fraction. ``scenario_variants`` implements the coarse sampling of section 3.1 ("echantillonnage latin"): deterministic per-side HP fraction variants of a base scenario, with no RNG. """ from __future__ import annotations from dataclasses import dataclass from typing import TYPE_CHECKING if TYPE_CHECKING: from collections.abc import Sequence from pathlib import Path from pf1e_simulator.models import Combatant from pf1e_simulator.loaders import load_combatant from pf1e_simulator.map import load_map from pf1e_simulator.runner import EncounterSpec, Side @dataclass(frozen=True) class SideSpec: """One side of a scenario: a deployment zone letter and the combatant files that fill it, with optional initial-hp scaling.""" name: str # side name, e.g. "players" zone: str # deployment zone letter, e.g. "A" combatant_files: tuple[Path, ...] # monster/PJ JSON files, loaded via load_combatant hp_fraction: float = 1.0 # initial HP as a fraction of hp_max, in (0, 1] aware: bool | None = None # SRD "Surprise" override; None keeps Combatant.aware (True) @dataclass(frozen=True) class ScenarioSpec: """A reusable scenario: map file, sides, and a battle round cap (docs section 6 -- the ScenarioSpec search space). Deterministically compiled to an EncounterSpec by build_encounter.""" name: str map_file: Path sides: tuple[SideSpec, ...] round_cap: int = 100 def __post_init__(self) -> None: if not self.name: msg = f"scenario name must be non-empty, got {self.name!r}" raise ValueError(msg) if not self.sides: msg = "scenario sides must be non-empty" raise ValueError(msg) for side in self.sides: if not side.zone: msg = ( f"side {side.name!r}: zone must be a single non-empty " f"string, got {side.zone!r}" ) raise ValueError(msg) if not 0.0 < side.hp_fraction <= 1.0: msg = ( f"side {side.name!r}: hp_fraction must be in (0, 1], " f"got {side.hp_fraction}" ) raise ValueError(msg) if self.round_cap < 1: msg = f"round_cap must be >= 1, got {self.round_cap}" raise ValueError(msg) def _apply_hp_fraction(combatant: Combatant, fraction: float) -> Combatant: """Scale initial HP via model_copy, the same pattern as runner.build_states.""" if fraction == 1.0: return combatant return combatant.model_copy( update={"hp_max": max(1, round(combatant.hp_max * fraction))} ) def _apply_aware(combatant: Combatant, *, aware: bool | None) -> Combatant: """Apply a side's SRD "Surprise" awareness override via model_copy. None keeps the loaded combatant unchanged (Combatant.aware defaults to True, models.py:416-420). SRD "Surprise": only some combatants aware means a surprise round precedes round 1; unaware combatants do not act in it. """ if aware is None: return combatant return combatant.model_copy(update={"aware": aware}) def build_encounter(scenario: ScenarioSpec) -> EncounterSpec: """Compile a ScenarioSpec into a runner EncounterSpec (deterministic). - load_map(scenario.map_file); for each SideSpec, load each combatant file with load_combatant, then apply hp_fraction to hp_max via pydantic model_copy(update={"hp_max": max(1, round(hp_max * fraction))}) (same model_copy pattern as runner.build_states). - When a side's aware is not None, it is applied to that side's combatants via model_copy(update={"aware": ...}) at the same point where hp_fraction is applied (SRD "Surprise" round semantics: only some aware -> surprise round; Combatant.aware defaults to True, so aware=None keeps the loaded default and is bit-identical to the behaviour without the field). - Duplicate combatant ids are NOT disambiguated here: build_states (called later by run_encounter) handles that. Just list each loaded combatant once per occurrence in combatant_files. - Returns EncounterSpec(map=..., sides=tuple(Side[...]), round_cap=...). """ map_spec = load_map(scenario.map_file) if map_spec.zones is not None and not map_spec.deployment: msg = ( "map has a zones layer but no deployment section (each zone " "letter needs a side)" ) raise ValueError(msg) for side in scenario.sides: if not side.combatant_files: msg = f"side {side.name!r}: at least one combatant file is required" raise ValueError(msg) letters = [side.zone for side in scenario.sides] shared = sorted({letter for letter in letters if letters.count(letter) > 1}) if shared: msg = ( "sides share zone letters (each side needs its own distinct " f"zone, or they deploy on top of each other): {shared}" ) raise ValueError(msg) if map_spec.deployment: declared = set(map_spec.deployment.values()) for side in scenario.sides: if side.zone not in declared: msg = ( f"side {side.name!r} zone {side.zone!r} not declared in " "map deployment" ) raise ValueError(msg) sides = tuple( Side( name=side.name, combatants=tuple( _apply_aware( _apply_hp_fraction(load_combatant(path), side.hp_fraction), aware=side.aware, ) for path in side.combatant_files ), zone=side.zone, ) for side in scenario.sides ) return EncounterSpec(map=map_spec, sides=sides, round_cap=scenario.round_cap) def scenario_variants( base: ScenarioSpec, *, hp_fractions: Sequence[float] = (1.0,), ) -> tuple[ScenarioSpec, ...]: """Deterministic variants of `base` over per-side HP fractions. Returns one ScenarioSpec per hp_fraction in `hp_fractions`, each with every side's hp_fraction set to that value (scenario names suffixed " hp=", e.g. "goblins-vs-orcs hp=0.5"; the identity fraction 1.0 keeps the base name, so the default call returns base itself). Same deps as __post_init__ validation (each fraction in (0, 1]); empty tuple of fractions -> ValueError. Deterministic given the same inputs: no RNG. """ if not hp_fractions: msg = "hp_fractions must be non-empty" raise ValueError(msg) for fraction in hp_fractions: if not 0.0 < fraction <= 1.0: msg = f"hp_fraction must be in (0, 1], got {fraction}" raise ValueError(msg) variants: list[ScenarioSpec] = [] for fraction in hp_fractions: name = base.name if fraction == 1.0 else f"{base.name} hp={fraction}" variants.append( ScenarioSpec( name=name, map_file=base.map_file, sides=tuple( SideSpec( name=side.name, zone=side.zone, combatant_files=side.combatant_files, hp_fraction=fraction, ) for side in base.sides ), round_cap=base.round_cap, ) ) return tuple(variants)