Implement phases 3-4 + all modules: decision engine, automapper, character creator, quest system, progression optimizer, and hybrid LLM integration
- Phase 4 (Decision Engine): ActionQueue with 7 priority tiers, 4 behavior states (Idle/Explore/Combat/Loot), CombatAI with skill rotation and flee logic, GoalManager with survival override, DecisionEngine state machine integrated into the main game loop - Phase 3 (Automapper): Complete hybrid SQLite/Memvid architecture with WorldMapper orchestrator, room fingerprinting (SHA-256), bidirectional exit linking, exploration queue, A* pathfinding, HistoricalStorage with JSON fallback, SemanticSearch with keyword fallback, SyncManager with configurable triggers - Character Creator: State machine for automated character creation with stat rolling/optimization per class weights, race/class/gender selection, reroll logic with configurable thresholds - Quest System: Quest lifecycle tracking (discover->accept->complete), NLP-based objective extraction (kill/fetch/talk/explore), EXP/time prioritization, pending kill target tracking - Progression Optimizer: Grinding spot finder (EXP/hour), equipment comparison by weighted stats, skill rotation optimizer (DPS vs mana efficiency), session summary statistics - LLM Integration (hybrid): Async Ollama client with retry/backoff, LLMBrain coordinator for quest parsing, social AI, decision advice, and ambiguous text parsing fallback. SocialAI for detecting and responding to player messages (say/tell/shout/group/trade). Graceful degradation when Ollama is unavailable. All 214 tests passing.
This commit is contained in:
1 parent
f85c27a742
commit
bd211b6036
34 files changed
+5479
-233
No files matched your search
@@ -0,0 +1,308 @@
|
||||
"""Tests for the LLM module (client, brain, social AI).
|
||||
|
||||
All tests use mocked HTTP responses — no Ollama server required.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
|
||||
from modules.llm.client import (
|
||||
LLMClient, LLMConfig, LLMResponse, render_template, PROMPT_TEMPLATES,
|
||||
)
|
||||
from modules.llm.brain import LLMBrain
|
||||
from modules.llm.social_ai import SocialAI
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_mock_response(text="", tokens=10, success=True):
|
||||
return LLMResponse(
|
||||
text=text, model="test", tokens_used=tokens,
|
||||
latency_ms=50.0, success=success,
|
||||
)
|
||||
|
||||
|
||||
def _make_json_response(data, tokens=10):
|
||||
return LLMResponse(
|
||||
text=json.dumps(data), model="test", tokens_used=tokens,
|
||||
latency_ms=50.0, success=True,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LLMClient
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLLMConfig:
|
||||
def test_defaults(self):
|
||||
cfg = LLMConfig()
|
||||
assert cfg.model == "llama3.2"
|
||||
assert cfg.temperature == 0.3
|
||||
assert cfg.base_url == "http://localhost:11434"
|
||||
|
||||
|
||||
class TestLLMResponse:
|
||||
def test_as_json_valid(self):
|
||||
r = LLMResponse(text='{"key": "value"}', success=True)
|
||||
data = r.as_json()
|
||||
assert data == {"key": "value"}
|
||||
|
||||
def test_as_json_invalid(self):
|
||||
r = LLMResponse(text="not json", success=True)
|
||||
assert r.as_json() is None
|
||||
|
||||
def test_as_json_empty(self):
|
||||
r = LLMResponse(text="", success=True)
|
||||
assert r.as_json() is None
|
||||
|
||||
|
||||
class TestRenderTemplate:
|
||||
def test_quest_parse_template(self):
|
||||
result = render_template("quest_parse", text="Kill 5 goblins")
|
||||
assert "Kill 5 goblins" in result
|
||||
assert "JSON" in result
|
||||
|
||||
def test_social_reply_template(self):
|
||||
result = render_template(
|
||||
"social_reply",
|
||||
message="Hello!",
|
||||
character_name="Grok",
|
||||
character_class="warrior",
|
||||
)
|
||||
assert "Grok" in result
|
||||
assert "Hello!" in result
|
||||
|
||||
def test_decide_action_template(self):
|
||||
result = render_template(
|
||||
"decide_action",
|
||||
room_title="Town Square",
|
||||
hp=100, max_hp=100, mp=50, max_mp=50,
|
||||
exits="north, south",
|
||||
npcs="guard",
|
||||
items="potion",
|
||||
combat_state="idle",
|
||||
goal="explore",
|
||||
)
|
||||
assert "Town Square" in result
|
||||
|
||||
def test_missing_variable(self):
|
||||
# Should not crash, just leave placeholder
|
||||
result = render_template("quest_parse")
|
||||
assert isinstance(result, str)
|
||||
|
||||
def test_custom_template(self):
|
||||
result = render_template("Hello {name}!", name="World")
|
||||
assert result == "Hello World!"
|
||||
|
||||
|
||||
class TestLLMClient:
|
||||
def test_client_creation(self):
|
||||
client = LLMClient(LLMConfig(model="test-model"))
|
||||
assert client.config.model == "test-model"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_unavailable(self):
|
||||
"""When server is down, should return error response."""
|
||||
client = LLMClient(LLMConfig(
|
||||
base_url="http://localhost:99999",
|
||||
max_retries=0,
|
||||
timeout_seconds=1,
|
||||
))
|
||||
resp = await client.generate("test")
|
||||
assert resp.success is False
|
||||
assert resp.error is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_available_offline(self):
|
||||
client = LLMClient(LLMConfig(base_url="http://localhost:99999"))
|
||||
available = await client.is_available()
|
||||
assert available is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LLMBrain
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLLMBrain:
|
||||
def test_creation(self):
|
||||
brain = LLMBrain(LLMConfig())
|
||||
assert brain.is_available is False
|
||||
assert brain._query_count == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_offline(self):
|
||||
brain = LLMBrain(LLMConfig(base_url="http://localhost:99999"))
|
||||
result = await brain.initialize()
|
||||
assert result is False
|
||||
assert brain.is_available is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_quest_offline_returns_none(self):
|
||||
brain = LLMBrain()
|
||||
brain._available = False
|
||||
result = await brain.parse_quest("Kill the dragon")
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_quest_with_mock(self):
|
||||
brain = LLMBrain()
|
||||
brain._available = True
|
||||
mock_resp = _make_json_response([
|
||||
{"type": "kill", "target": "dragon", "count": 1, "location": "cave"}
|
||||
])
|
||||
brain.client.ask = AsyncMock(return_value=mock_resp)
|
||||
|
||||
result = await brain.parse_quest("Slay the dragon in the cave")
|
||||
assert result is not None
|
||||
assert len(result) == 1
|
||||
assert result[0]["type"] == "kill"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_social_reply_offline_returns_none(self):
|
||||
brain = LLMBrain()
|
||||
brain._available = False
|
||||
result = await brain.social_reply("Hello!")
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_social_reply_with_mock(self):
|
||||
brain = LLMBrain()
|
||||
brain._available = True
|
||||
brain.client.ask = AsyncMock(
|
||||
return_value=_make_mock_response("Greetings, fellow adventurer!")
|
||||
)
|
||||
result = await brain.social_reply("Hello!")
|
||||
assert result is not None
|
||||
assert "adventurer" in result.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_suggest_action_offline(self):
|
||||
brain = LLMBrain()
|
||||
brain._available = False
|
||||
from core.game_state import GameState
|
||||
gs = GameState()
|
||||
result = await brain.suggest_action(gs)
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_suggest_action_with_mock(self):
|
||||
brain = LLMBrain()
|
||||
brain._available = True
|
||||
brain.client.ask = AsyncMock(
|
||||
return_value=_make_mock_response("north")
|
||||
)
|
||||
from core.game_state import GameState, Room
|
||||
gs = GameState()
|
||||
gs.current_room = Room(title="Town Square", exits={"north": 0})
|
||||
result = await brain.suggest_action(gs)
|
||||
assert result == "north"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_ambiguous_offline(self):
|
||||
brain = LLMBrain()
|
||||
brain._available = False
|
||||
result = await brain.parse_ambiguous("Something happens")
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_evaluate_npc_with_mock(self):
|
||||
brain = LLMBrain()
|
||||
brain._available = True
|
||||
brain.client.ask = AsyncMock(
|
||||
return_value=_make_json_response(
|
||||
{"hostile": True, "confidence": 0.9, "reason": "It's a goblin"}
|
||||
)
|
||||
)
|
||||
result = await brain.evaluate_npc("goblin", "A dark cave")
|
||||
assert result is not None
|
||||
assert result["hostile"] is True
|
||||
|
||||
def test_stats(self):
|
||||
brain = LLMBrain()
|
||||
stats = brain.stats()
|
||||
assert stats["queries"] == 0
|
||||
assert stats["available"] is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SocialAI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSocialAI:
|
||||
def _make_social_ai(self):
|
||||
client = LLMClient(LLMConfig())
|
||||
return SocialAI(client, character_name="Grok", character_class="warrior")
|
||||
|
||||
def test_detect_say(self):
|
||||
ai = self._make_social_ai()
|
||||
event = ai.detect_social_event("Gandalf says: Hello there!")
|
||||
assert event is not None
|
||||
assert event["type"] == "say"
|
||||
assert event["speaker"] == "Gandalf"
|
||||
|
||||
def test_detect_tell(self):
|
||||
ai = self._make_social_ai()
|
||||
event = ai.detect_social_event("Legolas tells you: Follow me")
|
||||
assert event is not None
|
||||
assert event["type"] == "tell"
|
||||
assert event["speaker"] == "Legolas"
|
||||
|
||||
def test_detect_shout(self):
|
||||
ai = self._make_social_ai()
|
||||
event = ai.detect_social_event("Gimli shouts: For glory!")
|
||||
assert event is not None
|
||||
assert event["type"] == "shout"
|
||||
|
||||
def test_detect_group_invite(self):
|
||||
ai = self._make_social_ai()
|
||||
event = ai.detect_social_event("Aragorn invites you to join a party")
|
||||
assert event is not None
|
||||
assert event["type"] == "group_invite"
|
||||
|
||||
def test_detect_no_social(self):
|
||||
ai = self._make_social_ai()
|
||||
event = ai.detect_social_event("The wind howls through the trees.")
|
||||
assert event is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_respond_say(self):
|
||||
ai = self._make_social_ai()
|
||||
ai.client.ask = AsyncMock(
|
||||
return_value=_make_mock_response("Well met, friend!")
|
||||
)
|
||||
event = {"type": "say", "speaker": "Gandalf", "message": "Hello"}
|
||||
result = await ai.respond(event)
|
||||
assert result is not None
|
||||
assert result.startswith("say ")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_respond_tell(self):
|
||||
ai = self._make_social_ai()
|
||||
ai.client.ask = AsyncMock(
|
||||
return_value=_make_mock_response("On my way!")
|
||||
)
|
||||
event = {"type": "tell", "speaker": "Legolas", "message": "Come here"}
|
||||
result = await ai.respond(event)
|
||||
assert result is not None
|
||||
assert result.startswith("tell Legolas")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_respond_group_invite(self):
|
||||
ai = self._make_social_ai()
|
||||
event = {"type": "group_invite", "speaker": "Aragorn", "message": ""}
|
||||
result = await ai.respond(event)
|
||||
assert result == "group accept"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_emote(self):
|
||||
ai = self._make_social_ai()
|
||||
ai.client.generate = AsyncMock(
|
||||
return_value=_make_mock_response("sharpens his sword quietly")
|
||||
)
|
||||
result = await ai.generate_emote("resting at the inn")
|
||||
assert result is not None
|
||||
assert result.startswith("emote ")
|
||||
Reference in new issue
Block a user