250 lines
9.0 KiB
Python
250 lines
9.0 KiB
Python
"""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
|