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