88 lines
3.0 KiB
Python
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
|