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