tower-learns-you / tests /test_local_agent.py
vknt's picture
Deploy The Tower Learns You (custom gr.Server frontend, hf_inference + mock fallback)
22a027e verified
Raw
History Blame Contribute Delete
9.56 kB
import json
from pathlib import Path
from types import SimpleNamespace
import pytest
from tower_game.ai import (
LocalOpenAIGateway,
MockGateway,
OPERATION_BRIEFS,
SYSTEM_PROMPT,
TowerAgent,
)
from tower_game.engine import TowerGame
from tower_game.narration import NarrationQueue
from tower_game.schemas import (
BossTurnDecision,
ClassEvolution,
DialChange,
GeneratedSkillSpec,
TurnNarration,
TurnNarrationRequest,
)
from tower_game.state import make_save, new_game_state, new_meta_state
class FakeCompletions:
def __init__(self, calls):
self.calls = list(calls)
self.requests = []
def create(self, **kwargs):
self.requests.append(kwargs)
tool_calls = self.calls.pop(0)
message = SimpleNamespace(content=None, tool_calls=tool_calls)
usage = SimpleNamespace(prompt_tokens=10, completion_tokens=4)
return SimpleNamespace(choices=[SimpleNamespace(message=message)], usage=usage)
class FakeClient:
def __init__(self, calls):
self.chat = SimpleNamespace(completions=FakeCompletions(calls))
def tool_call(name, arguments, call_id="call-1"):
return SimpleNamespace(
id=call_id,
function=SimpleNamespace(name=name, arguments=json.dumps(arguments)),
)
def make_agent(calls):
agent = TowerAgent.__new__(TowerAgent)
agent.config = {
"local_model": "test-model",
"timeout": 1,
"tool_round_limit": 3,
"operation_limits": {},
}
agent.client = FakeClient(calls)
agent.last_metrics = {}
return agent
def test_local_client_disables_hidden_transport_retries(monkeypatch, tmp_path):
captured = {}
class FakeOpenAI:
def __init__(self, **kwargs):
captured.update(kwargs)
monkeypatch.setattr("openai.OpenAI", FakeOpenAI)
key = tmp_path / "key.txt"
key.write_text("test-key", encoding="utf-8")
TowerAgent(
{
"base_url": "http://127.0.0.1:8080/v1",
"api_key_path": str(key),
"timeout": 8,
}
)
assert captured["max_retries"] == 0
def test_agent_requires_tools_and_supports_read_then_submit():
agent = make_agent(
[
[tool_call("inspect_recent_turns", {}, "read")],
[tool_call("submit_turn_narration", {"text": "The Tower records the blow."}, "submit")],
]
)
result = agent.call(
"turn_narration",
TurnNarration,
{"combat_events": [{"turn": 1, "actor": "Player", "text": "A hit."}]},
{"max_characters": 180},
)
assert result.text == "The Tower records the blow."
assert agent.last_metrics["tool_rounds"] == 2
request = agent.client.chat.completions.requests[0]
assert request["tool_choice"] == "required"
assert request["parallel_tool_calls"] is False
def test_agent_uses_operation_temperature_and_curated_skill_tools():
agent = make_agent(
[[tool_call("submit_turn_narration", {"text": "A measured answer."})]]
)
agent.config["temperatures"] = {"turn_narration": 0.33}
agent.call("turn_narration", TurnNarration, {}, {"max_characters": 180})
request = agent.client.chat.completions.requests[0]
assert request["temperature"] == 0.33
tools = agent._context({}, {})["list_skill_templates"]()
assert tools["primary_effects"] == [
"damage",
"heavy_damage",
"guard",
"heal",
"poison",
"attack_up",
"defense_up",
"attack_down",
"defense_down",
]
assert "lifesteal" in tools["attack_riders"]
assert "hp_sacrifice" in tools["attack_riders"]
assert "generic words" in OPERATION_BRIEFS["run_setup"]
assert "Python owns all mechanics" in SYSTEM_PROMPT
def test_local_evolution_rejects_signature_unrelated_to_repeated_strikes():
class MismatchedAgent:
last_metrics = {}
def call(self, operation, schema, state, payload, validate=None):
result = ClassEvolution(
class_name="Unrelated Magus",
title="A false answer",
narrative="This proposal ignores the repeated physical pattern.",
portrait_id="player_intelligence",
signature_skill=GeneratedSkillSpec(
name="Remote Thought",
description="An unrelated intellectual technique.",
ap_cost=1,
base_power=8,
scaling_stat="int_scaling_dial",
element="Magical",
effect_id="damage",
cooldown=1,
),
dial_changes=[DialChange(dial="int_scaling_dial", delta=0.2)],
)
if validate:
validate(result)
return result
gateway = LocalOpenAIGateway.__new__(LocalOpenAIGateway)
gateway.config = {}
gateway.agent = MismatchedAgent()
state = new_game_state(phase="combat")
state["action_history"] = ["used_strike"] * 12
result = gateway.evolution(state, 1)
assert result.signature_skill is not None
assert result.signature_skill.scaling_stat == "str_scaling_dial"
assert state["agent_status"]["mode"] == "fallback"
def test_agent_rejects_unknown_tools_and_stops_at_round_limit():
agent = make_agent(
[[tool_call("mutate_game_state", {})] for _ in range(3)]
)
with pytest.raises(RuntimeError, match="unknown tool"):
agent.call("turn_narration", TurnNarration, {}, {"max_characters": 180})
assert len(agent.client.chat.completions.requests) == 3
def test_boss_decision_uses_one_submit_only_tool_round():
agent = make_agent(
[[tool_call("submit_boss_turn_decision", {
"move_index": 1,
"reaction": "I see the pattern.",
"adjustment_enabled": False,
"replace_index": 0,
"replacement_move_id": "counter",
"replacement_power": 14,
"replacement_intent": "Tactic",
"replacement_element": "Magical",
})]]
)
result = agent.call(
"boss_turn_decision",
BossTurnDecision,
{},
{"legal_moves": [{"index": 0}, {"index": 1}]},
)
assert result.move_index == 1
request = agent.client.chat.completions.requests[0]
assert request["tool_choice"] == "required"
assert len(request["tools"]) == 1
assert request["tools"][0]["function"]["name"] == "submit_boss_turn_decision"
assert agent.last_metrics["tool_rounds"] == 1
class GeneratedGateway(MockGateway):
backend_name = "generated-test"
def test_generated_run_registry_is_persisted_and_transient_jobs_are_not():
game = TowerGame(GeneratedGateway())
state, meta = game.new_character(seed=17)
game.confirm_allocation(state, [6, 6, 6, 6])
assert state["game_phase"] == "run_setup"
game.prepare_run(state)
assert state["game_phase"] == "starter_skill"
assert len(state["run_skill_registry"]) == 16
assert len(state["pending_starter_skills"]) == 10
assert all(skill["ap_cost"] <= 2 for skill in state["run_skill_registry"].values())
state["_narration_job_id"] = "transient"
saved = make_save(state, meta)
assert "_narration_job_id" not in saved["state"]
assert len(saved["state"]["run_skill_registry"]) == 16
assert saved["state"]["loot_lexicon"]["adjectives"]
def test_narration_queue_returns_request_identity_and_result():
queue = NarrationQueue()
state = {
"run_id": "run-1",
"encounter_id": 2,
"turn_number": 3,
"floor_label": "Floor 1",
"combat_events": [{"turn": 3, "actor": "Player", "text": "Strike."}],
}
job_id = queue.submit(MockGateway(), state)
assert job_id
completed = None
for _ in range(100):
completed = queue.poll(job_id)
if completed:
break
assert completed
request, result, error, fallback = completed
assert request == TurnNarrationRequest(
run_id="run-1",
encounter_id=2,
turn_id=3,
floor_label="Floor 1",
events=state["combat_events"],
)
assert result and error is None
assert fallback is False
def test_llama_scripts_enforce_local_runtime_contracts():
root = Path(__file__).parents[1]
start = (root / "scripts" / "Start-LlamaServer.ps1").read_text(encoding="utf-8")
stop = (root / "scripts" / "Stop-LlamaServer.ps1").read_text(encoding="utf-8")
install = (root / "scripts" / "Install-LlamaCpp.ps1").read_text(encoding="utf-8")
gitignore = (root / ".gitignore").read_text(encoding="utf-8")
assert "releases/latest" in install
assert "win-cuda-12" in install
assert "Refusing to replace newer local llama.cpp" in install
assert "127.0.0.1" in start
assert "Refusing non-loopback" in start
assert "--api-key-file" in start
assert "--offline" in start
assert "--no-ui" in start
assert "--tools" not in start
assert "server.pid" in start and "server.pid" in stop
assert "Get-Process" in stop and "processPath.StartsWith" in stop
assert "Get-CimInstance" not in stop
assert "Set-Content" in stop
assert "Assets/Model/" in gitignore
assert ".local/" in gitignore