feat(phase2): P1c

This commit is contained in:
2026-09-22 12:21:23 +02:00
parent de4645838b
commit 15d24be638
6 changed files with 1044 additions and 2 deletions
+280
View File
@@ -0,0 +1,280 @@
"""Tests for the natural-language policy (P1.3)."""
from __future__ import annotations
from pathlib import Path
import pytest
from pf1e_simulator.client import FakeLLMClient, ResponseCache, ScriptExhaustedError
from pf1e_simulator.combat import Action, Observation
from pf1e_simulator.nl_policy import (
NLPolicy,
NLPolicyError,
fallback_default,
parse_actions,
)
from pf1e_simulator.prompt import PromptPack
from pf1e_simulator.prompt_builder import PromptBuilder
_FIXTURE_PACK = Path(__file__).resolve().parent / "fixtures" / "prompt_pack"
def _obs() -> Observation:
"""Minimal valid observation for deterministic tests."""
return Observation(
round=1,
acting_combatant=0,
combatants=(
{"id": "goblin", "side": "players", "active": True, "pos": (1, 1)},
),
pairs=(),
terrain=((None,),),
elevation=((0,),),
)
def _pack() -> PromptPack:
return PromptPack.load(_FIXTURE_PACK)
def _builder() -> PromptBuilder:
return PromptBuilder(pack=_pack())
def _make_client(script: list[str], *, builder: PromptBuilder, **kwargs) -> FakeLLMClient:
"""Create a FakeLLMClient with the correct prompt_version from the builder."""
return FakeLLMClient(
script=script,
prompt_version=builder.prompt_version("nl_default"),
**kwargs,
)
def _make_policy(client: FakeLLMClient, builder: PromptBuilder) -> NLPolicy:
return NLPolicy(client=client, builder=builder)
@pytest.fixture
def builder() -> PromptBuilder:
"""A builder over the fixture pack, shared by the NLPolicy integration tests."""
return _builder()
# ---- parse_actions tests -----------------------------------------------------
def test_parse_actions_ok() -> None:
reply = '[{"kind": "move", "destination": [2, 3]}]'
actions = parse_actions(reply)
assert actions == (Action(kind="move", destination=(2, 3)),)
def test_parse_actions_empty_array() -> None:
assert parse_actions("[]") == ()
def test_parse_actions_invalid_json() -> None:
with pytest.raises(NLPolicyError, match=r"reply is not valid JSON"):
parse_actions("not json")
def test_parse_actions_not_a_list() -> None:
with pytest.raises(NLPolicyError, match=r"reply must be a JSON array of action objects"):
parse_actions('{"kind": "attack"}')
def test_parse_actions_unknown_field() -> None:
with pytest.raises(NLPolicyError, match=r"unknown action field\(s\): bogus"):
parse_actions('[{"kind": "attack", "bogus": 1}]')
def test_parse_actions_missing_kind() -> None:
with pytest.raises(NLPolicyError, match=r"missing or unknown 'kind'"):
parse_actions('[{"target_id": "orc"}]')
def test_parse_actions_unknown_kind() -> None:
with pytest.raises(NLPolicyError, match=r"missing or unknown 'kind'"):
parse_actions('[{"kind": "dance"}]')
def test_parse_actions_channel_mode_invalid() -> None:
with pytest.raises(NLPolicyError, match=r"'channel_mode' must be one of: heal, harm"):
parse_actions('[{"kind": "channel_energy", "channel_mode": "loud"}]')
def test_parse_actions_destination_invalid_not_pair() -> None:
with pytest.raises(NLPolicyError, match=r"'destination' must be a \[row, col\] pair"):
parse_actions('[{"kind": "move", "destination": [2]}]')
def test_parse_actions_destination_invalid_strings() -> None:
with pytest.raises(NLPolicyError, match=r"'destination' must be a \[row, col\] pair"):
parse_actions('[{"kind": "move", "destination": ["2", "3"]}]')
def test_parse_actions_aid_mode_invalid() -> None:
with pytest.raises(NLPolicyError, match=r"'aid_mode' must be one of: attack, ac"):
parse_actions('[{"kind": "aid_another", "aid_mode": "damage"}]')
def test_parse_actions_trick_condition_invalid() -> None:
with pytest.raises(
NLPolicyError,
match=(
r"'trick_condition' must be one of: dazzled, blinded, "
r"deafened, entangled, shaken, sickened"
),
):
parse_actions('[{"kind": "cast_spell", "trick_condition": "nauseated"}]')
def test_parse_actions_ready_trigger_invalid() -> None:
with pytest.raises(
NLPolicyError,
match=(
r"'ready_trigger' must be one of: enemy_enters_reach, "
r"enemy_casts, enemy_attacks, enemy_hits_ally"
),
):
parse_actions('[{"kind": "ready", "ready_trigger": "enemy_sneezed"}]')
def test_parse_actions_channel_exclude_not_list() -> None:
with pytest.raises(NLPolicyError, match=r"'channel_exclude' must be a list of strings"):
parse_actions('[{"kind": "channel_energy", "channel_exclude": "orc"}]')
def test_parse_actions_channel_exclude_ok() -> None:
actions = parse_actions('[{"kind": "channel_energy", "channel_exclude": ["orc"]}]')
assert actions[0].channel_exclude == ("orc",)
def test_parse_actions_tumble_not_bool() -> None:
with pytest.raises(NLPolicyError, match=r"'tumble' must be a boolean"):
parse_actions('[{"kind": "attack", "tumble": "yes"}]')
def test_parse_actions_cast_defensively_not_bool() -> None:
with pytest.raises(NLPolicyError, match=r"'cast_defensively' must be a boolean"):
parse_actions('[{"kind": "cast_spell", "cast_defensively": 1}]')
def test_parse_actions_metamagic_ok() -> None:
actions = parse_actions('[{"kind": "cast_spell", "metamagic": ["Empower", "Maximize"]}]')
assert actions[0].metamagic == ("Empower", "Maximize")
# ---- fallback_default tests --------------------------------------------------
def test_fallback_default_prefers_full_attack() -> None:
menu = (
Action(kind="total_defense"),
Action(kind="full_attack", target_id="orc"),
Action(kind="move"),
)
assert fallback_default(menu) == (Action(kind="full_attack", target_id="orc"),)
def test_fallback_default_prefers_attack_over_charge() -> None:
menu = (
Action(kind="charge", target_id="orc"),
Action(kind="attack", target_id="orc"),
)
assert fallback_default(menu) == (Action(kind="attack", target_id="orc"),)
def test_fallback_default_prefers_charge_over_move() -> None:
menu = (
Action(kind="move", target_id="orc"),
Action(kind="charge", target_id="orc"),
)
assert fallback_default(menu) == (Action(kind="charge", target_id="orc"),)
def test_fallback_default_falls_back_to_first() -> None:
menu = (Action(kind="total_defense"), Action(kind="delay"))
assert fallback_default(menu) == (Action(kind="total_defense"),)
def test_fallback_default_empty_menu_returns_wait() -> None:
assert fallback_default(()) == (Action(kind="wait"),)
# ---- NLPolicy integration tests ---------------------------------------------
def test_nl_policy_parse_ok(builder: PromptBuilder) -> None:
"""Parse OK path with valid JSON from FakeLLMClient."""
script = ['[{"kind": "move", "destination": [2, 3]}]']
client = _make_client(script, builder=builder)
policy = _make_policy(client, builder)
actions = policy(_obs())
assert actions == (Action(kind="move", destination=(2, 3)),)
assert client.stats.calls == 1
def test_nl_policy_retry_then_succeeds() -> None:
"""One bad reply, then a good one → parsed successfully."""
builder = _builder()
script = ["not json", '[{"kind": "move", "destination": [2, 3]}]']
client = _make_client(script, builder=builder)
policy = _make_policy(client, builder)
actions = policy(_obs())
assert actions == (Action(kind="move", destination=(2, 3)),)
assert client.stats.calls == 2
def test_nl_policy_retry_then_fallback() -> None:
"""Two bad replies → fallback used."""
builder = _builder()
script = ["not json", "not json"]
client = _make_client(script, builder=builder)
policy = _make_policy(client, builder)
# Minimal obs menu is (total_defense, delay)
actions = policy(_obs())
# fallback_default picks first decisive action, but menu has only defensive
assert actions == (Action(kind="total_defense"),)
assert client.stats.calls == 2
def test_nl_policy_prompt_version_mismatch() -> None:
"""Client default prompt_version vs builder pack version → ValueError."""
builder = _builder()
# Use default prompt_version ("fixture:fake:0") which differs from pack
client = FakeLLMClient(script=['[{"kind": "wait"}]'])
with pytest.raises(ValueError, match=r"prompt_version"):
_make_policy(client, builder)
def test_nl_policy_script_exhausted_propagates() -> None:
"""ScriptExhaustedError from FakeLLMClient propagates (not caught)."""
builder = _builder()
client = _make_client([], builder=builder)
policy = _make_policy(client, builder)
with pytest.raises(ScriptExhaustedError):
policy(_obs())
def test_nl_policy_shared_cache_hit() -> None:
"""Two clients sharing a ResponseCache: second call hits cache."""
builder = _builder()
cache = ResponseCache()
# First client has one scripted response
client1 = _make_client(['[{"kind": "wait"}]'], builder=builder, cache=cache)
# Second client has NO script; relies on cache hit
client2 = _make_client([], builder=builder, cache=cache)
policy1 = _make_policy(client1, builder)
policy2 = _make_policy(client2, builder)
obs = _obs()
actions1 = policy1(obs)
actions2 = policy2(obs)
assert actions2 == actions1 == (Action(kind="wait"),)
# Second call was a cache hit (no ScriptExhaustedError)
assert client2.stats.cache_hits == 1
assert client1.stats.cache_misses == 1
+87
View File
@@ -0,0 +1,87 @@
"""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
+223
View File
@@ -0,0 +1,223 @@
"""Tests for the phase-2 prompt builder (P1.4)."""
from __future__ import annotations
import hashlib
import json
from dataclasses import fields
from pathlib import Path
from pf1e_simulator.combat import Action, Observation
from pf1e_simulator.nl_policy import KindValue
from pf1e_simulator.nl_policy import get_args as _get_args
from pf1e_simulator.prompt import PromptPack
from pf1e_simulator.prompt_builder import (
PromptBuilder,
_supported_kinds,
action_schema,
render_candidates,
)
_FIXTURE_PACK = Path(__file__).resolve().parent / "fixtures" / "prompt_pack"
_FIXTURE_VERSION = "0.1.0-test"
_FIXTURE_PROMPT_NAME = "nl_default"
def _obs() -> Observation:
"""Minimal valid observation for deterministic tests."""
return Observation(
round=1,
acting_combatant=0,
combatants=(
{"id": "goblin", "side": "players", "active": True, "pos": (1, 1)},
),
pairs=(),
terrain=((None,),),
elevation=((0,),),
)
def _pack() -> PromptPack:
return PromptPack.load(_FIXTURE_PACK)
def _builder() -> PromptBuilder:
return PromptBuilder(pack=_pack())
def test_supported_kinds_filters_unsupported() -> None:
kinds = _supported_kinds()
assert "swift" not in kinds
assert "free" not in kinds
assert "full_round" not in kinds
# 27 kinds remain
assert len(kinds) == 27
# Order preserved from source Literal
assert kinds == (
"attack", "move", "wait", "immediate", "full_attack", "charge",
"withdraw", "5foot_step", "cast_spell", "maneuver", "channel_energy",
"hex", "deed", "spell_combat", "inspire_courage", "retrieve_weapon",
"repair_weapon", "activate_power_attack", "delay", "ready",
"total_defense", "fight_defensively", "stand_up", "drop_prone",
"aid_another", "run", "hide",
)
def test_action_schema_structure() -> None:
schema = action_schema()
assert schema["type"] == "object"
assert schema["required"] == ["kind"]
assert schema["additionalProperties"] is False
props = schema["properties"]
# Properties match Action fields exactly
action_field_names = {f.name for f in fields(Action)}
assert set(props.keys()) == action_field_names
# Kind enum is the filtered 27
kind_prop = props["kind"]
assert kind_prop["type"] == "string"
assert kind_prop["enum"] == list(_supported_kinds())
def test_action_schema_spot_check_fragments() -> None:
schema = action_schema()
props = schema["properties"]
# target_id accepts either a string or null
assert props["target_id"] == {"type": ["string", "null"]}
# destination is a nullable two-int pair (prefixItems + min/maxItems)
dest = props["destination"]
assert dest["type"] == ["array", "null"]
assert dest["prefixItems"] == [{"type": "integer"}, {"type": "integer"}]
assert dest["minItems"] == 2
assert dest["maxItems"] == 2
# channel_exclude: string[] (array of strings)
ch_ex = props["channel_exclude"]
assert ch_ex["type"] == "array"
assert ch_ex["items"] == {"type": "string"}
# cast_defensively is a boolean field
assert props["cast_defensively"] == {"type": "boolean"}
# tumble is a boolean field
assert props["tumble"] == {"type": "boolean"}
# channel_mode: nullable enum
ch_mode = props["channel_mode"]
assert ch_mode["type"] == ["string", "null"]
assert ch_mode["enum"] == ["heal", "harm"]
# aid_mode: nullable enum
aid = props["aid_mode"]
assert aid["type"] == ["string", "null"]
assert aid["enum"] == ["attack", "ac"]
# trick_condition: nullable enum (6 values)
tc = props["trick_condition"]
assert tc["type"] == ["string", "null"]
assert tc["enum"] == ["dazzled", "blinded", "deafened", "entangled", "shaken", "sickened"]
# ready_trigger: nullable enum (4 values)
rt = props["ready_trigger"]
assert rt["type"] == ["string", "null"]
assert rt["enum"] == ["enemy_enters_reach", "enemy_casts", "enemy_attacks", "enemy_hits_ally"]
# metamagic: string[]
meta = props["metamagic"]
assert meta["type"] == "array"
assert meta["items"] == {"type": "string"}
# class_ability is a nullable string field
assert props["class_ability"] == {"type": ["string", "null"]}
def test_render_candidates() -> None:
# Single action with payload
rendered = render_candidates((Action(kind="move", destination=(2, 3)),))
assert rendered == ('0. move {"destination":[2,3]}',)
# Action with no payload (all defaults/None)
rendered = render_candidates((Action(kind="total_defense"),))
assert rendered == ("0. total_defense",)
# Action with target_id
rendered = render_candidates((Action(kind="attack", target_id="orc"),))
assert rendered == ('0. attack {"target_id":"orc"}',)
# Empty menu
assert render_candidates(()) == ()
# Multiple actions
rendered = render_candidates((
Action(kind="attack", target_id="a"),
Action(kind="move", destination=(1, 1)),
))
assert rendered == (
'0. attack {"target_id":"a"}',
'1. move {"destination":[1,1]}',
)
def test_prompt_builder_build_deterministic() -> None:
builder = _builder()
obs = _obs()
prompt1 = builder.build(obs)
prompt2 = builder.build(obs)
assert prompt1 == prompt2
def test_prompt_builder_sections_present() -> None:
builder = _builder()
obs = _obs()
prompt = builder.build(obs)
# Template content
assert "tactical director" in prompt
assert "battle state" in prompt
# Sections
assert "## observation\n" in prompt
assert "## plausible actions\n" in prompt
assert "## action schema\n" in prompt
# Schema is valid JSON and contains kind enum
schema_part = prompt.split("## action schema\n")[1]
parsed = json.loads(schema_part)
assert parsed["properties"]["kind"]["enum"] == list(_supported_kinds())
def test_prompt_version_format() -> None:
builder = _builder()
pv = builder.prompt_version(_FIXTURE_PROMPT_NAME)
assert pv.startswith(f"{_FIXTURE_VERSION}:{_FIXTURE_PROMPT_NAME}:")
digest = pv.split(":")[-1]
assert len(digest) == 12
assert all(c in "0123456789abcdef" for c in digest)
# Cross-check against actual pack content
pack = _pack()
content = pack.get(_FIXTURE_PROMPT_NAME)
expected_digest = hashlib.sha256(content.encode("utf-8")).hexdigest()[:12]
assert digest == expected_digest
def test_prompt_builder_custom_prompt_name() -> None:
builder = _builder()
obs = _obs()
# nl_default is the only fixture, but the API accepts any name
prompt = builder.build(obs, prompt_name=_FIXTURE_PROMPT_NAME)
assert "tactical director" in prompt
def test_prompt_version_delegates_to_pack() -> None:
builder = _builder()
pack = _pack()
assert builder.prompt_version(_FIXTURE_PROMPT_NAME) == pack.prompt_version(_FIXTURE_PROMPT_NAME)
def test_action_schema_cross_pin_with_nl_policy_kindvalue() -> None:
"""The kind enum in the schema must match nl_policy.KindValue exactly."""
schema = action_schema()
assert schema["properties"]["kind"]["enum"] == list(_get_args(KindValue))