Files
pf1e-simulator/src/pf1e_simulator/scenarios.py
T

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)