"""Phase-2 adversary compilation tests (docs/phase-2-llm-architecture.md 10 P4.1). Deterministic, offline: no network, no LLM. Pins the ``AdversarySpec`` / ``compile_adversary`` / ``compile_matchup`` contract (default-policy identity, per-side routing on a real ``CombatEngine``, empty ``party_side`` rejection), the additive ``"healer"`` target priority (prefer the enemy caster, fall back to ``weakest_ac``), and ``SideSpec.aware`` landing on the right side's combatants plus its SRD "Surprise" round effect. Conventions follow tests/test_phase2_search.py: small n (runs <= 5), fixed seed, ROOT-relative path constants. """ from __future__ import annotations from pathlib import Path from typing import TYPE_CHECKING import pytest from pf1e_simulator.adversary import ( AdversarySpec, adversary_name, compile_adversary, compile_matchup, ) from pf1e_simulator.combat import CombatantState, CombatEngine, default_policy from pf1e_simulator.grid import Grid from pf1e_simulator.loaders import load_combatant from pf1e_simulator.map import load_map from pf1e_simulator.rng import SeededRng from pf1e_simulator.runner import build_states, run_encounter from pf1e_simulator.scenarios import ScenarioSpec, SideSpec, build_encounter from pf1e_simulator.strategy import StrategySpec, compile_strategy, select_target if TYPE_CHECKING: from pf1e_simulator.models import Combatant from pf1e_simulator.runner import EncounterSpec 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" NAIRDA = ROOT / "data" / "pcs" / "nairda.json" RUNS = 5 SEED = 7 ROUND_CAP = 20 PARTY = "players" _MAP_SPEC = load_map(MAP) def _c(path: Path) -> Combatant: return load_combatant(path) def _scenario(*, monsters_aware: bool | None = None) -> ScenarioSpec: """3 goblins (zone A) vs 2 orcs (zone B), optionally with side awareness.""" return ScenarioSpec( name="goblins-vs-orcs", map_file=MAP, sides=( SideSpec( name=PARTY, zone="A", combatant_files=(GOBLIN, GOBLIN, GOBLIN), ), SideSpec( name="monsters", zone="B", combatant_files=(ORC, ORC), aware=monsters_aware, ), ), round_cap=ROUND_CAP, ) def _encounter() -> EncounterSpec: return build_encounter(_scenario()) def _state( combatant: Combatant, side: str, pos: tuple[int, int], *, hp: int | None = None ) -> CombatantState: """A bare runtime state over a loaded combatant (no battle run).""" return CombatantState( combatant=combatant, side=side, pos=pos, hp=combatant.hp_max if hp is None else hp, resources_left={}, ) def _mini_engine(states: list[CombatantState]) -> CombatEngine: """A live engine over the sample arena with the given states (no battle run).""" return CombatEngine(SeededRng(SEED), Grid.from_spec(_MAP_SPEC), states) # --- identity --------------------------------------------------------------- def test_compile_adversary_default_is_default_policy() -> None: """The all-default AdversarySpec compiles to default_policy by identity.""" assert compile_adversary(AdversarySpec()) is default_policy def test_compile_matchup_defaults_is_default_policy() -> None: """Two all-default specs return default_policy itself, not a wrapper.""" assert compile_matchup(StrategySpec(), AdversarySpec()) is default_policy def test_adversary_name_mirrors_strategy_name() -> None: """Labels render non-default levers in field order; the name field is excluded.""" assert adversary_name(AdversarySpec()) == "default" assert adversary_name(AdversarySpec(target_priority="healer")) == "healer" assert ( adversary_name(AdversarySpec(name="custom", spell_policy="none")) == "spell_policy=none" ) assert ( adversary_name( AdversarySpec(target_priority="weakest_ac", retreat_hp_fraction=0.5) ) == "weakest_ac, retreat=0.5" ) # --- validation ------------------------------------------------------------ def test_compile_matchup_empty_party_side_raises() -> None: """An empty party_side is a ValueError (pre-assigned msg, EM101/EM102).""" with pytest.raises(ValueError, match="party_side"): compile_matchup(StrategySpec(), AdversarySpec(), party_side="") # --- routing ---------------------------------------------------------------- def test_compile_matchup_routes_by_side() -> None: """On a real engine, each side of the matchup delegates to its own policy.""" party_spec = StrategySpec(name="party-focus", target_priority="weakest_ac") adversary_spec = AdversarySpec(name="adv-focus", target_priority="lowest_hp") matchup = compile_matchup(party_spec, adversary_spec) assert matchup is not default_policy encounter = _encounter() states = build_states(encounter) engine = CombatEngine(SeededRng(SEED), Grid.from_spec(encounter.map), states) party_state = next(s for s in states if s.side == PARTY) monster_state = next(s for s in states if s.side == "monsters") assert matchup(engine, party_state) == compile_strategy(party_spec)( engine, party_state ) assert matchup(engine, monster_state) == compile_adversary(adversary_spec)( engine, monster_state ) def test_matchup_and_healer_strategy_run_deterministically() -> None: """A healer spec compiles and runs evaluate-style cells bit-identically.""" healer = StrategySpec(name="healer-party", target_priority="healer") policy = compile_strategy(healer) assert policy is not default_policy matchup = compile_matchup(healer, AdversarySpec(target_priority="weakest_ac")) spec = _encounter() _, first = run_encounter(spec, runs=RUNS, seed=SEED, policy=policy, collect_runs=True) _, again = run_encounter(spec, runs=RUNS, seed=SEED, policy=policy, collect_runs=True) assert first == again _, matchup_a = run_encounter( spec, runs=RUNS, seed=SEED, policy=matchup, collect_runs=True ) _, matchup_b = run_encounter( spec, runs=RUNS, seed=SEED, policy=matchup, collect_runs=True ) assert matchup_a == matchup_b # --- healer targeting ------------------------------------------------------- def test_healer_prefers_the_enemy_caster() -> None: """With a monster-side caster (nairda) among non-casters, healer picks her.""" actor = _state(_c(GOBLIN), PARTY, (1, 1)) nairda = _state(_c(NAIRDA), "monsters", (1, 8)) orc = _state(_c(ORC), "monsters", (1, 16)) assert nairda.combatant.spells # caster precondition (nairda.json) engine = _mini_engine([actor, nairda, orc]) assert select_target(engine, actor, "healer") is nairda # Discriminator: weakest_ac sees equal AC 13 and prefers the wounded orc. assert select_target(engine, actor, "weakest_ac") is orc def test_healer_falls_back_to_weakest_ac_without_casters() -> None: """No active caster on the enemy side -> identical to weakest_ac.""" actor = _state(_c(GOBLIN), PARTY, (1, 1)) orc = _state(_c(ORC), "monsters", (1, 8)) goblin_enemy = _state(_c(GOBLIN), "monsters", (1, 16)) assert not orc.combatant.spells assert not goblin_enemy.combatant.spells engine = _mini_engine([actor, orc, goblin_enemy]) healer = select_target(engine, actor, "healer") assert healer is select_target(engine, actor, "weakest_ac") assert healer is orc # AC 13 < goblin AC 16 # --- SideSpec.aware (SRD "Surprise") --------------------------------------- def test_side_spec_aware_applies_per_side() -> None: """aware=False lands on that side's combatants only; the other side keeps True.""" encounter = build_encounter(_scenario(monsters_aware=False)) players, monsters = encounter.sides assert all(c.aware is True for c in players.combatants) assert all(c.aware is False for c in monsters.combatants) states = build_states(encounter) assert all(s.combatant.aware is True for s in states if s.side == PARTY) assert all(s.combatant.aware is False for s in states if s.side == "monsters") untouched = build_encounter(_scenario()) assert all(c.aware is True for side in untouched.sides for c in side.combatants) def test_side_spec_aware_triggers_surprise_round() -> None: """Only some aware -> a surprise round runs; the default (aware=None) has none.""" surprise = _encounter_states_and_run(_scenario(monsters_aware=False)) assert "surprise round" in surprise default = _encounter_states_and_run(_scenario()) assert "surprise round" not in default def _encounter_states_and_run(scenario: ScenarioSpec) -> tuple[str, ...]: """Run one seeded battle of `scenario` and return its transcript.""" encounter = build_encounter(scenario) engine = CombatEngine( SeededRng(SEED), Grid.from_spec(encounter.map), build_states(encounter), round_cap=ROUND_CAP, ) return engine.run().transcript