feat(phase2): optimizer and scenario compiler (P3)
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
"""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]
|
||||
|
||||
|
||||
@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 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).
|
||||
- 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_hp_fraction(load_combatant(path), side.hp_fraction)
|
||||
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
|
||||
" <name> hp=<fraction>", 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)
|
||||
Reference in New Issue
Block a user