Files
pf1e-simulator/tests/test_phase2_offline_battle.py
T
2026-09-22 12:21:23 +02:00

88 lines
3.0 KiB
Python

"""Offline battles driven by FakeLLMClient through the NL policy (P1.3/P1.4)."""
from __future__ import annotations
from pathlib import Path
from pf1e_simulator.client import FakeLLMClient
from pf1e_simulator.loaders import load_combatant
from pf1e_simulator.map import load_map
from pf1e_simulator.nl_policy import NLPolicy
from pf1e_simulator.prompt import PromptPack
from pf1e_simulator.prompt_builder import PromptBuilder
from pf1e_simulator.runner import EncounterSpec, Side, run_encounter
ROOT = Path(__file__).resolve().parents[1]
MAP = ROOT / "data" / "maps" / "sample_arena.yaml"
def _c(path: str):
return load_combatant(ROOT / "data" / path)
def _duel_spec() -> EncounterSpec:
"""A fast 1v1 (goblin archer vs orc) on the sample arena."""
return EncounterSpec(
map=load_map(MAP),
round_cap=20,
sides=(
Side(name="players", combatants=(_c("monsters/goblin.json"),), zone="A"),
Side(name="monsters", combatants=(_c("monsters/orc.json"),), zone="B"),
),
)
def _pack() -> PromptPack:
return PromptPack.load(ROOT / "tests" / "fixtures" / "prompt_pack")
def _builder() -> PromptBuilder:
return PromptBuilder(pack=_pack())
def _make_client(script: list[str], *, builder: PromptBuilder) -> FakeLLMClient:
return FakeLLMClient(
script=script,
prompt_version=builder.prompt_version("nl_default"),
)
def test_offline_battle_total_defense_draw() -> None:
"""Both sides play total_defense → draw at round_cap (20 rounds, 40 calls)."""
spec = _duel_spec()
builder = _builder()
# 60 responses is plenty for 40 turns (20 rounds x 2 combatants)
script = ['[{"kind": "total_defense"}]'] * 60
client = _make_client(script, builder=builder)
policy = NLPolicy(client=client, builder=builder)
report, _outcomes = run_encounter(spec, runs=1, seed=1, llm_policy=policy, collect_runs=True)
# Draw at round_cap
assert report.draws == 1
assert report.wins.get("players", 0) == 0
assert report.wins.get("monsters", 0) == 0
assert report.avg_rounds == 20
# 20 rounds x 2 combatants = 40 policy invocations
assert client.stats.calls == 40
def test_offline_battle_fallback() -> None:
"""Fallback-driven battle: script of invalid JSON forces fallback on every turn."""
spec = _duel_spec()
builder = _builder()
# Each turn: 2 completes (first parse fail, retry parse fail → fallback).
# Max 20 rounds x 2 combatants x 2 = 80 completes. Use 100 for margin.
script = ["not json"] * 100
client = _make_client(script, builder=builder)
policy = NLPolicy(client=client, builder=builder)
report, _outcomes = run_encounter(spec, runs=1, seed=1, llm_policy=policy, collect_runs=True)
# The battle should complete (not exhaust script)
assert report.runs == 1
assert report.draws + sum(report.wins.values()) == 1
# At least some calls happened; script not exhausted
assert client.stats.calls > 0
assert client.stats.calls <= 100