204 lines
7.9 KiB
Python
204 lines
7.9 KiB
Python
"""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
|
|
" <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)
|