"""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