207 lines
7.0 KiB
Python
207 lines
7.0 KiB
Python
"""Phase-2 scenario spec tests (docs/phase-2-llm-architecture.md 3.1, 6, 10 P3.1).
|
|
|
|
``ScenarioSpec``/``SideSpec`` are the reusable scenario search space of
|
|
section 6; ``build_encounter`` compiles one deterministically into a runner
|
|
``EncounterSpec`` (map + sides + round cap), and ``scenario_variants``
|
|
implements the deterministic per-side HP-fraction sampling of section 3.1.
|
|
These tests pin the round-trip (side order, ids, hp scaling, round cap), the
|
|
validation errors of both layers, the variant naming/scaling, determinism,
|
|
and one cheap integration run proving the compiled encounter is playable.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from pf1e_simulator.runner import run_encounter
|
|
from pf1e_simulator.scenarios import (
|
|
ScenarioSpec,
|
|
SideSpec,
|
|
build_encounter,
|
|
scenario_variants,
|
|
)
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
MAP = ROOT / "data" / "maps" / "sample_arena.yaml"
|
|
GOBLIN = ROOT / "data" / "monsters" / "goblin.json"
|
|
ORC = ROOT / "data" / "monsters" / "orc.json"
|
|
|
|
|
|
def _scenario(goblins: int = 3, orcs: int = 2) -> ScenarioSpec:
|
|
return ScenarioSpec(
|
|
name="goblins-vs-orcs",
|
|
map_file=MAP,
|
|
sides=(
|
|
SideSpec(
|
|
name="players",
|
|
zone="A",
|
|
combatant_files=tuple(GOBLIN for _ in range(goblins)),
|
|
),
|
|
SideSpec(
|
|
name="monsters",
|
|
zone="B",
|
|
combatant_files=tuple(ORC for _ in range(orcs)),
|
|
),
|
|
),
|
|
)
|
|
|
|
|
|
def test_build_encounter_round_trip() -> None:
|
|
"""Side order/names/zones, combatant counts and ids, round cap pass through."""
|
|
spec = build_encounter(_scenario(goblins=3, orcs=2))
|
|
|
|
assert [side.name for side in spec.sides] == ["players", "monsters"]
|
|
assert [side.zone for side in spec.sides] == ["A", "B"]
|
|
assert [len(side.combatants) for side in spec.sides] == [3, 2]
|
|
assert [c.id for c in spec.sides[0].combatants] == ["goblin", "goblin", "goblin"]
|
|
assert [c.id for c in spec.sides[1].combatants] == ["orc", "orc"]
|
|
assert spec.round_cap == 100
|
|
assert spec.map.deployment == {"players": "A", "monsters": "B"}
|
|
|
|
|
|
def test_build_encounter_hp_fraction() -> None:
|
|
"""hp_fraction 0.5 halves hp_max (rounded); 1.0 leaves it identical."""
|
|
half = build_encounter(
|
|
ScenarioSpec(
|
|
name="half-hp",
|
|
map_file=MAP,
|
|
sides=(
|
|
SideSpec(
|
|
name="players",
|
|
zone="A",
|
|
combatant_files=(GOBLIN,),
|
|
hp_fraction=0.5,
|
|
),
|
|
SideSpec(
|
|
name="monsters",
|
|
zone="B",
|
|
combatant_files=(ORC,),
|
|
hp_fraction=0.5,
|
|
),
|
|
),
|
|
)
|
|
)
|
|
assert half.sides[0].combatants[0].hp_max == 3
|
|
assert half.sides[1].combatants[0].hp_max == 3
|
|
|
|
full = build_encounter(_scenario(goblins=1, orcs=1))
|
|
assert full.sides[0].combatants[0].hp_max == 6
|
|
assert full.sides[1].combatants[0].hp_max == 6
|
|
|
|
|
|
def test_scenario_spec_validation() -> None:
|
|
"""Empty name, empty sides, bad hp_fraction, and round_cap 0 all raise."""
|
|
with pytest.raises(ValueError, match="name"):
|
|
ScenarioSpec(name="", map_file=MAP, sides=(_scenario().sides[0],))
|
|
with pytest.raises(ValueError, match="sides"):
|
|
ScenarioSpec(name="x", map_file=MAP, sides=())
|
|
for fraction in (0.0, 1.1, -0.5):
|
|
with pytest.raises(ValueError, match="hp_fraction"):
|
|
ScenarioSpec(
|
|
name="x",
|
|
map_file=MAP,
|
|
sides=(
|
|
SideSpec(
|
|
name="players",
|
|
zone="A",
|
|
combatant_files=(GOBLIN,),
|
|
hp_fraction=fraction,
|
|
),
|
|
),
|
|
)
|
|
with pytest.raises(ValueError, match="round_cap"):
|
|
ScenarioSpec(
|
|
name="x",
|
|
map_file=MAP,
|
|
sides=(_scenario().sides[0],),
|
|
round_cap=0,
|
|
)
|
|
|
|
|
|
def test_build_encounter_validation() -> None:
|
|
"""Shared zone letters, undeclared zones, and empty combatant lists raise."""
|
|
with pytest.raises(ValueError, match="share zone letters"):
|
|
build_encounter(
|
|
ScenarioSpec(
|
|
name="x",
|
|
map_file=MAP,
|
|
sides=(
|
|
SideSpec(name="a", zone="A", combatant_files=(GOBLIN,)),
|
|
SideSpec(name="b", zone="A", combatant_files=(ORC,)),
|
|
),
|
|
)
|
|
)
|
|
with pytest.raises(ValueError, match="not declared in map deployment"):
|
|
build_encounter(
|
|
ScenarioSpec(
|
|
name="x",
|
|
map_file=MAP,
|
|
sides=(
|
|
SideSpec(name="a", zone="A", combatant_files=(GOBLIN,)),
|
|
SideSpec(name="b", zone="C", combatant_files=(ORC,)),
|
|
),
|
|
)
|
|
)
|
|
with pytest.raises(ValueError, match="combatant file"):
|
|
build_encounter(
|
|
ScenarioSpec(
|
|
name="x",
|
|
map_file=MAP,
|
|
sides=(
|
|
SideSpec(name="a", zone="A", combatant_files=()),
|
|
SideSpec(name="b", zone="B", combatant_files=(ORC,)),
|
|
),
|
|
)
|
|
)
|
|
|
|
|
|
def test_variants_default_returns_base() -> None:
|
|
"""The default hp_fractions=(1.0,) yields exactly one variant equal to base."""
|
|
base = _scenario()
|
|
assert scenario_variants(base) == (base,)
|
|
|
|
|
|
def test_variants_fractions_names_and_scaling() -> None:
|
|
"""(0.5, 1.0) yields two variants with suffixed names and scaled HP."""
|
|
base = _scenario()
|
|
variants = scenario_variants(base, hp_fractions=(0.5, 1.0))
|
|
|
|
assert len(variants) == 2
|
|
assert variants[0].name == "goblins-vs-orcs hp=0.5"
|
|
assert all(side.hp_fraction == 0.5 for side in variants[0].sides)
|
|
assert variants[1] == base
|
|
|
|
half = build_encounter(variants[0])
|
|
assert half.sides[0].combatants[0].hp_max == 3
|
|
assert half.sides[1].combatants[0].hp_max == 3
|
|
|
|
|
|
def test_variants_empty_fractions_raise() -> None:
|
|
"""An empty hp_fractions tuple is a ValueError."""
|
|
with pytest.raises(ValueError, match="hp_fractions"):
|
|
scenario_variants(_scenario(), hp_fractions=())
|
|
|
|
|
|
def test_variants_invalid_fraction_raises() -> None:
|
|
"""A fraction outside (0, 1] is a ValueError."""
|
|
with pytest.raises(ValueError, match="hp_fraction"):
|
|
scenario_variants(_scenario(), hp_fractions=(1.5,))
|
|
|
|
|
|
def test_variants_deterministic() -> None:
|
|
"""Two calls with the same inputs produce equal variant tuples."""
|
|
base = _scenario()
|
|
fractions = (0.25, 0.5, 0.75, 1.0)
|
|
assert scenario_variants(base, hp_fractions=fractions) == scenario_variants(
|
|
base, hp_fractions=fractions
|
|
)
|
|
|
|
|
|
def test_build_encounter_playable() -> None:
|
|
"""A compiled encounter runs end-to-end (20 seeded battles)."""
|
|
spec = build_encounter(_scenario(goblins=3, orcs=2))
|
|
report = run_encounter(spec, runs=20, seed=1)
|
|
assert report.runs == 20
|