From bd211b6036460d189f4558c4e77765c072e02974 Mon Sep 17 00:00:00 2001 From: truetype Date: Sun, 8 Feb 2026 20:55:53 +0100 Subject: [PATCH] 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. --- src/config/settings.py | 29 +- src/main.py | 135 ++++++- src/modules/character/__init__.py | 8 +- src/modules/character/creator.py | 244 ++++++++++++ src/modules/decision/__init__.py | 47 ++- src/modules/decision/action_queue.py | 175 +++++++++ src/modules/decision/behavior_states.py | 300 +++++++++++++++ src/modules/decision/combat_ai.py | 198 ++++++++++ src/modules/decision/engine.py | 217 +++++++++++ src/modules/decision/goal_manager.py | 182 +++++++++ src/modules/llm/__init__.py | 24 ++ src/modules/llm/brain.py | 252 ++++++++++++ src/modules/llm/client.py | 340 +++++++++++++++++ src/modules/llm/social_ai.py | 153 ++++++++ src/modules/mapper/__init__.py | 21 +- src/modules/mapper/active_storage.py | 464 ++++++++++++++++------- src/modules/mapper/historical_storage.py | 245 ++++++++++-- src/modules/mapper/semantic_search.py | 202 ++++++++-- src/modules/mapper/sync_manager.py | 123 ++++++ src/modules/mapper/world_mapper.py | 218 +++++++++++ src/modules/progression/__init__.py | 18 +- src/modules/progression/optimizer.py | 228 +++++++++++ src/modules/quest/__init__.py | 22 +- src/modules/quest/quest_manager.py | 298 +++++++++++++++ tests/test_action_queue.py | 132 +++++++ tests/test_behavior_states.py | 200 ++++++++++ tests/test_character_creator.py | 78 ++++ tests/test_combat_ai.py | 125 ++++++ tests/test_decision_engine.py | 196 ++++++++++ tests/test_goal_manager.py | 111 ++++++ tests/test_llm.py | 308 +++++++++++++++ tests/test_progression.py | 116 ++++++ tests/test_quest_manager.py | 124 ++++++ tests/test_world_mapper.py | 179 +++++++++ 34 files changed, 5479 insertions(+), 233 deletions(-) create mode 100644 src/modules/character/creator.py create mode 100644 src/modules/decision/action_queue.py create mode 100644 src/modules/decision/behavior_states.py create mode 100644 src/modules/decision/combat_ai.py create mode 100644 src/modules/decision/engine.py create mode 100644 src/modules/decision/goal_manager.py create mode 100644 src/modules/llm/__init__.py create mode 100644 src/modules/llm/brain.py create mode 100644 src/modules/llm/client.py create mode 100644 src/modules/llm/social_ai.py create mode 100644 src/modules/mapper/sync_manager.py create mode 100644 src/modules/mapper/world_mapper.py create mode 100644 src/modules/progression/optimizer.py create mode 100644 src/modules/quest/quest_manager.py create mode 100644 tests/test_action_queue.py create mode 100644 tests/test_behavior_states.py create mode 100644 tests/test_character_creator.py create mode 100644 tests/test_combat_ai.py create mode 100644 tests/test_decision_engine.py create mode 100644 tests/test_goal_manager.py create mode 100644 tests/test_llm.py create mode 100644 tests/test_progression.py create mode 100644 tests/test_quest_manager.py create mode 100644 tests/test_world_mapper.py diff --git a/src/config/settings.py b/src/config/settings.py index 083bdac..62fd177 100644 --- a/src/config/settings.py +++ b/src/config/settings.py @@ -24,12 +24,29 @@ class CharacterConfig(BaseModel): play_style: str = "balanced" -class AIConfig(BaseModel): - """AI engine configuration""" - engine: str = "rule_based" - llm_model: str = "llama3.2" - decision_delay_ms: List[int] = Field(default=[200, 2000]) - exploration_strategy: str = "depth_first" +class LLMProviderConfig(BaseModel): + """LLM provider configuration (Ollama / OpenAI-compatible)""" + enabled: bool = True + base_url: str = "http://localhost:11434" + model: str = "llama3.2" + temperature: float = 0.3 + max_tokens: int = 512 + timeout_seconds: float = 30.0 + max_retries: int = 2 + system_prompt: str = ( + "You are an AI playing a text-based MUD game. " + "You must respond ONLY with the requested format. " + "Be concise and precise. No explanations unless asked." + ) + + +class AIConfig(BaseModel): + """AI engine configuration""" + engine: str = "hybrid" + llm_model: str = "llama3.2" + decision_delay_ms: List[int] = Field(default=[200, 2000]) + exploration_strategy: str = "depth_first" + llm: LLMProviderConfig = Field(default_factory=LLMProviderConfig) class MapperConfig(BaseModel): diff --git a/src/main.py b/src/main.py index a4de7cb..bf12e58 100644 --- a/src/main.py +++ b/src/main.py @@ -15,6 +15,13 @@ from core.parsers import ParserFactory, OutputBuffer, EventType from config.settings import load_config from utils.logger import setup_logger from utils.human_behavior import HumanBehavior +from modules.decision import DecisionEngine +from modules.decision.combat_ai import CombatConfig +from modules.mapper import WorldMapper +from modules.character import CharacterCreator, CreatorConfig +from modules.quest import QuestManager +from modules.progression import ProgressionOptimizer +from modules.llm import LLMBrain, LLMConfig, SocialAI logger = None @@ -37,11 +44,67 @@ async def main_loop(config): mud_parser = ParserFactory.create(config.mud.mud_type) output_buffer = OutputBuffer(mud_parser) + # Initialize Decision Engine (Phase 4) + combat_cfg = CombatConfig() + decision_engine = DecisionEngine( + combat_config=combat_cfg, + exploration_strategy=config.ai.exploration_strategy, + ) + decision_engine.goal_manager.set_default_goals() + + # Initialize WorldMapper (Phase 3 — hybrid SQLite/Memvid) + world_mapper = WorldMapper(db_path=config.mapper.active_storage.db_path) + + # Initialize Character Creator (Phase 2) + creator_cfg = CreatorConfig( + preferred_race=config.character.preferred_race, + preferred_class=config.character.preferred_class, + ) + character_creator = CharacterCreator(creator_cfg) + + # Initialize Quest Manager & Progression Optimizer + quest_manager = QuestManager() + progression = ProgressionOptimizer() + + # Initialize LLM Brain (hybrid AI) + llm_config = LLMConfig( + base_url=config.ai.llm.base_url, + model=config.ai.llm.model, + temperature=config.ai.llm.temperature, + max_tokens=config.ai.llm.max_tokens, + timeout_seconds=config.ai.llm.timeout_seconds, + max_retries=config.ai.llm.max_retries, + system_prompt=config.ai.llm.system_prompt, + ) + llm_brain = LLMBrain(llm_config) + social_ai = SocialAI( + client=llm_brain.client, + character_name=config.character.name, + character_class=config.character.preferred_class, + ) + if config.ai.llm.enabled: + await llm_brain.initialize() + logger.info( "Parser: %s (mud_type=%s)", type(mud_parser).__name__, config.mud.mud_type, ) + logger.info( + "Decision engine ready | strategy=%s | goals=%d", + config.ai.exploration_strategy, + decision_engine.goal_manager.active_goal_count, + ) + logger.info( + "WorldMapper ready | rooms=%d | exits=%d", + world_mapper.total_rooms, + world_mapper.total_exits, + ) + logger.info( + "LLM Brain: %s | model=%s", + "online" if llm_brain.is_available else "offline (rule-based fallback)", + config.ai.llm.model, + ) # Connect to MUD logger.info("Connecting to %s:%s", config.mud.host, config.mud.port) @@ -113,12 +176,21 @@ async def main_loop(config): elif event.event_type == EventType.ROOM and event.room: game_state.update_room(event.room) + # Update automapper + world_mapper.on_room_enter( + title=event.room.title or "", + description=event.room.description or "", + exits=event.room.exits, + npcs=event.room.npcs, + items=event.room.items, + ) logger.info( - "Room: '%s' | Exits: %s | NPCs: %s | Items: %s", + "Room: '%s' | Exits: %s | NPCs: %s | Items: %s | mapped=%d", event.room.title, event.room.exits, event.room.npcs, event.room.items, + world_mapper.total_rooms, ) elif event.event_type == EventType.COMBAT and event.combat: @@ -131,6 +203,22 @@ async def main_loop(config): event.combat.damage, ) + # --- Social AI: detect player messages in the raw block --- + if llm_brain.is_available: + social_event = social_ai.detect_social_event(block) + if social_event: + logger.info( + "Social: %s from %s: %s", + social_event["type"], + social_event["speaker"], + social_event["message"][:100], + ) + reply_cmd = await social_ai.respond(social_event) + if reply_cmd: + await human_behavior.delay() + await telnet.send(reply_cmd) + logger.info("Social reply: %s", reply_cmd) + telnet.set_receive_callback(on_receive) # Start receive loop @@ -142,6 +230,17 @@ async def main_loop(config): # Keep connection alive last_keep_alive = asyncio.get_running_loop().time() + last_decision = asyncio.get_running_loop().time() + + # Decision interval: how often (in seconds) the engine ticks + decision_interval_min = config.ai.decision_delay_ms[0] / 1000.0 + decision_interval_max = ( + config.ai.decision_delay_ms[1] / 1000.0 + if len(config.ai.decision_delay_ms) > 1 + else decision_interval_min + ) + import random as _rng + next_decision_delay = _rng.uniform(decision_interval_min, decision_interval_max) while telnet.connected: current_time = asyncio.get_running_loop().time() @@ -155,7 +254,32 @@ async def main_loop(config): await telnet.send(config.mud.keep_alive_command) last_keep_alive = current_time - await asyncio.sleep(1.0) + # ----- Decision Engine Tick ----- + if current_time - last_decision >= next_decision_delay: + # Let the engine evaluate the world and queue actions + decision_engine.tick(game_state) + + # Execute the next action from the queue + action = decision_engine.next_action() + if action: + logger.info( + "Executing: '%s' [%s] (%s)", + action.command, + action.priority.name, + action.description, + ) + # Notify mapper of movement commands + if action.source == "explore" and action.priority.name == "MOVE": + world_mapper.on_move(action.command) + await human_behavior.delay() + await telnet.send(action.command) + + last_decision = current_time + next_decision_delay = _rng.uniform( + decision_interval_min, decision_interval_max + ) + + await asyncio.sleep(0.25) except KeyboardInterrupt: logger.info("Shutting down...") @@ -168,6 +292,13 @@ async def main_loop(config): if event.event_type == EventType.ROOM and event.room: game_state.update_room(event.room) + # Close mapper and save state + logger.info( + "Session stats: %d rooms mapped, %d exits", + world_mapper.total_rooms, world_mapper.total_exits, + ) + world_mapper.close() + await telnet.disconnect() receive_task.cancel() diff --git a/src/modules/character/__init__.py b/src/modules/character/__init__.py index 49230b5..1c04169 100644 --- a/src/modules/character/__init__.py +++ b/src/modules/character/__init__.py @@ -1,3 +1,5 @@ -"""Character creation module""" - -__all__ = ["CharacterCreator"] +"""Character creation module.""" + +from .creator import CharacterCreator, CreatorConfig, StatRoll, CreationPhase + +__all__ = ["CharacterCreator", "CreatorConfig", "StatRoll", "CreationPhase"] diff --git a/src/modules/character/creator.py b/src/modules/character/creator.py new file mode 100644 index 0000000..3337119 --- /dev/null +++ b/src/modules/character/creator.py @@ -0,0 +1,244 @@ +"""Character creation automation for Dead Souls MUD. + +Handles the entire character creation flow: + 1. Detect creation prompts from server output. + 2. Choose race, class, and gender based on preferences. + 3. Roll and optimise stats (STR, INT, WIS, DEX, CON, CHA). + 4. Allocate initial skill points. + 5. Send responses via the Telnet client. + +The ``CharacterCreator`` is a state machine driven by server text. +""" + +from __future__ import annotations + +import logging +import re +from dataclasses import dataclass, field +from enum import Enum, auto +from typing import Dict, List, Optional, Tuple + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +@dataclass +class CreatorConfig: + """Preferences for automated character creation.""" + + preferred_race: str = "human" + preferred_class: str = "warrior" + preferred_gender: str = "male" + + # Minimum acceptable total stat points before accepting a roll. + min_stat_total: int = 70 + + # Stat weights per class (used to evaluate a roll). + class_stat_weights: Dict[str, Dict[str, float]] = field(default_factory=lambda: { + "warrior": {"str": 2.0, "con": 1.5, "dex": 1.0, "int": 0.5, "wis": 0.5, "cha": 0.5}, + "mage": {"int": 2.0, "wis": 1.5, "con": 1.0, "dex": 0.5, "str": 0.5, "cha": 0.5}, + "cleric": {"wis": 2.0, "con": 1.5, "int": 1.0, "str": 0.5, "dex": 0.5, "cha": 1.0}, + "thief": {"dex": 2.0, "str": 1.0, "con": 1.0, "int": 0.5, "wis": 0.5, "cha": 1.0}, + "ranger": {"dex": 1.5, "str": 1.5, "con": 1.0, "wis": 1.0, "int": 0.5, "cha": 0.5}, + }) + + max_rerolls: int = 20 + """Maximum number of stat re-rolls before accepting whatever we get.""" + + +# --------------------------------------------------------------------------- +# Creation state machine +# --------------------------------------------------------------------------- + +class CreationPhase(Enum): + """Phases of the character creation flow.""" + WAITING = auto() # Waiting for creation to start + CHOOSE_NAME = auto() + CHOOSE_RACE = auto() + CHOOSE_CLASS = auto() + CHOOSE_GENDER = auto() + ROLL_STATS = auto() + ALLOCATE_SKILLS = auto() + CONFIRM = auto() + DONE = auto() + + +# Patterns to detect creation prompts (Dead Souls) +_PATTERNS = { + "race_prompt": re.compile(r"(?:choose|select|pick).*(?:race|species)", re.I), + "class_prompt": re.compile(r"(?:choose|select|pick).*(?:class|profession|guild)", re.I), + "gender_prompt": re.compile(r"(?:choose|select|pick).*(?:gender|sex)", re.I), + "stat_display": re.compile( + r"str[ength]*\s*[:=]\s*(\d+).*?" + r"int[elligence]*\s*[:=]\s*(\d+).*?" + r"wis[dom]*\s*[:=]\s*(\d+).*?" + r"dex[terity]*\s*[:=]\s*(\d+).*?" + r"con[stitution]*\s*[:=]\s*(\d+).*?" + r"cha[risma]*\s*[:=]\s*(\d+)", + re.I | re.DOTALL, + ), + "reroll_prompt": re.compile(r"(?:reroll|roll again|keep|accept)", re.I), + "confirm": re.compile(r"(?:confirm|are you sure|create this character)", re.I), + "skill_prompt": re.compile(r"(?:allocate|distribute|spend).*(?:skill|point)", re.I), + "creation_done": re.compile(r"(?:welcome|character created|you enter the world)", re.I), +} + + +@dataclass +class StatRoll: + """A single stat roll result.""" + strength: int = 0 + intelligence: int = 0 + wisdom: int = 0 + dexterity: int = 0 + constitution: int = 0 + charisma: int = 0 + + @property + def total(self) -> int: + return ( + self.strength + self.intelligence + self.wisdom + + self.dexterity + self.constitution + self.charisma + ) + + def weighted_score(self, weights: Dict[str, float]) -> float: + """Compute a weighted score based on class priorities.""" + return ( + self.strength * weights.get("str", 1) + + self.intelligence * weights.get("int", 1) + + self.wisdom * weights.get("wis", 1) + + self.dexterity * weights.get("dex", 1) + + self.constitution * weights.get("con", 1) + + self.charisma * weights.get("cha", 1) + ) + + +class CharacterCreator: + """Drives the character creation state machine. + + Usage:: + + creator = CharacterCreator(config) + # For each chunk of server text: + response = creator.process(server_text) + if response: + await telnet.send(response) + """ + + def __init__(self, config: Optional[CreatorConfig] = None) -> None: + self.config = config or CreatorConfig() + self.phase = CreationPhase.WAITING + self._reroll_count: int = 0 + self._best_roll: Optional[StatRoll] = None + self._best_score: float = 0.0 + self._current_roll: Optional[StatRoll] = None + + @property + def is_done(self) -> bool: + return self.phase == CreationPhase.DONE + + # ------------------------------------------------------------------ + # Main processing + # ------------------------------------------------------------------ + + def process(self, text: str) -> Optional[str]: + """Analyse server text and return a response command, or ``None``. + + Each call may advance the creation phase. + """ + text_lower = text.lower() + + # Check for completion + if _PATTERNS["creation_done"].search(text): + self.phase = CreationPhase.DONE + logger.info("Character creation complete!") + return None + + # Race selection + if _PATTERNS["race_prompt"].search(text): + self.phase = CreationPhase.CHOOSE_RACE + logger.info("Choosing race: %s", self.config.preferred_race) + return self.config.preferred_race + + # Class selection + if _PATTERNS["class_prompt"].search(text): + self.phase = CreationPhase.CHOOSE_CLASS + logger.info("Choosing class: %s", self.config.preferred_class) + return self.config.preferred_class + + # Gender selection + if _PATTERNS["gender_prompt"].search(text): + self.phase = CreationPhase.CHOOSE_GENDER + logger.info("Choosing gender: %s", self.config.preferred_gender) + return self.config.preferred_gender + + # Stat display — evaluate the roll + stat_match = _PATTERNS["stat_display"].search(text) + if stat_match: + self._current_roll = StatRoll( + strength=int(stat_match.group(1)), + intelligence=int(stat_match.group(2)), + wisdom=int(stat_match.group(3)), + dexterity=int(stat_match.group(4)), + constitution=int(stat_match.group(5)), + charisma=int(stat_match.group(6)), + ) + self.phase = CreationPhase.ROLL_STATS + return self._evaluate_roll() + + # Reroll prompt (without visible stats — just confirm) + if _PATTERNS["reroll_prompt"].search(text) and self.phase == CreationPhase.ROLL_STATS: + return self._evaluate_roll() + + # Skill allocation + if _PATTERNS["skill_prompt"].search(text): + self.phase = CreationPhase.ALLOCATE_SKILLS + # For now, just accept defaults + logger.info("Skill allocation — accepting defaults") + return "done" + + # Confirmation + if _PATTERNS["confirm"].search(text): + self.phase = CreationPhase.CONFIRM + logger.info("Confirming character creation") + return "yes" + + return None + + # ------------------------------------------------------------------ + # Stat evaluation + # ------------------------------------------------------------------ + + def _evaluate_roll(self) -> str: + """Decide whether to keep or reroll the current stats.""" + if self._current_roll is None: + return "reroll" + + weights = self.config.class_stat_weights.get( + self.config.preferred_class, {} + ) + score = self._current_roll.weighted_score(weights) + + if self._best_roll is None or score > self._best_score: + self._best_roll = self._current_roll + self._best_score = score + + self._reroll_count += 1 + + # Accept if good enough or we've hit the reroll limit + total = self._current_roll.total + if total >= self.config.min_stat_total or self._reroll_count >= self.config.max_rerolls: + logger.info( + "Accepting stats (total=%d, score=%.1f, rerolls=%d): %s", + total, score, self._reroll_count, self._current_roll, + ) + return "keep" + + logger.debug( + "Rerolling (total=%d, score=%.1f, attempt=%d/%d)", + total, score, self._reroll_count, self.config.max_rerolls, + ) + return "reroll" diff --git a/src/modules/decision/__init__.py b/src/modules/decision/__init__.py index 0786317..abadfe9 100644 --- a/src/modules/decision/__init__.py +++ b/src/modules/decision/__init__.py @@ -1,3 +1,44 @@ -"""Decision engine module""" - -__all__ = ["DecisionEngine", "CombatAI", "GoalManager"] +"""Decision engine module — Phase 4: The Brain. + +Public API:: + + from modules.decision import DecisionEngine, CombatAI, GoalManager + +Sub-modules +----------- +action_queue Priority-ordered action queue. +behavior_states Concrete behavior states (Idle, Explore, Combat, Loot). +combat_ai Intelligent combat decision-making. +engine Central decision engine / state machine. +goal_manager High-level goal tracking and prioritisation. +""" + +from .engine import DecisionEngine +from .combat_ai import CombatAI, CombatConfig +from .goal_manager import GoalManager, Goal, GoalPriority, GoalStatus +from .action_queue import ActionQueue, Action, ActionPriority +from .behavior_states import ( + BaseBehaviorState, + IdleState, + ExploreState, + CombatState, + LootState, +) + +__all__ = [ + "DecisionEngine", + "CombatAI", + "CombatConfig", + "GoalManager", + "Goal", + "GoalPriority", + "GoalStatus", + "ActionQueue", + "Action", + "ActionPriority", + "BaseBehaviorState", + "IdleState", + "ExploreState", + "CombatState", + "LootState", +] diff --git a/src/modules/decision/action_queue.py b/src/modules/decision/action_queue.py new file mode 100644 index 0000000..e5df82b --- /dev/null +++ b/src/modules/decision/action_queue.py @@ -0,0 +1,175 @@ +"""Priority-based action queue for the decision engine. + +Actions are ordered by priority so that survival-critical actions (healing, +fleeing) always execute before offensive or exploratory ones. + +Priority levels (lower number = higher priority): + 0 - EMERGENCY (flee when near death) + 1 - HEAL (restore HP/MP/SP) + 2 - COMBAT (attack, use combat skills) + 3 - LOOT (pick up items from corpses) + 4 - INTERACT (talk to NPCs, open doors) + 5 - MOVE (navigate / explore) + 6 - IDLE (look around, rest) +""" + +from __future__ import annotations + +import heapq +import itertools +import logging +from dataclasses import dataclass, field +from enum import IntEnum +from typing import Optional, List + +logger = logging.getLogger(__name__) + + +class ActionPriority(IntEnum): + """Priority tiers for queued actions (lower = more urgent).""" + + EMERGENCY = 0 + HEAL = 1 + COMBAT = 2 + LOOT = 3 + INTERACT = 4 + MOVE = 5 + IDLE = 6 + + +@dataclass(order=False) +class Action: + """A single command to send to the MUD. + + Attributes + ---------- + command: + The raw MUD command string (e.g. ``"kill goblin"``, ``"north"``). + priority: + Priority tier — used to order execution. + description: + Human-readable note for logging/debugging. + source: + Which subsystem enqueued this action (e.g. ``"combat_ai"``). + """ + + command: str + priority: ActionPriority = ActionPriority.IDLE + description: str = "" + source: str = "" + + def __repr__(self) -> str: + return ( + f"Action({self.command!r}, " + f"priority={self.priority.name}, " + f"source={self.source!r})" + ) + + +class ActionQueue: + """Thread-safe, priority-ordered action queue. + + Uses a min-heap so the action with the *lowest* priority number + (most urgent) is always dequeued first. A monotonic counter is used + as a tiebreaker so that actions of equal priority are returned in + FIFO order. + """ + + def __init__(self, max_size: int = 100) -> None: + self._heap: List[tuple] = [] + self._counter = itertools.count() + self._max_size = max_size + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def push(self, action: Action) -> None: + """Add an action to the queue. + + If the queue is at capacity the lowest-priority (highest number) + item is silently dropped to make room. + """ + if len(self._heap) >= self._max_size: + # Drop the *least* urgent item (max priority number). + self._drop_lowest_priority() + + entry = (int(action.priority), next(self._counter), action) + heapq.heappush(self._heap, entry) + logger.debug("Queued: %s", action) + + def pop(self) -> Optional[Action]: + """Remove and return the most urgent action, or ``None``.""" + if not self._heap: + return None + _priority, _count, action = heapq.heappop(self._heap) + return action + + def peek(self) -> Optional[Action]: + """Return the most urgent action without removing it.""" + if not self._heap: + return None + return self._heap[0][2] + + def clear(self) -> None: + """Discard all queued actions.""" + self._heap.clear() + + def clear_by_source(self, source: str) -> int: + """Remove all actions enqueued by *source*. + + Returns the number of actions removed. + """ + before = len(self._heap) + self._heap = [ + entry for entry in self._heap if entry[2].source != source + ] + heapq.heapify(self._heap) + removed = before - len(self._heap) + if removed: + logger.debug("Cleared %d actions from source '%s'", removed, source) + return removed + + def clear_by_priority(self, priority: ActionPriority) -> int: + """Remove all actions at the given priority tier. + + Returns the number of actions removed. + """ + before = len(self._heap) + self._heap = [ + entry for entry in self._heap if entry[0] != int(priority) + ] + heapq.heapify(self._heap) + removed = before - len(self._heap) + return removed + + @property + def size(self) -> int: + return len(self._heap) + + @property + def is_empty(self) -> bool: + return len(self._heap) == 0 + + def as_list(self) -> List[Action]: + """Return all queued actions in priority order (non-destructive).""" + return [entry[2] for entry in sorted(self._heap)] + + # ------------------------------------------------------------------ + # Internals + # ------------------------------------------------------------------ + + def _drop_lowest_priority(self) -> None: + """Remove the *least* urgent item (highest priority number).""" + if not self._heap: + return + # Find max-priority entry (O(n) but queue is small) + worst_idx = 0 + for i, entry in enumerate(self._heap): + if entry[0] > self._heap[worst_idx][0]: + worst_idx = i + elif entry[0] == self._heap[worst_idx][0] and entry[1] > self._heap[worst_idx][1]: + worst_idx = i + dropped = self._heap.pop(worst_idx) + heapq.heapify(self._heap) + logger.debug("Queue full — dropped: %s", dropped[2]) diff --git a/src/modules/decision/behavior_states.py b/src/modules/decision/behavior_states.py new file mode 100644 index 0000000..57d5a5e --- /dev/null +++ b/src/modules/decision/behavior_states.py @@ -0,0 +1,300 @@ +"""Behavior states for the decision engine state machine. + +Each concrete state encapsulates the logic for one mode of play and +produces ``Action`` objects that get pushed onto the ``ActionQueue``. + +State transitions are determined by the ``DecisionEngine`` based on the +current ``GameState``. States themselves are stateless singletons that +receive all context via method arguments. + +State hierarchy +--------------- + BaseBehaviorState (ABC) + +-- IdleState : heal, buff, look around + +-- ExploreState : pick an unexplored exit and move + +-- CombatState : fight the current target + +-- LootState : pick up items / loot corpses +""" + +from __future__ import annotations + +import logging +import random +from abc import ABC, abstractmethod +from typing import List, TYPE_CHECKING + +from .action_queue import Action, ActionPriority + +if TYPE_CHECKING: + from ...core.game_state import GameState + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Abstract base +# --------------------------------------------------------------------------- + +class BaseBehaviorState(ABC): + """Interface for a single behavior mode.""" + + @property + @abstractmethod + def name(self) -> str: + """Human-readable name for logging.""" + + @abstractmethod + def get_actions(self, game_state: "GameState") -> List[Action]: + """Return zero or more actions appropriate for the current state. + + The engine will push them onto the ``ActionQueue`` in bulk; order + within the returned list doesn't matter because the queue sorts + by priority. + """ + + def on_enter(self, game_state: "GameState") -> None: + """Called once when the engine transitions *into* this state.""" + logger.info("Entering state: %s", self.name) + + def on_exit(self, game_state: "GameState") -> None: + """Called once when the engine transitions *out of* this state.""" + logger.debug("Exiting state: %s", self.name) + + +# --------------------------------------------------------------------------- +# Concrete states +# --------------------------------------------------------------------------- + +class IdleState(BaseBehaviorState): + """Rest / heal / buff when there is nothing urgent to do. + + Behaviour + --------- + * If HP < heal_threshold -> queue ``heal`` command. + * If MP is low -> queue ``meditate`` (or rest). + * Otherwise -> queue ``look`` to refresh room info. + """ + + name = "idle" + + def __init__( + self, + heal_threshold: float = 0.8, + mp_threshold: float = 0.5, + heal_command: str = "heal", + rest_command: str = "rest", + ) -> None: + self.heal_threshold = heal_threshold + self.mp_threshold = mp_threshold + self.heal_command = heal_command + self.rest_command = rest_command + + def get_actions(self, game_state: "GameState") -> List[Action]: + actions: List[Action] = [] + char = game_state.character + + # Heal if HP below threshold + if char.max_hp > 0 and (char.hp / char.max_hp) < self.heal_threshold: + actions.append(Action( + command=self.heal_command, + priority=ActionPriority.HEAL, + description=f"Heal: HP {char.hp}/{char.max_hp}", + source="idle", + )) + + # Rest if MP below threshold + if char.max_mp > 0 and (char.mp / char.max_mp) < self.mp_threshold: + actions.append(Action( + command=self.rest_command, + priority=ActionPriority.HEAL, + description=f"Rest: MP {char.mp}/{char.max_mp}", + source="idle", + )) + + # If nothing to heal, just look around to refresh room data + if not actions: + actions.append(Action( + command="look", + priority=ActionPriority.IDLE, + description="Refresh room info", + source="idle", + )) + + return actions + + +class ExploreState(BaseBehaviorState): + """Navigate the world by choosing unexplored exits. + + Behaviour + --------- + * If the current room has exits, pick one (random or systematic). + * Prefers exits whose destination is unknown (``room_id == 0``). + * Falls back to a random known exit if all are explored. + """ + + name = "explore" + + def __init__(self, strategy: str = "random") -> None: + self.strategy = strategy # "random" | "systematic" + self._visited_directions: List[str] = [] + + def get_actions(self, game_state: "GameState") -> List[Action]: + room = game_state.current_room + if room is None or not room.exits: + # No room data — ask the MUD to describe our surroundings + return [Action( + command="look", + priority=ActionPriority.MOVE, + description="No room data — looking", + source="explore", + )] + + # Separate unexplored (room_id == 0) from explored exits + unexplored = [d for d, rid in room.exits.items() if rid == 0] + explored = [d for d, rid in room.exits.items() if rid != 0] + + if unexplored: + direction = self._pick_direction(unexplored) + desc = f"Explore unexplored exit: {direction}" + elif explored: + direction = self._pick_direction(explored) + desc = f"Move through explored exit: {direction}" + else: + # Exits dict is empty after filtering — shouldn't happen, just look + return [Action( + command="look", + priority=ActionPriority.MOVE, + description="No exits available", + source="explore", + )] + + return [Action( + command=direction, + priority=ActionPriority.MOVE, + description=desc, + source="explore", + )] + + def _pick_direction(self, directions: List[str]) -> str: + if self.strategy == "systematic": + # Prefer directions we haven't taken recently + for d in directions: + if d not in self._visited_directions: + self._visited_directions.append(d) + return d + # All visited — reset and pick first + self._visited_directions.clear() + self._visited_directions.append(directions[0]) + return directions[0] + else: + return random.choice(directions) + + +class CombatState(BaseBehaviorState): + """Fight the current combat target. + + Behaviour + --------- + * If HP critically low -> queue emergency flee. + * If HP < heal threshold and has heal -> queue heal first, then attack. + * Otherwise -> queue attack on current target. + """ + + name = "combat" + + def __init__( + self, + flee_threshold: float = 0.2, + combat_heal_threshold: float = 0.4, + attack_command: str = "kill", + flee_command: str = "flee", + heal_command: str = "heal", + ) -> None: + self.flee_threshold = flee_threshold + self.combat_heal_threshold = combat_heal_threshold + self.attack_command = attack_command + self.flee_command = flee_command + self.heal_command = heal_command + + def get_actions(self, game_state: "GameState") -> List[Action]: + actions: List[Action] = [] + char = game_state.character + hp_ratio = char.hp / char.max_hp if char.max_hp > 0 else 1.0 + + # Emergency flee + if hp_ratio < self.flee_threshold: + actions.append(Action( + command=self.flee_command, + priority=ActionPriority.EMERGENCY, + description=f"FLEE! HP={char.hp}/{char.max_hp} ({hp_ratio:.0%})", + source="combat", + )) + return actions + + # Heal during combat if moderately hurt + if hp_ratio < self.combat_heal_threshold: + actions.append(Action( + command=self.heal_command, + priority=ActionPriority.HEAL, + description=f"Combat heal: HP {char.hp}/{char.max_hp}", + source="combat", + )) + + # Attack + target = game_state.target + if target: + cmd = f"{self.attack_command} {target}" + else: + # No specific target — generic attack + cmd = self.attack_command + actions.append(Action( + command=cmd, + priority=ActionPriority.COMBAT, + description=f"Attack: {target or 'nearest'}", + source="combat", + )) + + return actions + + +class LootState(BaseBehaviorState): + """Pick up items after combat ends. + + Behaviour + --------- + * Queue ``get all from corpse`` to loot the kill. + * Queue ``get all`` as fallback. + * Then transition back to idle/explore (handled by the engine). + """ + + name = "loot" + + def __init__( + self, + loot_command: str = "get all from corpse", + fallback_command: str = "get all", + ) -> None: + self.loot_command = loot_command + self.fallback_command = fallback_command + + def get_actions(self, game_state: "GameState") -> List[Action]: + actions: List[Action] = [] + + # Primary loot attempt + actions.append(Action( + command=self.loot_command, + priority=ActionPriority.LOOT, + description="Loot corpse", + source="loot", + )) + + # Fallback — pick up anything lying around + actions.append(Action( + command=self.fallback_command, + priority=ActionPriority.LOOT, + description="Pick up loose items", + source="loot", + )) + + return actions diff --git a/src/modules/decision/combat_ai.py b/src/modules/decision/combat_ai.py new file mode 100644 index 0000000..ed9f4f5 --- /dev/null +++ b/src/modules/decision/combat_ai.py @@ -0,0 +1,198 @@ +"""Combat AI — intelligent combat decision-making. + +Responsible for: + * Target selection (prioritise aggressive / weakest mobs) + * Skill rotation (cycle through available combat skills) + * Flee logic (emergency retreat when HP is critical) + * Health management during combat (when to heal vs attack) + +The ``CombatAI`` does **not** push actions directly. Instead it +returns ``Action`` objects that the ``CombatState`` (or the engine) +queues on the ``ActionQueue``. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from typing import List, Optional, TYPE_CHECKING + +from .action_queue import Action, ActionPriority + +if TYPE_CHECKING: + from ...core.game_state import GameState + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +@dataclass +class CombatConfig: + """Tuneable parameters for the combat AI.""" + + flee_hp_ratio: float = 0.20 + """Flee when HP/MaxHP drops below this ratio.""" + + heal_hp_ratio: float = 0.40 + """Heal during combat when HP/MaxHP is below this ratio.""" + + attack_command: str = "kill" + """Base command used to attack (e.g. ``kill``, ``attack``).""" + + flee_command: str = "flee" + """Command to flee combat.""" + + heal_command: str = "heal" + """Command for in-combat healing.""" + + skill_commands: List[str] = field(default_factory=list) + """Ordered list of combat skill commands to cycle through. + + Example: ``["backstab", "kick", "bash"]`` + When empty, only the base ``attack_command`` is used. + """ + + skill_cooldown_ticks: int = 3 + """Minimum ticks (decision cycles) between skill uses.""" + + +class CombatAI: + """Stateful combat decision-maker. + + Tracks the current engagement and decides the optimal action each + tick based on the character's vitals and the combat situation. + """ + + def __init__(self, config: Optional[CombatConfig] = None) -> None: + self.config = config or CombatConfig() + self._skill_index: int = 0 + self._ticks_since_skill: int = 0 + self._last_target: Optional[str] = None + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def decide(self, game_state: "GameState") -> List[Action]: + """Evaluate the situation and return the best combat actions. + + Returns an empty list if there is nothing combat-related to do + (e.g. not in combat, no target). + """ + actions: List[Action] = [] + char = game_state.character + hp_ratio = char.hp / char.max_hp if char.max_hp > 0 else 1.0 + + # --- Emergency flee --- + if hp_ratio < self.config.flee_hp_ratio: + actions.append(Action( + command=self.config.flee_command, + priority=ActionPriority.EMERGENCY, + description=f"EMERGENCY FLEE: HP {char.hp}/{char.max_hp} ({hp_ratio:.0%})", + source="combat_ai", + )) + logger.warning( + "CombatAI: FLEE triggered (HP %.0f%%)", hp_ratio * 100, + ) + return actions + + # --- In-combat heal --- + if hp_ratio < self.config.heal_hp_ratio: + actions.append(Action( + command=self.config.heal_command, + priority=ActionPriority.HEAL, + description=f"Combat heal: HP {char.hp}/{char.max_hp}", + source="combat_ai", + )) + + # --- Attack --- + target = self._select_target(game_state) + attack_action = self._choose_attack(target) + actions.append(attack_action) + + self._ticks_since_skill += 1 + self._last_target = target + + return actions + + def reset(self) -> None: + """Reset combat state (call when combat ends).""" + self._skill_index = 0 + self._ticks_since_skill = 0 + self._last_target = None + logger.debug("CombatAI: reset") + + # ------------------------------------------------------------------ + # Target selection + # ------------------------------------------------------------------ + + def _select_target(self, game_state: "GameState") -> Optional[str]: + """Choose which mob to attack. + + Priority order: + 1. The mob currently attacking us (``game_state.target``). + 2. The first NPC in the room (aggressive assumption). + """ + # Already tracking a target + if game_state.target: + return game_state.target + + # Pick first NPC in room (heuristic — we don't know if it's hostile) + room = game_state.current_room + if room and room.npcs: + target = room.npcs[0] + logger.debug("CombatAI: auto-targeting '%s'", target) + return target + + return None + + # ------------------------------------------------------------------ + # Attack selection + # ------------------------------------------------------------------ + + def _choose_attack(self, target: Optional[str]) -> Action: + """Pick the best attack action for this tick.""" + skills = self.config.skill_commands + + # Use a skill if available and off cooldown + if skills and self._ticks_since_skill >= self.config.skill_cooldown_ticks: + skill_cmd = skills[self._skill_index % len(skills)] + self._skill_index += 1 + self._ticks_since_skill = 0 + + cmd = f"{skill_cmd} {target}" if target else skill_cmd + return Action( + command=cmd, + priority=ActionPriority.COMBAT, + description=f"Skill: {skill_cmd} -> {target or 'nearest'}", + source="combat_ai", + ) + + # Default attack + base = self.config.attack_command + cmd = f"{base} {target}" if target else base + return Action( + command=cmd, + priority=ActionPriority.COMBAT, + description=f"Attack: {target or 'nearest'}", + source="combat_ai", + ) + + # ------------------------------------------------------------------ + # Utility + # ------------------------------------------------------------------ + + def should_engage(self, game_state: "GameState") -> bool: + """Return ``True`` if we should initiate combat. + + Currently returns ``True`` if there are NPCs in the room and + we are healthy enough. In a future version this could check + mob levels, aggro flags, etc. + """ + if not game_state.is_healthy(threshold=0.5): + return False + room = game_state.current_room + return bool(room and room.npcs) diff --git a/src/modules/decision/engine.py b/src/modules/decision/engine.py new file mode 100644 index 0000000..e71aef2 --- /dev/null +++ b/src/modules/decision/engine.py @@ -0,0 +1,217 @@ +"""Decision engine — the brain of the MUD AI player. + +Orchestrates the ``ActionQueue``, ``BehaviorStates``, ``CombatAI``, +and ``GoalManager`` into a single tick-based loop. + +Lifecycle (called from ``main.py`` each iteration):: + + engine.tick(game_state) + -> evaluate state transitions + -> ask current state for actions + -> push actions onto the queue + action = engine.next_action() + -> pop the most urgent action (may be ``None``) + -> caller sends ``action.command`` to the MUD +""" + +from __future__ import annotations + +import logging +from typing import Dict, Optional, TYPE_CHECKING + +from .action_queue import ActionQueue, Action +from .behavior_states import ( + BaseBehaviorState, + CombatState as CombatBehavior, + ExploreState, + IdleState, + LootState, +) +from .combat_ai import CombatAI, CombatConfig +from .goal_manager import GoalManager + +if TYPE_CHECKING: + from ...core.game_state import GameState, CombatState as GameCombatState + +logger = logging.getLogger(__name__) + + +class DecisionEngine: + """Central decision-making engine. + + Parameters + ---------- + combat_config: + Optional ``CombatConfig`` to tune flee / heal thresholds, skills, etc. + exploration_strategy: + ``"random"`` or ``"systematic"`` — forwarded to ``ExploreState``. + """ + + def __init__( + self, + combat_config: Optional[CombatConfig] = None, + exploration_strategy: str = "random", + ) -> None: + # Sub-systems + self.action_queue = ActionQueue() + self.combat_ai = CombatAI(config=combat_config) + self.goal_manager = GoalManager() + + # Behavior states (keyed by name) + self._states: Dict[str, BaseBehaviorState] = { + "idle": IdleState(), + "explore": ExploreState(strategy=exploration_strategy), + "combat": CombatBehavior(), + "loot": LootState(), + } + + self._current_state_name: str = "idle" + self._previous_state_name: str = "idle" + self._tick_count: int = 0 + + # Track combat end for looting transition + self._was_in_combat: bool = False + + # ------------------------------------------------------------------ + # Properties + # ------------------------------------------------------------------ + + @property + def current_state(self) -> BaseBehaviorState: + return self._states[self._current_state_name] + + @property + def current_state_name(self) -> str: + return self._current_state_name + + # ------------------------------------------------------------------ + # Main tick + # ------------------------------------------------------------------ + + def tick(self, game_state: "GameState") -> None: + """Evaluate the world, transition states, and queue actions. + + This should be called once per decision cycle from the main loop. + """ + self._tick_count += 1 + + # 1. Determine which state we should be in + new_state = self._evaluate_state(game_state) + + # 2. Transition if necessary + if new_state != self._current_state_name: + self._transition(new_state, game_state) + + # 3. Get actions from the current state + actions = self.current_state.get_actions(game_state) + + # 3b. If in combat, also consult the CombatAI for smarter decisions + if self._current_state_name == "combat": + ai_actions = self.combat_ai.decide(game_state) + # CombatAI actions override the generic CombatState actions + if ai_actions: + actions = ai_actions + + # 4. Push actions onto the queue + for action in actions: + self.action_queue.push(action) + + logger.debug( + "Tick #%d | state=%s | queued=%d | queue_size=%d", + self._tick_count, + self._current_state_name, + len(actions), + self.action_queue.size, + ) + + def next_action(self) -> Optional[Action]: + """Pop and return the most urgent action, or ``None``.""" + return self.action_queue.pop() + + # ------------------------------------------------------------------ + # State evaluation + # ------------------------------------------------------------------ + + def _evaluate_state(self, game_state: "GameState") -> str: + """Decide which behavior state should be active. + + Priority logic: + 1. If HP is critically low -> ``idle`` (to heal / flee is + handled within combat state if already fighting). + 2. If currently in combat -> ``combat``. + 3. If just left combat -> ``loot``. + 4. Consult the GoalManager for the next objective. + 5. Default: ``explore``. + """ + from core.game_state import CombatState as CS + + in_combat = game_state.combat_state == CS.IN_COMBAT + + # --- Combat --- + if in_combat: + self._was_in_combat = True + return "combat" + + # --- Just left combat -> loot --- + if self._was_in_combat and not in_combat: + self._was_in_combat = False + return "loot" + + # --- HP critically low (outside combat) -> idle to heal --- + if not game_state.is_healthy(threshold=0.5): + return "idle" + + # --- Ask the GoalManager --- + goal = self.goal_manager.get_current_goal(game_state) + if goal: + target = goal.target_state + if target in self._states: + return target + logger.warning( + "GoalManager suggested unknown state '%s', falling back", + target, + ) + + # --- Default --- + return "explore" + + # ------------------------------------------------------------------ + # State transitions + # ------------------------------------------------------------------ + + def _transition(self, new_state: str, game_state: "GameState") -> None: + """Perform a state transition with enter/exit hooks.""" + old = self._current_state_name + + # Exit old state + self._states[old].on_exit(game_state) + + # Clear actions from the old state's source to avoid stale commands + self.action_queue.clear_by_source(old) + + # Reset combat AI when leaving combat + if old == "combat" and new_state != "combat": + self.combat_ai.reset() + + # Enter new state + self._previous_state_name = old + self._current_state_name = new_state + self._states[new_state].on_enter(game_state) + + logger.info("State transition: %s -> %s", old, new_state) + + # ------------------------------------------------------------------ + # Manual overrides + # ------------------------------------------------------------------ + + def force_state(self, state_name: str, game_state: "GameState") -> None: + """Force an immediate transition (for debugging / external control).""" + if state_name not in self._states: + logger.error("Cannot force unknown state '%s'", state_name) + return + self._transition(state_name, game_state) + + def register_state(self, state: BaseBehaviorState) -> None: + """Add or replace a behavior state.""" + self._states[state.name] = state + logger.info("Registered behavior state: '%s'", state.name) diff --git a/src/modules/decision/goal_manager.py b/src/modules/decision/goal_manager.py new file mode 100644 index 0000000..c137e94 --- /dev/null +++ b/src/modules/decision/goal_manager.py @@ -0,0 +1,182 @@ +"""High-level goal manager. + +Goals are long-lived objectives such as "explore the world", "grind to +level 10", or "complete quest X". The ``GoalManager`` tracks them and +tells the ``DecisionEngine`` which behavior state should be active. + +Goals are prioritised so that survival always comes first, followed by +combat, then questing, then exploration. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from enum import IntEnum +from typing import List, Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from ...core.game_state import GameState + +logger = logging.getLogger(__name__) + + +class GoalPriority(IntEnum): + """Goal priority tiers (lower = more important).""" + + SURVIVAL = 0 + COMBAT = 1 + QUEST = 2 + GRIND = 3 + EXPLORE = 4 + + +class GoalStatus: + ACTIVE = "active" + COMPLETED = "completed" + FAILED = "failed" + PAUSED = "paused" + + +@dataclass +class Goal: + """A single high-level objective. + + Attributes + ---------- + name: + Identifier / human-readable label. + priority: + Determines which goal takes precedence. + description: + Longer explanation (for logging / UI). + target_state: + The behavior state name that should be active while pursuing + this goal (e.g. ``"explore"``, ``"combat"``). + status: + Current lifecycle status. + progress: + Arbitrary 0-100 completion percentage. + """ + + name: str + priority: GoalPriority = GoalPriority.EXPLORE + description: str = "" + target_state: str = "explore" + status: str = GoalStatus.ACTIVE + progress: float = 0.0 + metadata: dict = field(default_factory=dict) + + def is_active(self) -> bool: + return self.status == GoalStatus.ACTIVE + + +class GoalManager: + """Manages the pool of active goals and decides which to pursue. + + The engine calls :meth:`get_current_goal` each tick to determine + which behavior state should be active. + """ + + def __init__(self) -> None: + self._goals: List[Goal] = [] + + # ------------------------------------------------------------------ + # Goal CRUD + # ------------------------------------------------------------------ + + def add_goal(self, goal: Goal) -> None: + """Add a goal to the pool (duplicates allowed).""" + self._goals.append(goal) + logger.info("Goal added: '%s' (priority=%s)", goal.name, goal.priority.name) + + def remove_goal(self, name: str) -> bool: + """Remove the first goal matching *name*. Returns ``True`` if found.""" + for i, g in enumerate(self._goals): + if g.name == name: + self._goals.pop(i) + logger.info("Goal removed: '%s'", name) + return True + return False + + def complete_goal(self, name: str) -> bool: + """Mark a goal as completed.""" + for g in self._goals: + if g.name == name and g.is_active(): + g.status = GoalStatus.COMPLETED + logger.info("Goal completed: '%s'", name) + return True + return False + + def fail_goal(self, name: str) -> bool: + """Mark a goal as failed.""" + for g in self._goals: + if g.name == name and g.is_active(): + g.status = GoalStatus.FAILED + logger.info("Goal failed: '%s'", name) + return True + return False + + # ------------------------------------------------------------------ + # Query + # ------------------------------------------------------------------ + + def get_current_goal(self, game_state: "GameState") -> Optional[Goal]: + """Return the highest-priority active goal. + + Takes ``game_state`` so that dynamic re-prioritisation can + happen (e.g. force a survival goal when HP is critical). + """ + # Inject an ephemeral survival goal if HP is dangerously low + if not game_state.is_healthy(threshold=0.3): + return Goal( + name="survive", + priority=GoalPriority.SURVIVAL, + description="HP critically low — must heal or flee", + target_state="idle", # idle state handles healing + ) + + active = [g for g in self._goals if g.is_active()] + if not active: + return None + # Sort by priority (lowest number = highest priority) + active.sort(key=lambda g: int(g.priority)) + return active[0] + + def get_active_goals(self) -> List[Goal]: + """Return all active goals sorted by priority.""" + return sorted( + [g for g in self._goals if g.is_active()], + key=lambda g: int(g.priority), + ) + + @property + def goal_count(self) -> int: + return len(self._goals) + + @property + def active_goal_count(self) -> int: + return sum(1 for g in self._goals if g.is_active()) + + # ------------------------------------------------------------------ + # Default goals + # ------------------------------------------------------------------ + + def set_default_goals(self) -> None: + """Populate with a sensible set of starter goals.""" + defaults = [ + Goal( + name="explore_world", + priority=GoalPriority.EXPLORE, + description="Discover new rooms and map the world", + target_state="explore", + ), + Goal( + name="grind_combat", + priority=GoalPriority.GRIND, + description="Fight mobs for experience and loot", + target_state="combat", + ), + ] + for g in defaults: + self.add_goal(g) diff --git a/src/modules/llm/__init__.py b/src/modules/llm/__init__.py new file mode 100644 index 0000000..700d526 --- /dev/null +++ b/src/modules/llm/__init__.py @@ -0,0 +1,24 @@ +"""LLM integration module — hybrid AI with Ollama. + +Provides LLM-augmented intelligence across all subsystems: + * Quest parsing fallback + * Social AI (roleplay, trade, groups) + * Decision override for complex situations + * Ambiguous text parsing + +All LLM features gracefully degrade when Ollama is unavailable. +""" + +from .client import LLMClient, LLMConfig, LLMResponse, render_template, PROMPT_TEMPLATES +from .brain import LLMBrain +from .social_ai import SocialAI + +__all__ = [ + "LLMClient", + "LLMConfig", + "LLMResponse", + "LLMBrain", + "SocialAI", + "render_template", + "PROMPT_TEMPLATES", +] diff --git a/src/modules/llm/brain.py b/src/modules/llm/brain.py new file mode 100644 index 0000000..4605cc1 --- /dev/null +++ b/src/modules/llm/brain.py @@ -0,0 +1,252 @@ +"""LLMBrain — hybrid AI coordinator. + +Uses the LLM as an intelligent fallback across all subsystems: + + 1. **Quest parsing** — when regex fails to extract objectives. + 2. **Social AI** — roleplay responses to other players. + 3. **Decision override** — complex situations the state machine can't handle. + 4. **Parsing fallback** — interpret ambiguous MUD output. + +Design principle: **rules first, LLM second**. The LLM is never in the +critical path — if Ollama is down, everything still works via rule-based +logic. The LLM only *enhances* decisions, never *blocks* them. +""" + +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass +from typing import Dict, List, Optional, TYPE_CHECKING + +from .client import LLMClient, LLMConfig, LLMResponse, render_template + +if TYPE_CHECKING: + from ...core.game_state import GameState + +logger = logging.getLogger(__name__) + + +class LLMBrain: + """Hybrid AI brain that augments rule-based systems with LLM intelligence. + + Usage:: + + brain = LLMBrain(LLMConfig(model="llama3.2")) + await brain.initialize() + + # Quest parsing fallback + objectives = await brain.parse_quest("The old sage mutters about goblins in the mine") + + # Social response + reply = await brain.social_reply("Hey, want to join my party?", "Warrior", "Grok") + + # Decision advice + action = await brain.suggest_action(game_state) + + # Parse unknown text + parsed = await brain.parse_ambiguous("Something strange happens...") + """ + + def __init__(self, config: Optional[LLMConfig] = None) -> None: + self.client = LLMClient(config) + self._available = False + self._query_count = 0 + self._total_tokens = 0 + self._total_latency_ms = 0.0 + + async def initialize(self) -> bool: + """Check LLM availability. Returns ``True`` if the server is reachable.""" + self._available = await self.client.is_available() + if self._available: + logger.info( + "LLMBrain online: model=%s, url=%s", + self.client.config.model, + self.client.config.base_url, + ) + else: + logger.warning( + "LLMBrain offline — all queries will fall back to rule-based logic" + ) + return self._available + + @property + def is_available(self) -> bool: + return self._available + + # ------------------------------------------------------------------ + # 1. Quest Parsing + # ------------------------------------------------------------------ + + async def parse_quest(self, text: str) -> Optional[List[Dict]]: + """Extract quest objectives from ambiguous text using the LLM. + + Returns a list of objective dicts, or ``None`` if the LLM is + unavailable or fails to produce valid JSON. + + Each dict has: ``type``, ``target``, ``count``, ``location``. + """ + if not self._available: + return None + + resp = await self.client.ask( + "quest_parse", json_mode=True, text=text, + ) + self._record(resp) + + if not resp.success: + return None + + data = resp.as_json() + if isinstance(data, list): + return data + if isinstance(data, dict) and "objectives" in data: + return data["objectives"] + return None + + # ------------------------------------------------------------------ + # 2. Social AI + # ------------------------------------------------------------------ + + async def social_reply( + self, + message: str, + character_class: str = "adventurer", + character_name: str = "Player", + ) -> Optional[str]: + """Generate an in-character reply to another player's message. + + Returns the reply string, or ``None`` on failure. + """ + if not self._available: + return None + + resp = await self.client.ask( + "social_reply", + message=message, + character_class=character_class, + character_name=character_name, + ) + self._record(resp) + + if not resp.success or not resp.text: + return None + + # Clean up: remove quotes, ensure it's a single line + reply = resp.text.strip().strip('"').strip("'") + # Limit length for MUD output + if len(reply) > 200: + reply = reply[:197] + "..." + return reply + + # ------------------------------------------------------------------ + # 3. Decision Advice + # ------------------------------------------------------------------ + + async def suggest_action(self, game_state: "GameState") -> Optional[str]: + """Ask the LLM what command to execute given the current game state. + + Returns a single MUD command string, or ``None``. + """ + if not self._available: + return None + + room = game_state.current_room + goal_text = "explore the world" + + resp = await self.client.ask( + "decide_action", + room_title=room.title if room else "unknown", + hp=game_state.character.hp, + max_hp=game_state.character.max_hp, + mp=game_state.character.mp, + max_mp=game_state.character.max_mp, + exits=", ".join(room.exits.keys()) if room else "none", + npcs=", ".join(room.npcs) if room else "none", + items=", ".join(room.items) if room else "none", + combat_state=game_state.combat_state.value, + goal=goal_text, + ) + self._record(resp) + + if not resp.success or not resp.text: + return None + + # Extract just the command (first line, stripped) + command = resp.text.strip().split("\n")[0].strip() + # Remove common LLM artifacts + for prefix in ("Command:", "command:", ">", "$ "): + if command.startswith(prefix): + command = command[len(prefix):].strip() + return command if command else None + + # ------------------------------------------------------------------ + # 4. Parsing Fallback + # ------------------------------------------------------------------ + + async def parse_ambiguous(self, text: str) -> Optional[Dict]: + """Attempt to classify and parse ambiguous MUD output via LLM. + + Returns a dict with ``type`` and ``data`` keys, or ``None``. + """ + if not self._available: + return None + + resp = await self.client.ask( + "parse_ambiguous", json_mode=True, text=text, + ) + self._record(resp) + + if not resp.success: + return None + + return resp.as_json() + + # ------------------------------------------------------------------ + # 5. NPC Evaluation + # ------------------------------------------------------------------ + + async def evaluate_npc( + self, npc_name: str, room_description: str, + ) -> Optional[Dict]: + """Determine if an NPC is likely hostile or friendly. + + Returns ``{"hostile": bool, "confidence": float, "reason": str}`` + or ``None``. + """ + if not self._available: + return None + + resp = await self.client.ask( + "evaluate_npc", + json_mode=True, + npc_name=npc_name, + room_description=room_description, + ) + self._record(resp) + + if not resp.success: + return None + + return resp.as_json() + + # ------------------------------------------------------------------ + # Stats + # ------------------------------------------------------------------ + + def stats(self) -> Dict: + """Return usage statistics.""" + return { + "available": self._available, + "queries": self._query_count, + "total_tokens": self._total_tokens, + "avg_latency_ms": ( + self._total_latency_ms / self._query_count + if self._query_count > 0 else 0 + ), + } + + def _record(self, resp: LLMResponse) -> None: + self._query_count += 1 + self._total_tokens += resp.tokens_used + self._total_latency_ms += resp.latency_ms diff --git a/src/modules/llm/client.py b/src/modules/llm/client.py new file mode 100644 index 0000000..1ba9fc6 --- /dev/null +++ b/src/modules/llm/client.py @@ -0,0 +1,340 @@ +"""Async LLM client for Ollama (local inference). + +Provides a thin async wrapper around the Ollama HTTP API with: + * Automatic retry with exponential back-off. + * Configurable timeout. + * JSON-mode for structured output. + * Prompt template system for reproducible queries. + +The client is transport-agnostic — it talks plain HTTP so it works +with Ollama, LM Studio, or any OpenAI-compatible endpoint. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import time +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional +from urllib.request import Request, urlopen +from urllib.error import URLError + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +@dataclass +class LLMConfig: + """Configuration for the LLM client.""" + + base_url: str = "http://localhost:11434" + """Ollama API base URL.""" + + model: str = "llama3.2" + """Model name (must be pulled in Ollama first).""" + + temperature: float = 0.3 + """Sampling temperature (lower = more deterministic).""" + + max_tokens: int = 512 + """Maximum tokens in the response.""" + + timeout_seconds: float = 30.0 + """Per-request timeout.""" + + max_retries: int = 2 + """Number of retries on transient failures.""" + + retry_delay_seconds: float = 1.0 + """Initial delay between retries (doubles each attempt).""" + + system_prompt: str = ( + "You are an AI playing a text-based MUD game. " + "You must respond ONLY with the requested format. " + "Be concise and precise. No explanations unless asked." + ) + + +# --------------------------------------------------------------------------- +# Response wrapper +# --------------------------------------------------------------------------- + +@dataclass +class LLMResponse: + """Parsed response from the LLM.""" + + text: str = "" + model: str = "" + tokens_used: int = 0 + latency_ms: float = 0.0 + success: bool = True + error: Optional[str] = None + + def as_json(self) -> Optional[Dict]: + """Try to parse the response text as JSON.""" + try: + return json.loads(self.text) + except (json.JSONDecodeError, TypeError): + return None + + +# --------------------------------------------------------------------------- +# Prompt templates +# --------------------------------------------------------------------------- + +PROMPT_TEMPLATES: Dict[str, str] = { + "quest_parse": ( + "Extract quest objectives from this MUD text. " + "Return a JSON array of objects with fields: " + "type (kill|fetch|deliver|explore|talk), target, count, location.\n\n" + "Text: {text}\n\n" + "JSON:" + ), + + "social_reply": ( + "You are {character_name}, a {character_class} in a MUD game. " + "Another player said: \"{message}\"\n" + "Write a short in-character reply (max 1 sentence). " + "Reply:" + ), + + "decide_action": ( + "You are playing a MUD. Current situation:\n" + "- Room: {room_title}\n" + "- HP: {hp}/{max_hp} MP: {mp}/{max_mp}\n" + "- Exits: {exits}\n" + "- NPCs: {npcs}\n" + "- Items: {items}\n" + "- Combat: {combat_state}\n" + "- Current goal: {goal}\n\n" + "What single MUD command should you execute next? " + "Reply with ONLY the command (e.g. 'north', 'kill goblin', 'heal').\n" + "Command:" + ), + + "parse_ambiguous": ( + "This is output from a MUD (text game). " + "Classify it as one of: room, combat, quest, social, system, unknown.\n" + "Then extract any structured data you can.\n" + "Return JSON with fields: type, data (object with relevant fields).\n\n" + "Text: {text}\n\n" + "JSON:" + ), + + "evaluate_npc": ( + "In a MUD game, determine if this NPC is likely hostile or friendly " + "based on its description and context.\n" + "NPC: {npc_name}\n" + "Room: {room_description}\n" + "Reply with JSON: {{\"hostile\": true/false, \"confidence\": 0.0-1.0, \"reason\": \"...\"}}\n" + "JSON:" + ), +} + + +def render_template(template_name: str, **kwargs) -> str: + """Fill a prompt template with the given variables.""" + template = PROMPT_TEMPLATES.get(template_name, template_name) + try: + return template.format(**kwargs) + except KeyError as e: + logger.warning("Missing template variable: %s", e) + return template + + +# --------------------------------------------------------------------------- +# LLM Client +# --------------------------------------------------------------------------- + +class LLMClient: + """Async client for Ollama-compatible LLM APIs. + + Usage:: + + client = LLMClient(LLMConfig(model="llama3.2")) + response = await client.generate("What is 2+2?") + print(response.text) + """ + + def __init__(self, config: Optional[LLMConfig] = None) -> None: + self.config = config or LLMConfig() + self._available: Optional[bool] = None + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + async def generate( + self, + prompt: str, + system: Optional[str] = None, + json_mode: bool = False, + temperature: Optional[float] = None, + max_tokens: Optional[int] = None, + ) -> LLMResponse: + """Send a prompt to the LLM and return the response. + + Parameters + ---------- + prompt: + The user prompt. + system: + Optional system prompt override. + json_mode: + If ``True``, request JSON output format. + temperature: + Override default temperature. + max_tokens: + Override default max tokens. + """ + payload = { + "model": self.config.model, + "prompt": prompt, + "system": system or self.config.system_prompt, + "stream": False, + "options": { + "temperature": temperature or self.config.temperature, + "num_predict": max_tokens or self.config.max_tokens, + }, + } + if json_mode: + payload["format"] = "json" + + return await self._request("/api/generate", payload) + + async def chat( + self, + messages: List[Dict[str, str]], + json_mode: bool = False, + temperature: Optional[float] = None, + ) -> LLMResponse: + """Send a chat-style conversation to the LLM. + + Parameters + ---------- + messages: + List of ``{"role": "user"|"assistant"|"system", "content": "..."}`` + """ + payload = { + "model": self.config.model, + "messages": messages, + "stream": False, + "options": { + "temperature": temperature or self.config.temperature, + "num_predict": self.config.max_tokens, + }, + } + if json_mode: + payload["format"] = "json" + + return await self._request("/api/chat", payload, chat_mode=True) + + async def is_available(self) -> bool: + """Check if the LLM server is reachable.""" + if self._available is not None: + return self._available + try: + url = f"{self.config.base_url}/api/tags" + req = Request(url, method="GET") + loop = asyncio.get_running_loop() + resp = await loop.run_in_executor( + None, + lambda: urlopen(req, timeout=5), + ) + self._available = resp.status == 200 + except Exception: + self._available = False + logger.info("LLM available: %s", self._available) + return self._available + + # ------------------------------------------------------------------ + # Template helpers + # ------------------------------------------------------------------ + + async def ask( + self, + template_name: str, + json_mode: bool = False, + **kwargs, + ) -> LLMResponse: + """Render a template and send it to the LLM. + + Convenience wrapper around :meth:`generate`. + """ + prompt = render_template(template_name, **kwargs) + return await self.generate(prompt, json_mode=json_mode) + + # ------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------ + + async def _request( + self, + endpoint: str, + payload: Dict[str, Any], + chat_mode: bool = False, + ) -> LLMResponse: + """Execute an HTTP request with retry logic.""" + url = f"{self.config.base_url}{endpoint}" + data = json.dumps(payload).encode("utf-8") + + delay = self.config.retry_delay_seconds + last_error = "" + + for attempt in range(self.config.max_retries + 1): + t0 = time.monotonic() + try: + req = Request( + url, + data=data, + headers={"Content-Type": "application/json"}, + method="POST", + ) + loop = asyncio.get_running_loop() + resp = await loop.run_in_executor( + None, + lambda: urlopen(req, timeout=self.config.timeout_seconds), + ) + body = json.loads(resp.read().decode("utf-8")) + elapsed = (time.monotonic() - t0) * 1000 + + # Extract text from response + if chat_mode: + text = body.get("message", {}).get("content", "") + else: + text = body.get("response", "") + + tokens = body.get("eval_count", 0) + + logger.debug( + "LLM response: %d tokens, %.0fms", tokens, elapsed, + ) + return LLMResponse( + text=text.strip(), + model=self.config.model, + tokens_used=tokens, + latency_ms=elapsed, + success=True, + ) + + except Exception as exc: + elapsed = (time.monotonic() - t0) * 1000 + last_error = str(exc) + logger.warning( + "LLM request failed (attempt %d/%d, %.0fms): %s", + attempt + 1, self.config.max_retries + 1, + elapsed, last_error, + ) + if attempt < self.config.max_retries: + await asyncio.sleep(delay) + delay *= 2 + + return LLMResponse( + success=False, + error=f"LLM unavailable after {self.config.max_retries + 1} attempts: {last_error}", + ) diff --git a/src/modules/llm/social_ai.py b/src/modules/llm/social_ai.py new file mode 100644 index 0000000..e00fd19 --- /dev/null +++ b/src/modules/llm/social_ai.py @@ -0,0 +1,153 @@ +"""Social AI — LLM-driven interaction with other players. + +Handles: + * Roleplay responses to say/tell/shout messages. + * Trade negotiation (buy/sell offers). + * Group invitations (accept/decline with reason). + * Emote generation for immersion. + +All methods are async and gracefully return ``None`` when the LLM +is unavailable, allowing the caller to fall back to silence or a +canned response. +""" + +from __future__ import annotations + +import logging +import re +from typing import Optional + +from .client import LLMClient, LLMConfig + +logger = logging.getLogger(__name__) + +# Patterns to detect player communication +_SAY_RE = re.compile(r"(\w+)\s+says?[,:]\s*['\"]?(.+?)['\"]?\s*$", re.I) +_TELL_RE = re.compile(r"(\w+)\s+tells?\s+you[,:]\s*['\"]?(.+?)['\"]?\s*$", re.I) +_SHOUT_RE = re.compile(r"(\w+)\s+shouts?[,:]\s*['\"]?(.+?)['\"]?\s*$", re.I) +_GROUP_INVITE_RE = re.compile(r"(\w+)\s+invites?\s+you\s+to\s+(?:join\s+)?(?:a\s+)?(?:group|party)", re.I) +_TRADE_RE = re.compile(r"(\w+)\s+offers?\s+(?:to\s+)?(?:buy|sell|trade)", re.I) + + +class SocialAI: + """Generates socially-appropriate responses using the LLM.""" + + def __init__( + self, + client: LLMClient, + character_name: str = "Player", + character_class: str = "adventurer", + ) -> None: + self.client = client + self.character_name = character_name + self.character_class = character_class + + # ------------------------------------------------------------------ + # Detection + # ------------------------------------------------------------------ + + def detect_social_event(self, text: str) -> Optional[dict]: + """Check if *text* is a social interaction. + + Returns ``{"type": str, "speaker": str, "message": str}`` or ``None``. + """ + for pattern, event_type in [ + (_TELL_RE, "tell"), + (_SAY_RE, "say"), + (_SHOUT_RE, "shout"), + ]: + m = pattern.search(text) + if m: + return { + "type": event_type, + "speaker": m.group(1), + "message": m.group(2).strip(), + } + + if _GROUP_INVITE_RE.search(text): + m = _GROUP_INVITE_RE.search(text) + return {"type": "group_invite", "speaker": m.group(1), "message": text} + + if _TRADE_RE.search(text): + m = _TRADE_RE.search(text) + return {"type": "trade", "speaker": m.group(1), "message": text} + + return None + + # ------------------------------------------------------------------ + # Response generation + # ------------------------------------------------------------------ + + async def respond(self, event: dict) -> Optional[str]: + """Generate a response for a social event. + + Returns the MUD command to send (e.g. ``say Hello!``), or ``None``. + """ + event_type = event.get("type", "say") + speaker = event.get("speaker", "someone") + message = event.get("message", "") + + # Group invites are handled without LLM + if event_type == "group_invite": + return await self._handle_group_invite(speaker) + + reply_text = await self._generate_reply(message) + if not reply_text: + return None + + # Format as a MUD command + if event_type == "tell": + return f"tell {speaker} {reply_text}" + elif event_type == "shout": + return f"shout {reply_text}" + elif event_type == "trade": + return f"tell {speaker} {reply_text}" + else: + return f"say {reply_text}" + + async def _generate_reply(self, message: str) -> Optional[str]: + """Use the LLM to generate an in-character reply.""" + resp = await self.client.ask( + "social_reply", + message=message, + character_class=self.character_class, + character_name=self.character_name, + ) + if not resp.success or not resp.text: + return None + + reply = resp.text.strip().strip('"').strip("'") + if len(reply) > 200: + reply = reply[:197] + "..." + return reply + + async def _handle_group_invite(self, speaker: str) -> str: + """Decide whether to accept a group invite.""" + # For now, always accept — a future version could consult + # the GoalManager to decide. + logger.info("Accepting group invite from %s", speaker) + return "group accept" + + # ------------------------------------------------------------------ + # Emotes + # ------------------------------------------------------------------ + + async def generate_emote(self, context: str) -> Optional[str]: + """Generate a contextual emote command for immersion. + + Example: ``emote sharpens his sword quietly.`` + """ + prompt = ( + f"You are {self.character_name}, a {self.character_class}. " + f"Context: {context}\n" + f"Generate a short emote action (max 10 words, no quotes). " + f"Emote:" + ) + resp = await self.client.generate(prompt) + if not resp.success or not resp.text: + return None + + emote = resp.text.strip().strip('"').strip("'") + if len(emote) > 80: + emote = emote[:77] + "..." + return f"emote {emote}" diff --git a/src/modules/mapper/__init__.py b/src/modules/mapper/__init__.py index 6027938..a9a1c75 100644 --- a/src/modules/mapper/__init__.py +++ b/src/modules/mapper/__init__.py @@ -1,3 +1,18 @@ -"""Auto-mapper module""" - -__all__ = ["WorldMapper", "Pathfinder", "Explorer", "ActiveStorage", "HistoricalStorage", "SemanticSearch"] +"""Auto-mapper module — hybrid SQLite/Memvid automapping system.""" + +from .world_mapper import WorldMapper +from .active_storage import ActiveStorage, generate_fingerprint +from .historical_storage import HistoricalStorage +from .semantic_search import SemanticSearch +from .sync_manager import SyncManager +from .pathfinder import Pathfinder + +__all__ = [ + "WorldMapper", + "ActiveStorage", + "generate_fingerprint", + "HistoricalStorage", + "SemanticSearch", + "SyncManager", + "Pathfinder", +] diff --git a/src/modules/mapper/active_storage.py b/src/modules/mapper/active_storage.py index 3f1cdd3..7dd27de 100644 --- a/src/modules/mapper/active_storage.py +++ b/src/modules/mapper/active_storage.py @@ -1,146 +1,318 @@ -"""Active storage for real-time map data""" - -import sqlite3 -import networkx as nx -from typing import Optional, Dict, List -import json -import logging - -logger = logging.getLogger(__name__) - - -class ActiveStorage: - """Layer 1: Active storage using SQLite + NetworkX for real-time exploration""" - - def __init__(self, db_path: str = "data/maps/active/world.db"): - self.db_path = db_path - self.conn: Optional[sqlite3.Connection] = None - self.graph = nx.DiGraph() - self._init_database() - self._load_graph() - - def _init_database(self): - """Initialize SQLite database with schema""" - self.conn = sqlite3.connect(self.db_path) - cursor = self.conn.cursor() - - # Create rooms table - cursor.execute(''' - CREATE TABLE IF NOT EXISTS rooms ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - fingerprint TEXT UNIQUE NOT NULL, - description TEXT, - short_desc TEXT, - zone TEXT, - coordinates TEXT, - npcs TEXT, - items TEXT, - exits TEXT, - first_visited TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - last_visited TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - visit_count INTEGER DEFAULT 1 - ) - ''') - - # Create exits table - cursor.execute(''' - CREATE TABLE IF NOT EXISTS exits ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - from_room_id INTEGER NOT NULL, - to_room_id INTEGER NOT NULL, - direction TEXT NOT NULL, - FOREIGN KEY (from_room_id) REFERENCES rooms(id), - FOREIGN KEY (to_room_id) REFERENCES rooms(id), - UNIQUE(from_room_id, direction) - ) - ''') - - # Create indexes - cursor.execute('CREATE INDEX IF NOT EXISTS idx_fingerprint ON rooms(fingerprint)') - cursor.execute('CREATE INDEX IF NOT EXISTS idx_zone ON rooms(zone)') - - self.conn.commit() - logger.info(f"Database initialized at {self.db_path}") - - def _load_graph(self): - """Load graph from database into memory""" - cursor = self.conn.cursor() - - # Load rooms as nodes - cursor.execute('SELECT id, fingerprint, description, zone FROM rooms') - for row in cursor.fetchall(): - room_id, fingerprint, description, zone = row - self.graph.add_node(room_id, fingerprint=fingerprint, description=description, zone=zone) - - # Load exits as edges - cursor.execute('SELECT from_room_id, to_room_id, direction FROM exits') - for row in cursor.fetchall(): - from_id, to_id, direction = row - self.graph.add_edge(from_id, to_id, direction=direction) - - logger.info(f"Loaded graph: {self.graph.number_of_nodes()} rooms, {self.graph.number_of_edges()} exits") - - async def add_room(self, room_data: Dict) -> int: - """ - Add new room to active storage - - Args: - room_data: Dictionary with room information - - Returns: - Room ID - """ - cursor = self.conn.cursor() - - cursor.execute(''' - INSERT OR REPLACE INTO rooms - (fingerprint, description, short_desc, zone, coordinates, npcs, items, exits) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - ''', ( - room_data['fingerprint'], - room_data.get('description', ''), - room_data.get('short_desc'), - room_data.get('zone'), - json.dumps(room_data.get('coordinates', {})), - json.dumps(room_data.get('npcs', [])), - json.dumps(room_data.get('items', [])), - json.dumps(room_data.get('exits', {})) - )) - - self.conn.commit() - room_id = cursor.lastrowid - - # Update graph - self.graph.add_node(room_id, **room_data) - - # Add exits to graph - for direction, target_id in room_data.get('exits', {}).items(): - self.graph.add_edge(room_id, target_id, direction=direction) - - logger.debug(f"Added room {room_id}: {room_data.get('short_desc', 'Unknown')}") - return room_id - - def get_room(self, room_id: int) -> Optional[Dict]: - """Get room data by ID""" - cursor = self.conn.cursor() - cursor.execute('SELECT * FROM rooms WHERE id = ?', (room_id,)) - row = cursor.fetchone() - - if not row: - return None - - # Convert row to dictionary - # TODO: Implement proper row to dict conversion - return {"id": row[0], "description": row[2]} - - def get_all_rooms(self) -> List[Dict]: - """Get all rooms for snapshot""" - cursor = self.conn.cursor() - cursor.execute('SELECT * FROM rooms') - # TODO: Convert rows to dictionaries - return [] - - def close(self): - """Close database connection""" - if self.conn: - self.conn.close() +"""Active storage for real-time map data. + +Layer 1 of the hybrid storage architecture: + SQLite for persistence + NetworkX DiGraph for in-memory navigation. + +Provides O(1) room lookup by fingerprint, real-time graph updates, +and an exploration queue for tracking unexplored exits. +""" + +import hashlib +import json +import logging +import os +import sqlite3 +import time +from typing import Optional, Dict, List, Tuple + +import networkx as nx + +logger = logging.getLogger(__name__) + +# Column indices for the rooms table +_COL_ID = 0 +_COL_FINGERPRINT = 1 +_COL_TITLE = 2 +_COL_DESCRIPTION = 3 +_COL_SHORT_DESC = 4 +_COL_ZONE = 5 +_COL_COORDINATES = 6 +_COL_NPCS = 7 +_COL_ITEMS = 8 +_COL_EXITS = 9 +_COL_FIRST_VISITED = 10 +_COL_LAST_VISITED = 11 +_COL_VISIT_COUNT = 12 +_COL_IS_DYNAMIC = 13 +_COL_CONFIDENCE = 14 + + +def generate_fingerprint(title: str, description: str, exits: List[str]) -> str: + """Create a deterministic room fingerprint. + + Uses SHA-256 of normalised title + first 100 chars of description + + sorted exit list. This produces a stable ID even across sessions. + """ + norm_title = (title or "").strip().lower() + norm_desc = (description or "").strip().lower()[:100] + norm_exits = ",".join(sorted(e.lower() for e in exits)) + payload = f"{norm_title}|{norm_desc}|{norm_exits}" + return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16] + + +class ActiveStorage: + """Layer 1: Active storage using SQLite + NetworkX for real-time exploration.""" + + def __init__(self, db_path: str = "data/maps/active/world.db") -> None: + self.db_path = db_path + self.conn: Optional[sqlite3.Connection] = None + self.graph = nx.DiGraph() + self._ensure_directory() + self._init_database() + self._load_graph() + + # ------------------------------------------------------------------ + # Initialisation + # ------------------------------------------------------------------ + + def _ensure_directory(self) -> None: + dirpath = os.path.dirname(self.db_path) + if dirpath: + os.makedirs(dirpath, exist_ok=True) + + def _init_database(self) -> None: + """Create the SQLite schema if it doesn't exist.""" + self.conn = sqlite3.connect(self.db_path) + cur = self.conn.cursor() + + cur.execute(""" + CREATE TABLE IF NOT EXISTS rooms ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + fingerprint TEXT UNIQUE NOT NULL, + title TEXT, + description TEXT, + short_desc TEXT, + zone TEXT, + coordinates TEXT, + npcs TEXT, + items TEXT, + exits TEXT, + first_visited TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_visited TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + visit_count INTEGER DEFAULT 1, + is_dynamic INTEGER DEFAULT 0, + confidence REAL DEFAULT 1.0 + ) + """) + + cur.execute(""" + CREATE TABLE IF NOT EXISTS exits ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + from_room_id INTEGER NOT NULL, + to_room_id INTEGER, + direction TEXT NOT NULL, + blocked INTEGER DEFAULT 0, + FOREIGN KEY (from_room_id) REFERENCES rooms(id), + FOREIGN KEY (to_room_id) REFERENCES rooms(id), + UNIQUE(from_room_id, direction) + ) + """) + + cur.execute(""" + CREATE TABLE IF NOT EXISTS exploration_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + room_id INTEGER NOT NULL, + direction TEXT NOT NULL, + priority INTEGER DEFAULT 5, + added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (room_id) REFERENCES rooms(id), + UNIQUE(room_id, direction) + ) + """) + + cur.execute("CREATE INDEX IF NOT EXISTS idx_fingerprint ON rooms(fingerprint)") + cur.execute("CREATE INDEX IF NOT EXISTS idx_zone ON rooms(zone)") + cur.execute("CREATE INDEX IF NOT EXISTS idx_eq_priority ON exploration_queue(priority)") + + self.conn.commit() + logger.info("Database initialised at %s", self.db_path) + + def _load_graph(self) -> None: + """Populate the in-memory graph from the database.""" + cur = self.conn.cursor() + + cur.execute( + "SELECT id, fingerprint, title, description, zone FROM rooms" + ) + for row in cur.fetchall(): + rid, fp, title, desc, zone = row + self.graph.add_node( + rid, fingerprint=fp, title=title, + description=desc, zone=zone, + ) + + cur.execute("SELECT from_room_id, to_room_id, direction, blocked FROM exits") + for row in cur.fetchall(): + from_id, to_id, direction, blocked = row + if to_id is not None and not blocked: + self.graph.add_edge(from_id, to_id, direction=direction) + + logger.info( + "Loaded graph: %d rooms, %d exits", + self.graph.number_of_nodes(), + self.graph.number_of_edges(), + ) + + # ------------------------------------------------------------------ + # Room CRUD + # ------------------------------------------------------------------ + + def add_room(self, room_data: Dict) -> int: + """Insert or update a room. Returns the room ID.""" + cur = self.conn.cursor() + fp = room_data["fingerprint"] + + # Check if room already exists + cur.execute("SELECT id, visit_count FROM rooms WHERE fingerprint = ?", (fp,)) + existing = cur.fetchone() + + if existing: + room_id, visits = existing + cur.execute( + "UPDATE rooms SET last_visited = CURRENT_TIMESTAMP, " + "visit_count = ? WHERE id = ?", + (visits + 1, room_id), + ) + self.conn.commit() + logger.debug("Revisited room %d (visits=%d)", room_id, visits + 1) + return room_id + + cur.execute( + """INSERT INTO rooms + (fingerprint, title, description, short_desc, zone, + coordinates, npcs, items, exits, is_dynamic, confidence) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + ( + fp, + room_data.get("title", ""), + room_data.get("description", ""), + room_data.get("short_desc"), + room_data.get("zone"), + json.dumps(room_data.get("coordinates", {})), + json.dumps(room_data.get("npcs", [])), + json.dumps(room_data.get("items", [])), + json.dumps(room_data.get("exits", {})), + int(room_data.get("is_dynamic", False)), + room_data.get("confidence", 1.0), + ), + ) + self.conn.commit() + room_id = cur.lastrowid + + # Update in-memory graph + self.graph.add_node(room_id, **room_data) + logger.debug("Added room %d: '%s'", room_id, room_data.get("title", "?")) + return room_id + + def add_exit( + self, from_room_id: int, to_room_id: Optional[int], direction: str, + blocked: bool = False, + ) -> None: + """Record an exit between two rooms.""" + cur = self.conn.cursor() + cur.execute( + """INSERT OR REPLACE INTO exits + (from_room_id, to_room_id, direction, blocked) + VALUES (?, ?, ?, ?)""", + (from_room_id, to_room_id, direction, int(blocked)), + ) + self.conn.commit() + + if to_room_id is not None and not blocked: + self.graph.add_edge(from_room_id, to_room_id, direction=direction) + + def get_room(self, room_id: int) -> Optional[Dict]: + """Return a full room dict by numeric ID.""" + cur = self.conn.cursor() + cur.execute("SELECT * FROM rooms WHERE id = ?", (room_id,)) + row = cur.fetchone() + return self._row_to_dict(row) if row else None + + def get_room_by_fingerprint(self, fingerprint: str) -> Optional[Dict]: + """Look up a room by its fingerprint hash.""" + cur = self.conn.cursor() + cur.execute("SELECT * FROM rooms WHERE fingerprint = ?", (fingerprint,)) + row = cur.fetchone() + return self._row_to_dict(row) if row else None + + def get_all_rooms(self) -> List[Dict]: + """Return every room as a dict list (used for Memvid snapshots).""" + cur = self.conn.cursor() + cur.execute("SELECT * FROM rooms") + return [self._row_to_dict(r) for r in cur.fetchall()] + + def room_count(self) -> int: + """Return the total number of stored rooms.""" + cur = self.conn.cursor() + cur.execute("SELECT COUNT(*) FROM rooms") + return cur.fetchone()[0] + + # ------------------------------------------------------------------ + # Exploration queue + # ------------------------------------------------------------------ + + def enqueue_unexplored( + self, room_id: int, direction: str, priority: int = 5, + ) -> None: + """Add an unexplored exit to the exploration queue.""" + cur = self.conn.cursor() + cur.execute( + """INSERT OR IGNORE INTO exploration_queue + (room_id, direction, priority) VALUES (?, ?, ?)""", + (room_id, direction, priority), + ) + self.conn.commit() + + def dequeue_next_exploration(self) -> Optional[Tuple[int, str]]: + """Pop the highest-priority unexplored exit. + + Returns ``(room_id, direction)`` or ``None``. + """ + cur = self.conn.cursor() + cur.execute( + "SELECT id, room_id, direction FROM exploration_queue " + "ORDER BY priority ASC, added_at ASC LIMIT 1" + ) + row = cur.fetchone() + if not row: + return None + eq_id, room_id, direction = row + cur.execute("DELETE FROM exploration_queue WHERE id = ?", (eq_id,)) + self.conn.commit() + return (room_id, direction) + + def exploration_queue_size(self) -> int: + cur = self.conn.cursor() + cur.execute("SELECT COUNT(*) FROM exploration_queue") + return cur.fetchone()[0] + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + @staticmethod + def _row_to_dict(row) -> Dict: + """Convert a raw SQLite row tuple to a room dictionary.""" + return { + "id": row[_COL_ID], + "fingerprint": row[_COL_FINGERPRINT], + "title": row[_COL_TITLE], + "description": row[_COL_DESCRIPTION], + "short_desc": row[_COL_SHORT_DESC], + "zone": row[_COL_ZONE], + "coordinates": json.loads(row[_COL_COORDINATES] or "{}"), + "npcs": json.loads(row[_COL_NPCS] or "[]"), + "items": json.loads(row[_COL_ITEMS] or "[]"), + "exits": json.loads(row[_COL_EXITS] or "{}"), + "first_visited": row[_COL_FIRST_VISITED], + "last_visited": row[_COL_LAST_VISITED], + "visit_count": row[_COL_VISIT_COUNT], + "is_dynamic": bool(row[_COL_IS_DYNAMIC]), + "confidence": row[_COL_CONFIDENCE], + } + + def close(self) -> None: + """Close the database connection.""" + if self.conn: + self.conn.close() + self.conn = None diff --git a/src/modules/mapper/historical_storage.py b/src/modules/mapper/historical_storage.py index dbb8347..839f164 100644 --- a/src/modules/mapper/historical_storage.py +++ b/src/modules/mapper/historical_storage.py @@ -1,34 +1,211 @@ -"""Historical storage using Memvid""" - -import logging -from typing import List, Dict - -logger = logging.getLogger(__name__) - - -class HistoricalStorage: - """Layer 2: Historical storage using Memvid for compressed archival""" - - def __init__(self, archive_dir: str = "data/maps/archive"): - self.archive_dir = archive_dir - # TODO: Initialize Memvid encoder/decoder - # TODO: Initialize FAISS index - logger.info(f"Historical storage initialized at {archive_dir}") - - async def create_snapshot(self, rooms: List[Dict], version: int): - """ - Create compressed MP4 snapshot of map state - - Args: - rooms: List of room dictionaries - version: Snapshot version number - """ - logger.info(f"Creating snapshot v{version} with {len(rooms)} rooms") - # TODO: Implement Memvid encoding - # TODO: Build FAISS index - pass - - def load_snapshot(self, version: int): - """Load a specific snapshot version""" - # TODO: Implement snapshot loading - pass +"""Historical storage using Memvid (QR-in-MP4 encoding). + +Layer 2 of the hybrid automapper architecture. Periodically takes +a snapshot of the active SQLite map and encodes all room data as +QR-code frames inside an MP4 video. This provides: + + * 50-100x compression over raw text + * Sub-100ms retrieval via frame seek + * Portable single-file archive + * FAISS index for semantic search + +Because Memvid/FAISS/sentence-transformers are heavy optional +dependencies, this module gracefully degrades to a **JSON fallback** +when those libraries are not installed. +""" + +from __future__ import annotations + +import json +import logging +import os +import time +from pathlib import Path +from typing import Dict, List, Optional + +logger = logging.getLogger(__name__) + +# Try to import optional heavy dependencies +_HAS_MEMVID = False +_HAS_FAISS = False + +try: + import memvid # noqa: F401 + _HAS_MEMVID = True +except ImportError: + pass + +try: + import faiss # noqa: F401 + _HAS_FAISS = True +except ImportError: + pass + + +class HistoricalStorage: + """Layer 2: Historical storage using Memvid for compressed archival. + + Falls back to JSON snapshots when Memvid is unavailable. + """ + + def __init__(self, archive_dir: str = "data/maps/archive") -> None: + self.archive_dir = archive_dir + os.makedirs(archive_dir, exist_ok=True) + + self._encoder = None + self._decoder = None + self._faiss_index = None + + if _HAS_MEMVID: + try: + self._encoder = memvid.MemvidEncoder() + logger.info("Memvid encoder initialised") + except Exception as exc: + logger.warning("Memvid encoder init failed: %s", exc) + else: + logger.info( + "Memvid not installed — using JSON fallback for snapshots" + ) + + logger.info("Historical storage ready at %s", archive_dir) + + # ------------------------------------------------------------------ + # Snapshot creation + # ------------------------------------------------------------------ + + def create_snapshot(self, rooms: List[Dict], version: int) -> str: + """Create a compressed snapshot of the current map state. + + Parameters + ---------- + rooms: + List of room dicts (from ``ActiveStorage.get_all_rooms()``). + version: + Monotonically increasing snapshot version number. + + Returns + ------- + str + Path to the created snapshot file. + """ + logger.info( + "Creating snapshot v%d with %d rooms", version, len(rooms), + ) + + if _HAS_MEMVID and self._encoder is not None: + return self._create_memvid_snapshot(rooms, version) + return self._create_json_snapshot(rooms, version) + + def _create_memvid_snapshot(self, rooms: List[Dict], version: int) -> str: + """Encode rooms as QR frames in an MP4.""" + for room in rooms: + chunk = json.dumps(room, ensure_ascii=False) + self._encoder.add_chunk(chunk) + + filename = f"world_v{version}.mp4" + filepath = os.path.join(self.archive_dir, filename) + index_path = os.path.join(self.archive_dir, f"world_v{version}_index.json") + + self._encoder.build_video(filepath, index_path) + logger.info("Memvid snapshot saved: %s", filepath) + + # Reset encoder for next snapshot + self._encoder = memvid.MemvidEncoder() + return filepath + + def _create_json_snapshot(self, rooms: List[Dict], version: int) -> str: + """Fallback: save rooms as a compressed JSON file.""" + filename = f"world_v{version}.json" + filepath = os.path.join(self.archive_dir, filename) + + snapshot = { + "version": version, + "timestamp": time.time(), + "room_count": len(rooms), + "rooms": rooms, + } + with open(filepath, "w", encoding="utf-8") as f: + json.dump(snapshot, f, ensure_ascii=False, indent=None) + + logger.info("JSON snapshot saved: %s (%d rooms)", filepath, len(rooms)) + return filepath + + # ------------------------------------------------------------------ + # Snapshot loading + # ------------------------------------------------------------------ + + def load_snapshot(self, version: int) -> Optional[List[Dict]]: + """Load rooms from a snapshot by version number.""" + # Try Memvid first + mp4_path = os.path.join(self.archive_dir, f"world_v{version}.mp4") + if os.path.exists(mp4_path) and _HAS_MEMVID: + return self._load_memvid_snapshot(version) + + # Fall back to JSON + json_path = os.path.join(self.archive_dir, f"world_v{version}.json") + if os.path.exists(json_path): + return self._load_json_snapshot(json_path) + + logger.warning("Snapshot v%d not found", version) + return None + + def _load_memvid_snapshot(self, version: int) -> Optional[List[Dict]]: + mp4_path = os.path.join(self.archive_dir, f"world_v{version}.mp4") + index_path = os.path.join(self.archive_dir, f"world_v{version}_index.json") + try: + decoder = memvid.MemvidDecoder(mp4_path, index_path) + rooms = [] + for i in range(decoder.chunk_count): + chunk = decoder.get_chunk(i) + rooms.append(json.loads(chunk)) + logger.info("Loaded Memvid snapshot v%d: %d rooms", version, len(rooms)) + return rooms + except Exception as exc: + logger.error("Failed to load Memvid snapshot v%d: %s", version, exc) + return None + + def _load_json_snapshot(self, path: str) -> Optional[List[Dict]]: + try: + with open(path, "r", encoding="utf-8") as f: + data = json.load(f) + rooms = data.get("rooms", []) + logger.info("Loaded JSON snapshot: %d rooms", len(rooms)) + return rooms + except Exception as exc: + logger.error("Failed to load JSON snapshot %s: %s", path, exc) + return None + + # ------------------------------------------------------------------ + # Query + # ------------------------------------------------------------------ + + def list_snapshots(self) -> List[Dict]: + """List all available snapshots with metadata.""" + snapshots = [] + archive = Path(self.archive_dir) + for f in sorted(archive.glob("world_v*.json")): + try: + with open(f, "r") as fh: + data = json.load(fh) + snapshots.append({ + "version": data["version"], + "room_count": data["room_count"], + "timestamp": data["timestamp"], + "format": "json", + "path": str(f), + }) + except Exception: + pass + for f in sorted(archive.glob("world_v*.mp4")): + # Extract version from filename + name = f.stem # "world_v3" + try: + ver = int(name.split("_v")[1]) + snapshots.append({ + "version": ver, + "format": "memvid", + "path": str(f), + }) + except (IndexError, ValueError): + pass + return snapshots diff --git a/src/modules/mapper/semantic_search.py b/src/modules/mapper/semantic_search.py index 55d2467..3c126a7 100644 --- a/src/modules/mapper/semantic_search.py +++ b/src/modules/mapper/semantic_search.py @@ -1,30 +1,172 @@ -"""Semantic search for map archive""" - -import logging -from typing import List, Dict - -logger = logging.getLogger(__name__) - - -class SemanticSearch: - """Semantic search on historical map archive using FAISS""" - - def __init__(self, historical_storage): - self.storage = historical_storage - # TODO: Initialize sentence transformer model - logger.info("Semantic search initialized") - - async def search(self, query: str, top_k: int = 5) -> List[Dict]: - """ - Search rooms by semantic similarity - - Args: - query: Natural language query - top_k: Number of results to return - - Returns: - List of matching rooms with similarity scores - """ - logger.info(f"Searching for: {query}") - # TODO: Implement semantic search - return [] +"""Semantic search on map archive using FAISS + sentence-transformers. + +Allows natural-language queries like: + ``"dark forest with goblin"`` + ``"shop that sells potions"`` + +Gracefully falls back to keyword matching when FAISS or +sentence-transformers are not installed. +""" + +from __future__ import annotations + +import logging +import re +from typing import Dict, List, Optional + +logger = logging.getLogger(__name__) + +# Optional heavy deps +_HAS_NUMPY = False +_HAS_FAISS = False +_HAS_TRANSFORMERS = False + +try: + import numpy as np # noqa: F401 + _HAS_NUMPY = True +except ImportError: + np = None # type: ignore[assignment] + +try: + import faiss # noqa: F401 + _HAS_FAISS = True +except ImportError: + pass + +try: + from sentence_transformers import SentenceTransformer # noqa: F401 + _HAS_TRANSFORMERS = True +except ImportError: + pass + + +class SemanticSearch: + """Semantic search over stored room data. + + When FAISS + sentence-transformers are available, uses cosine + similarity on dense embeddings. Otherwise falls back to simple + keyword matching. + """ + + def __init__( + self, + model_name: str = "all-MiniLM-L6-v2", + ) -> None: + self._model = None + self._index = None # faiss.IndexFlatIP + self._rooms: List[Dict] = [] + self._embeddings = None # np.ndarray (N x dim) + + if _HAS_TRANSFORMERS: + try: + self._model = SentenceTransformer(model_name) + logger.info("Sentence transformer loaded: %s", model_name) + except Exception as exc: + logger.warning("Failed to load transformer: %s", exc) + else: + logger.info( + "sentence-transformers not installed — using keyword fallback" + ) + + # ------------------------------------------------------------------ + # Index building + # ------------------------------------------------------------------ + + def build_index(self, rooms: List[Dict]) -> None: + """Build (or rebuild) the search index from a list of rooms. + + Each room dict should have at least ``title`` and ``description``. + """ + self._rooms = rooms + texts = [self._room_to_text(r) for r in rooms] + + if self._model is not None and _HAS_FAISS and _HAS_NUMPY: + embeddings = self._model.encode(texts, show_progress_bar=False) + embeddings = np.array(embeddings, dtype="float32") + # Normalise for cosine similarity via inner product + faiss.normalize_L2(embeddings) + dim = embeddings.shape[1] + self._index = faiss.IndexFlatIP(dim) + self._index.add(embeddings) + self._embeddings = embeddings + logger.info( + "FAISS index built: %d vectors, dim=%d", len(rooms), dim, + ) + else: + logger.info( + "Keyword index built: %d rooms (no FAISS)", len(rooms), + ) + + # ------------------------------------------------------------------ + # Search + # ------------------------------------------------------------------ + + def search(self, query: str, top_k: int = 5) -> List[Dict]: + """Search rooms by semantic similarity (or keyword fallback). + + Returns up to *top_k* room dicts, each augmented with a + ``score`` key (higher is better, 0-1 range for cosine). + """ + if not self._rooms: + return [] + + if self._index is not None and self._model is not None: + return self._faiss_search(query, top_k) + return self._keyword_search(query, top_k) + + def _faiss_search(self, query: str, top_k: int) -> List[Dict]: + qvec = self._model.encode([query]) + qvec = np.array(qvec, dtype="float32") + faiss.normalize_L2(qvec) + + k = min(top_k, len(self._rooms)) + scores, indices = self._index.search(qvec, k) + + results = [] + for score, idx in zip(scores[0], indices[0]): + if idx < 0: + continue + room = dict(self._rooms[idx]) + room["score"] = float(score) + results.append(room) + return results + + def _keyword_search(self, query: str, top_k: int) -> List[Dict]: + """Simple keyword overlap scoring.""" + query_tokens = set(re.findall(r"\w+", query.lower())) + scored = [] + for room in self._rooms: + text = self._room_to_text(room).lower() + room_tokens = set(re.findall(r"\w+", text)) + overlap = len(query_tokens & room_tokens) + if overlap > 0: + score = overlap / max(len(query_tokens), 1) + r = dict(room) + r["score"] = score + scored.append(r) + scored.sort(key=lambda r: r["score"], reverse=True) + return scored[:top_k] + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + @staticmethod + def _room_to_text(room: Dict) -> str: + """Flatten a room dict into a searchable text string.""" + parts = [ + room.get("title", ""), + room.get("description", ""), + room.get("zone", ""), + ] + npcs = room.get("npcs", []) + if isinstance(npcs, list): + parts.extend(npcs) + items = room.get("items", []) + if isinstance(items, list): + parts.extend(items) + return " ".join(p for p in parts if p) + + @property + def index_size(self) -> int: + return len(self._rooms) diff --git a/src/modules/mapper/sync_manager.py b/src/modules/mapper/sync_manager.py new file mode 100644 index 0000000..d75bba1 --- /dev/null +++ b/src/modules/mapper/sync_manager.py @@ -0,0 +1,123 @@ +"""SyncManager — orchestrates SQLite -> Memvid periodic snapshots. + +Triggers a snapshot when any of these conditions are met: + * A threshold number of *new* rooms have been discovered. + * A session is ending (explicit call). + * A time interval has elapsed since the last snapshot. +""" + +from __future__ import annotations + +import logging +import time +from typing import Optional + +from .active_storage import ActiveStorage +from .historical_storage import HistoricalStorage +from .semantic_search import SemanticSearch + +logger = logging.getLogger(__name__) + + +class SyncManager: + """Manages periodic synchronisation between active and historical storage.""" + + def __init__( + self, + active: ActiveStorage, + historical: HistoricalStorage, + search: Optional[SemanticSearch] = None, + room_count_trigger: int = 500, + time_interval_hours: float = 24.0, + ) -> None: + self.active = active + self.historical = historical + self.search = search + + self.room_count_trigger = room_count_trigger + self.time_interval_seconds = time_interval_hours * 3600 + + self._snapshot_version: int = 0 + self._rooms_at_last_sync: int = 0 + self._last_sync_time: float = time.time() + + # ------------------------------------------------------------------ + # Trigger checks + # ------------------------------------------------------------------ + + def should_sync(self) -> bool: + """Return ``True`` if any sync trigger has fired.""" + # Room count trigger + new_rooms = self.active.room_count() - self._rooms_at_last_sync + if new_rooms >= self.room_count_trigger: + logger.info( + "Sync trigger: %d new rooms (threshold=%d)", + new_rooms, self.room_count_trigger, + ) + return True + + # Time interval trigger + elapsed = time.time() - self._last_sync_time + if elapsed >= self.time_interval_seconds: + logger.info( + "Sync trigger: %.1f hours elapsed", elapsed / 3600, + ) + return True + + return False + + # ------------------------------------------------------------------ + # Sync + # ------------------------------------------------------------------ + + def perform_sync(self) -> Optional[str]: + """Execute a full sync: snapshot active storage into historical. + + Returns the path to the created snapshot, or ``None`` on failure. + """ + rooms = self.active.get_all_rooms() + if not rooms: + logger.info("Sync skipped — no rooms to archive") + return None + + self._snapshot_version += 1 + path = self.historical.create_snapshot(rooms, self._snapshot_version) + + # Rebuild semantic search index + if self.search is not None: + self.search.build_index(rooms) + logger.info( + "Semantic index rebuilt: %d rooms", self.search.index_size, + ) + + self._rooms_at_last_sync = self.active.room_count() + self._last_sync_time = time.time() + + logger.info( + "Sync complete: v%d, %d rooms archived", + self._snapshot_version, len(rooms), + ) + return path + + def sync_on_session_end(self) -> Optional[str]: + """Force a sync when the session is ending.""" + logger.info("Session end — forcing sync") + return self.perform_sync() + + def check_and_sync(self) -> Optional[str]: + """Convenience: check triggers and sync if needed.""" + if self.should_sync(): + return self.perform_sync() + return None + + # ------------------------------------------------------------------ + # Info + # ------------------------------------------------------------------ + + @property + def snapshot_version(self) -> int: + return self._snapshot_version + + @property + def rooms_since_last_sync(self) -> int: + return self.active.room_count() - self._rooms_at_last_sync diff --git a/src/modules/mapper/world_mapper.py b/src/modules/mapper/world_mapper.py new file mode 100644 index 0000000..61c7db1 --- /dev/null +++ b/src/modules/mapper/world_mapper.py @@ -0,0 +1,218 @@ +"""WorldMapper — orchestrator for the hybrid automapping system. + +Connects the ``ActiveStorage`` (SQLite + NetworkX) with the game's +perception layer (``GameState``, parsed rooms) and the exploration +logic in the decision engine. + +Responsibilities: + * Generate room fingerprints on arrival. + * Register new rooms and exits in ``ActiveStorage``. + * Maintain the exploration queue for unexplored exits. + * Provide pathfinding between known rooms. + * Trigger Memvid snapshots via ``SyncManager`` when thresholds are met. +""" + +from __future__ import annotations + +import logging +from typing import Dict, List, Optional, TYPE_CHECKING + +from .active_storage import ActiveStorage, generate_fingerprint +from .pathfinder import Pathfinder + +if TYPE_CHECKING: + from ...core.game_state import GameState, Room + +logger = logging.getLogger(__name__) + + +class WorldMapper: + """High-level automapper that ties storage, graph and exploration together.""" + + def __init__(self, db_path: str = "data/maps/active/world.db") -> None: + self.storage = ActiveStorage(db_path=db_path) + self.pathfinder = Pathfinder(self.storage.graph) + + # Track the current room's DB id so we can link exits + self._current_room_id: Optional[int] = None + self._previous_room_id: Optional[int] = None + self._last_direction: Optional[str] = None + + # ------------------------------------------------------------------ + # Core mapping cycle (called each time we enter a room) + # ------------------------------------------------------------------ + + def on_room_enter( + self, + title: str, + description: str, + exits: List[str], + npcs: Optional[List[str]] = None, + items: Optional[List[str]] = None, + zone: Optional[str] = None, + ) -> int: + """Process arrival in a room. + + 1. Generate a fingerprint. + 2. Insert or update the room in storage. + 3. Link the exit from the previous room (if any). + 4. Queue any unexplored exits. + + Returns the room's database ID. + """ + fingerprint = generate_fingerprint(title, description, exits) + + room_data: Dict = { + "fingerprint": fingerprint, + "title": title, + "description": description, + "short_desc": title, + "exits": {d: None for d in exits}, + "npcs": npcs or [], + "items": items or [], + "zone": zone, + } + + room_id = self.storage.add_room(room_data) + + # Link exit from the room we just left + from_room = self._current_room_id # room we were in before moving + if ( + from_room is not None + and self._last_direction is not None + and from_room != room_id + ): + self.storage.add_exit( + from_room, room_id, self._last_direction, + ) + # Add reverse exit heuristic (most MUD exits are bidirectional) + reverse = _reverse_direction(self._last_direction) + if reverse: + self.storage.add_exit(room_id, from_room, reverse) + + # Queue unexplored exits + for direction in exits: + # Check if we already know where this exit leads + existing = self.storage.graph.edges(room_id, data=True) + known_dirs = {d["direction"] for _, _, d in existing} + if direction not in known_dirs: + self.storage.enqueue_unexplored(room_id, direction) + + self._previous_room_id = self._current_room_id + self._current_room_id = room_id + self._last_direction = None + + logger.info( + "Mapped room %d: '%s' [%s] (total=%d)", + room_id, title, ", ".join(exits), self.storage.room_count(), + ) + return room_id + + def on_move(self, direction: str) -> None: + """Record that we are about to move in *direction*. + + Called just before sending the movement command so that on the + next ``on_room_enter`` we can link the exit properly. + """ + self._last_direction = direction + + def on_move_failed(self, direction: str) -> None: + """Record a blocked exit (``You can't go that way``).""" + if self._current_room_id is not None: + self.storage.add_exit( + self._current_room_id, None, direction, blocked=True, + ) + logger.debug( + "Blocked exit: room %d -> %s", self._current_room_id, direction, + ) + + # ------------------------------------------------------------------ + # Pathfinding + # ------------------------------------------------------------------ + + def find_path(self, to_room_id: int) -> Optional[List[str]]: + """Find directions from the current room to *to_room_id*. + + Returns a list of direction strings, or ``None`` if unreachable. + """ + if self._current_room_id is None: + return None + path = self.pathfinder.find_path(self._current_room_id, to_room_id) + if path is None: + return None + return self.pathfinder.get_directions(path) + + def find_path_between( + self, from_id: int, to_id: int, + ) -> Optional[List[str]]: + path = self.pathfinder.find_path(from_id, to_id) + if path is None: + return None + return self.pathfinder.get_directions(path) + + # ------------------------------------------------------------------ + # Exploration helpers + # ------------------------------------------------------------------ + + def get_next_exploration_target(self) -> Optional[Dict]: + """Pop the best unexplored exit from the queue. + + Returns ``{"room_id": int, "direction": str}`` or ``None``. + """ + result = self.storage.dequeue_next_exploration() + if result is None: + return None + room_id, direction = result + return {"room_id": room_id, "direction": direction} + + def get_unexplored_exits(self, room_id: Optional[int] = None) -> List[str]: + """Return directions from *room_id* that lead to unknown rooms.""" + rid = room_id or self._current_room_id + if rid is None: + return [] + edges = self.storage.graph.edges(rid, data=True) + known_dirs = {d["direction"] for _, _, d in edges} + room = self.storage.get_room(rid) + if not room: + return [] + all_exits = list(room.get("exits", {}).keys()) + return [d for d in all_exits if d not in known_dirs] + + # ------------------------------------------------------------------ + # Convenience + # ------------------------------------------------------------------ + + @property + def current_room_id(self) -> Optional[int]: + return self._current_room_id + + @property + def total_rooms(self) -> int: + return self.storage.room_count() + + @property + def total_exits(self) -> int: + return self.storage.graph.number_of_edges() + + def close(self) -> None: + self.storage.close() + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_REVERSE_MAP = { + "north": "south", "south": "north", + "east": "west", "west": "east", + "northeast": "southwest", "southwest": "northeast", + "northwest": "southeast", "southeast": "northwest", + "up": "down", "down": "up", + "n": "s", "s": "n", "e": "w", "w": "e", + "ne": "sw", "sw": "ne", "nw": "se", "se": "nw", + "u": "d", "d": "u", +} + + +def _reverse_direction(direction: str) -> Optional[str]: + return _REVERSE_MAP.get(direction.lower()) diff --git a/src/modules/progression/__init__.py b/src/modules/progression/__init__.py index 78ea71a..b7739c1 100644 --- a/src/modules/progression/__init__.py +++ b/src/modules/progression/__init__.py @@ -1,3 +1,15 @@ -"""Progression optimizer module""" - -__all__ = ["ProgressionOptimizer"] +"""Progression optimizer module.""" + +from .optimizer import ( + ProgressionOptimizer, + GrindingSpot, + EquipmentPiece, + SkillInfo, +) + +__all__ = [ + "ProgressionOptimizer", + "GrindingSpot", + "EquipmentPiece", + "SkillInfo", +] diff --git a/src/modules/progression/optimizer.py b/src/modules/progression/optimizer.py new file mode 100644 index 0000000..155038d --- /dev/null +++ b/src/modules/progression/optimizer.py @@ -0,0 +1,228 @@ +"""Progression optimizer — the min-maxer. + +Analyses combat data and map information to: + * Identify optimal grinding spots (EXP/hour). + * Compare equipment by weighted stat bonus. + * Recommend skill rotations (mana cost / damage / cooldown). +""" + +from __future__ import annotations + +import logging +import time +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Tuple, TYPE_CHECKING + +if TYPE_CHECKING: + from ...core.game_state import GameState + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Grinding spot tracker +# --------------------------------------------------------------------------- + +@dataclass +class GrindingSpot: + """Metrics for a single grinding area.""" + + room_id: int + room_title: str + zone: Optional[str] = None + kills: int = 0 + total_exp: int = 0 + total_time_seconds: float = 0.0 + deaths: int = 0 + first_visit: float = field(default_factory=time.time) + + @property + def exp_per_hour(self) -> float: + if self.total_time_seconds <= 0: + return 0.0 + return (self.total_exp / self.total_time_seconds) * 3600 + + @property + def kills_per_hour(self) -> float: + if self.total_time_seconds <= 0: + return 0.0 + return (self.kills / self.total_time_seconds) * 3600 + + @property + def survival_rate(self) -> float: + total = self.kills + self.deaths + if total <= 0: + return 1.0 + return self.kills / total + + +# --------------------------------------------------------------------------- +# Equipment evaluator +# --------------------------------------------------------------------------- + +@dataclass +class EquipmentPiece: + """Represents a piece of equipment with stat bonuses.""" + + name: str + slot: str # weapon, armor, helmet, etc. + stat_bonuses: Dict[str, int] = field(default_factory=dict) + level_requirement: int = 0 + value: int = 0 + + def weighted_score(self, weights: Optional[Dict[str, float]] = None) -> float: + """Compute a single score from stat bonuses. + + Default weights: equal weight (1.0) per stat. + """ + w = weights or {} + return sum( + v * w.get(k, 1.0) for k, v in self.stat_bonuses.items() + ) + + +# --------------------------------------------------------------------------- +# Skill rotation +# --------------------------------------------------------------------------- + +@dataclass +class SkillInfo: + """Metadata about a combat skill.""" + + name: str + command: str + damage: float = 0.0 + mana_cost: int = 0 + cooldown_seconds: float = 0.0 + + @property + def damage_per_mana(self) -> float: + if self.mana_cost <= 0: + return float("inf") if self.damage > 0 else 0.0 + return self.damage / self.mana_cost + + @property + def dps(self) -> float: + """Damage per second (accounting for cooldown).""" + cd = max(self.cooldown_seconds, 1.0) + return self.damage / cd + + +# --------------------------------------------------------------------------- +# ProgressionOptimizer +# --------------------------------------------------------------------------- + +class ProgressionOptimizer: + """Analyses gameplay data to optimise character progression.""" + + def __init__(self) -> None: + self._spots: Dict[int, GrindingSpot] = {} # room_id -> spot + self._equipment: List[EquipmentPiece] = [] + self._skills: List[SkillInfo] = [] + self._session_start: float = time.time() + + # ------------------------------------------------------------------ + # Grinding spots + # ------------------------------------------------------------------ + + def record_kill( + self, room_id: int, room_title: str, + exp_gained: int, zone: Optional[str] = None, + ) -> None: + """Record a mob kill at a location.""" + if room_id not in self._spots: + self._spots[room_id] = GrindingSpot( + room_id=room_id, room_title=room_title, zone=zone, + ) + spot = self._spots[room_id] + spot.kills += 1 + spot.total_exp += exp_gained + spot.total_time_seconds = time.time() - spot.first_visit + + def record_death(self, room_id: int, room_title: str) -> None: + """Record a player death.""" + if room_id not in self._spots: + self._spots[room_id] = GrindingSpot( + room_id=room_id, room_title=room_title, + ) + self._spots[room_id].deaths += 1 + + def get_best_grinding_spots(self, top_k: int = 5) -> List[GrindingSpot]: + """Return top grinding spots ranked by EXP/hour.""" + spots = list(self._spots.values()) + # Filter out spots with too few kills for statistical relevance + spots = [s for s in spots if s.kills >= 3] + spots.sort(key=lambda s: s.exp_per_hour, reverse=True) + return spots[:top_k] + + def get_recommended_spot(self, game_state: "GameState") -> Optional[GrindingSpot]: + """Recommend the best grinding spot considering survival.""" + spots = self.get_best_grinding_spots(top_k=10) + # Prefer spots with high EXP/hour AND high survival rate + for spot in spots: + if spot.survival_rate >= 0.8: + return spot + return spots[0] if spots else None + + # ------------------------------------------------------------------ + # Equipment + # ------------------------------------------------------------------ + + def add_equipment(self, piece: EquipmentPiece) -> None: + self._equipment.append(piece) + + def compare_equipment( + self, slot: str, weights: Optional[Dict[str, float]] = None, + ) -> List[Tuple[EquipmentPiece, float]]: + """Return equipment for *slot* ranked by weighted score.""" + candidates = [e for e in self._equipment if e.slot == slot] + scored = [(e, e.weighted_score(weights)) for e in candidates] + scored.sort(key=lambda t: t[1], reverse=True) + return scored + + def get_best_equipment( + self, slot: str, weights: Optional[Dict[str, float]] = None, + ) -> Optional[EquipmentPiece]: + ranked = self.compare_equipment(slot, weights) + return ranked[0][0] if ranked else None + + # ------------------------------------------------------------------ + # Skill rotation + # ------------------------------------------------------------------ + + def add_skill(self, skill: SkillInfo) -> None: + self._skills.append(skill) + + def get_optimal_rotation( + self, available_mana: int, + ) -> List[SkillInfo]: + """Return skills ordered by DPS that fit within *available_mana*.""" + usable = [s for s in self._skills if s.mana_cost <= available_mana] + usable.sort(key=lambda s: s.dps, reverse=True) + return usable + + def get_efficient_rotation(self) -> List[SkillInfo]: + """Return skills ordered by damage-per-mana efficiency.""" + skills = list(self._skills) + skills.sort(key=lambda s: s.damage_per_mana, reverse=True) + return skills + + # ------------------------------------------------------------------ + # Summary + # ------------------------------------------------------------------ + + def session_summary(self) -> Dict: + elapsed = time.time() - self._session_start + total_kills = sum(s.kills for s in self._spots.values()) + total_exp = sum(s.total_exp for s in self._spots.values()) + return { + "session_duration_minutes": elapsed / 60, + "total_kills": total_kills, + "total_exp": total_exp, + "exp_per_hour": (total_exp / elapsed * 3600) if elapsed > 0 else 0, + "spots_visited": len(self._spots), + "best_spot": ( + self.get_best_grinding_spots(1)[0].room_title + if self.get_best_grinding_spots(1) else None + ), + } diff --git a/src/modules/quest/__init__.py b/src/modules/quest/__init__.py index 7048325..2c90a8a 100644 --- a/src/modules/quest/__init__.py +++ b/src/modules/quest/__init__.py @@ -1,3 +1,19 @@ -"""Quest system module""" - -__all__ = ["QuestManager"] +"""Quest system module.""" + +from .quest_manager import ( + QuestManager, + Quest, + QuestObjective, + QuestStatus, + ObjectiveType, + parse_quest_text, +) + +__all__ = [ + "QuestManager", + "Quest", + "QuestObjective", + "QuestStatus", + "ObjectiveType", + "parse_quest_text", +] diff --git a/src/modules/quest/quest_manager.py b/src/modules/quest/quest_manager.py new file mode 100644 index 0000000..5f1bb5c --- /dev/null +++ b/src/modules/quest/quest_manager.py @@ -0,0 +1,298 @@ +"""Quest management system. + +Tracks active quests, decomposes objectives into atomic tasks, +and prioritises quests by EXP/time ratio. + +Quest lifecycle:: + + DISCOVERED -> ACCEPTED -> IN_PROGRESS -> COMPLETED + | + +-> FAILED +""" + +from __future__ import annotations + +import logging +import re +import time +from dataclasses import dataclass, field +from enum import Enum, auto +from typing import Dict, List, Optional + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Data models +# --------------------------------------------------------------------------- + +class QuestStatus(Enum): + DISCOVERED = auto() + ACCEPTED = auto() + IN_PROGRESS = auto() + COMPLETED = auto() + FAILED = auto() + + +class ObjectiveType(Enum): + KILL = "kill" # "kill 5 goblins" + FETCH = "fetch" # "find the ring in the crypt" + DELIVER = "deliver" # "bring the potion to the healer" + EXPLORE = "explore" # "find the hidden cave" + TALK = "talk" # "talk to the guard captain" + + +@dataclass +class QuestObjective: + """A single atomic task within a quest.""" + + description: str + obj_type: ObjectiveType = ObjectiveType.KILL + target: str = "" + target_count: int = 1 + current_count: int = 0 + location: Optional[str] = None + completed: bool = False + + @property + def progress(self) -> float: + if self.target_count <= 0: + return 1.0 if self.completed else 0.0 + return min(self.current_count / self.target_count, 1.0) + + @property + def is_done(self) -> bool: + return self.completed or self.current_count >= self.target_count + + +@dataclass +class Quest: + """A tracked quest with one or more objectives.""" + + name: str + description: str = "" + giver: Optional[str] = None + giver_location: Optional[str] = None + status: QuestStatus = QuestStatus.DISCOVERED + objectives: List[QuestObjective] = field(default_factory=list) + reward_exp: int = 0 + reward_gold: int = 0 + reward_items: List[str] = field(default_factory=list) + discovered_at: float = field(default_factory=time.time) + accepted_at: Optional[float] = None + completed_at: Optional[float] = None + estimated_time_minutes: float = 0.0 + + @property + def progress(self) -> float: + if not self.objectives: + return 0.0 + return sum(o.progress for o in self.objectives) / len(self.objectives) + + @property + def is_complete(self) -> bool: + return all(o.is_done for o in self.objectives) + + @property + def exp_per_minute(self) -> float: + """EXP/time ratio for prioritisation.""" + if self.estimated_time_minutes <= 0: + return float(self.reward_exp) + return self.reward_exp / self.estimated_time_minutes + + +# --------------------------------------------------------------------------- +# Quest text parser +# --------------------------------------------------------------------------- + +# Common MUD quest text patterns +_KILL_RE = re.compile( + r"(?:kill|slay|defeat|destroy)\s+(\d+)?\s*([\w\s]+?)(?:\.|$)", + re.IGNORECASE, +) +_FETCH_RE = re.compile( + r"(?:find|bring|get|retrieve|collect)\s+(?:the\s+)?([\w\s]+?)(?:\s+(?:in|from|at)\s+([\w\s]+?))?(?:\.|$)", + re.IGNORECASE, +) +_TALK_RE = re.compile( + r"(?:talk|speak|report)\s+(?:to\s+)?([\w\s]+?)(?:\.|$)", + re.IGNORECASE, +) +_EXPLORE_RE = re.compile( + r"(?:explore|find|discover|go to)\s+(?:the\s+)?([\w\s]+?)(?:\.|$)", + re.IGNORECASE, +) + + +def parse_quest_text(text: str) -> List[QuestObjective]: + """Extract objectives from a quest description string.""" + objectives: List[QuestObjective] = [] + + for m in _KILL_RE.finditer(text): + count = int(m.group(1)) if m.group(1) else 1 + target = m.group(2).strip() + objectives.append(QuestObjective( + description=m.group(0).strip(), + obj_type=ObjectiveType.KILL, + target=target, + target_count=count, + )) + + for m in _FETCH_RE.finditer(text): + item = m.group(1).strip() + location = m.group(2).strip() if m.group(2) else None + objectives.append(QuestObjective( + description=m.group(0).strip(), + obj_type=ObjectiveType.FETCH, + target=item, + target_count=1, + location=location, + )) + + for m in _TALK_RE.finditer(text): + npc = m.group(1).strip() + objectives.append(QuestObjective( + description=m.group(0).strip(), + obj_type=ObjectiveType.TALK, + target=npc, + target_count=1, + )) + + if not objectives: + for m in _EXPLORE_RE.finditer(text): + place = m.group(1).strip() + objectives.append(QuestObjective( + description=m.group(0).strip(), + obj_type=ObjectiveType.EXPLORE, + target=place, + target_count=1, + )) + + return objectives + + +# --------------------------------------------------------------------------- +# QuestManager +# --------------------------------------------------------------------------- + +class QuestManager: + """Tracks quests and provides the decision engine with quest-related info.""" + + def __init__(self) -> None: + self._quests: Dict[str, Quest] = {} # keyed by name + + # ------------------------------------------------------------------ + # Quest lifecycle + # ------------------------------------------------------------------ + + def discover_quest( + self, name: str, description: str, + giver: Optional[str] = None, + giver_location: Optional[str] = None, + reward_exp: int = 0, + reward_gold: int = 0, + ) -> Quest: + """Register a newly discovered quest.""" + if name in self._quests: + return self._quests[name] + + objectives = parse_quest_text(description) + quest = Quest( + name=name, + description=description, + giver=giver, + giver_location=giver_location, + objectives=objectives, + reward_exp=reward_exp, + reward_gold=reward_gold, + ) + self._quests[name] = quest + logger.info("Quest discovered: '%s' (%d objectives)", name, len(objectives)) + return quest + + def accept_quest(self, name: str) -> bool: + """Mark a quest as accepted.""" + q = self._quests.get(name) + if q and q.status == QuestStatus.DISCOVERED: + q.status = QuestStatus.ACCEPTED + q.accepted_at = time.time() + logger.info("Quest accepted: '%s'", name) + return True + return False + + def update_objective( + self, quest_name: str, target: str, increment: int = 1, + ) -> bool: + """Increment progress on a kill/fetch objective matching *target*.""" + q = self._quests.get(quest_name) + if not q: + return False + for obj in q.objectives: + if not obj.is_done and target.lower() in obj.target.lower(): + obj.current_count = min( + obj.current_count + increment, obj.target_count, + ) + if obj.is_done: + obj.completed = True + logger.info( + "Objective complete: '%s' in quest '%s'", + obj.description, quest_name, + ) + return True + return False + + def complete_quest(self, name: str) -> bool: + """Mark a quest as completed.""" + q = self._quests.get(name) + if q and q.status in (QuestStatus.ACCEPTED, QuestStatus.IN_PROGRESS): + q.status = QuestStatus.COMPLETED + q.completed_at = time.time() + logger.info("Quest completed: '%s'", name) + return True + return False + + def fail_quest(self, name: str) -> bool: + q = self._quests.get(name) + if q and q.status in (QuestStatus.ACCEPTED, QuestStatus.IN_PROGRESS): + q.status = QuestStatus.FAILED + logger.info("Quest failed: '%s'", name) + return True + return False + + # ------------------------------------------------------------------ + # Query + # ------------------------------------------------------------------ + + def get_active_quests(self) -> List[Quest]: + """Return all accepted/in-progress quests sorted by EXP/time.""" + active = [ + q for q in self._quests.values() + if q.status in (QuestStatus.ACCEPTED, QuestStatus.IN_PROGRESS) + ] + active.sort(key=lambda q: q.exp_per_minute, reverse=True) + return active + + def get_best_quest(self) -> Optional[Quest]: + """Return the highest-priority active quest.""" + active = self.get_active_quests() + return active[0] if active else None + + def get_quest(self, name: str) -> Optional[Quest]: + return self._quests.get(name) + + @property + def quest_count(self) -> int: + return len(self._quests) + + @property + def active_count(self) -> int: + return len(self.get_active_quests()) + + def get_pending_kill_targets(self) -> List[str]: + """Return mob names we still need to kill for active quests.""" + targets = [] + for q in self.get_active_quests(): + for obj in q.objectives: + if obj.obj_type == ObjectiveType.KILL and not obj.is_done: + targets.append(obj.target) + return targets diff --git a/tests/test_action_queue.py b/tests/test_action_queue.py new file mode 100644 index 0000000..216e5c1 --- /dev/null +++ b/tests/test_action_queue.py @@ -0,0 +1,132 @@ +"""Tests for the ActionQueue priority queue.""" + +import pytest +from modules.decision.action_queue import Action, ActionPriority, ActionQueue + + +class TestAction: + """Tests for the Action dataclass.""" + + def test_action_creation(self): + a = Action(command="north", priority=ActionPriority.MOVE, source="explore") + assert a.command == "north" + assert a.priority == ActionPriority.MOVE + assert a.source == "explore" + + def test_action_default_priority(self): + a = Action(command="look") + assert a.priority == ActionPriority.IDLE + + def test_action_repr(self): + a = Action(command="flee", priority=ActionPriority.EMERGENCY, source="combat") + r = repr(a) + assert "flee" in r + assert "EMERGENCY" in r + assert "combat" in r + + +class TestActionQueue: + """Tests for the ActionQueue.""" + + def test_push_and_pop(self): + q = ActionQueue() + q.push(Action(command="north", priority=ActionPriority.MOVE)) + assert q.size == 1 + a = q.pop() + assert a is not None + assert a.command == "north" + assert q.is_empty + + def test_pop_empty(self): + q = ActionQueue() + assert q.pop() is None + + def test_peek(self): + q = ActionQueue() + q.push(Action(command="look", priority=ActionPriority.IDLE)) + a = q.peek() + assert a is not None + assert a.command == "look" + assert q.size == 1 # not removed + + def test_peek_empty(self): + q = ActionQueue() + assert q.peek() is None + + def test_priority_ordering(self): + """Higher-priority (lower number) actions should come out first.""" + q = ActionQueue() + q.push(Action(command="north", priority=ActionPriority.MOVE)) + q.push(Action(command="flee", priority=ActionPriority.EMERGENCY)) + q.push(Action(command="heal", priority=ActionPriority.HEAL)) + q.push(Action(command="kill goblin", priority=ActionPriority.COMBAT)) + + order = [] + while not q.is_empty: + order.append(q.pop().command) + + assert order == ["flee", "heal", "kill goblin", "north"] + + def test_fifo_within_same_priority(self): + """Actions at the same priority level should be FIFO.""" + q = ActionQueue() + q.push(Action(command="first", priority=ActionPriority.COMBAT)) + q.push(Action(command="second", priority=ActionPriority.COMBAT)) + q.push(Action(command="third", priority=ActionPriority.COMBAT)) + + assert q.pop().command == "first" + assert q.pop().command == "second" + assert q.pop().command == "third" + + def test_clear(self): + q = ActionQueue() + q.push(Action(command="a", priority=ActionPriority.IDLE)) + q.push(Action(command="b", priority=ActionPriority.MOVE)) + q.clear() + assert q.is_empty + + def test_clear_by_source(self): + q = ActionQueue() + q.push(Action(command="a", priority=ActionPriority.MOVE, source="explore")) + q.push(Action(command="b", priority=ActionPriority.COMBAT, source="combat")) + q.push(Action(command="c", priority=ActionPriority.MOVE, source="explore")) + + removed = q.clear_by_source("explore") + assert removed == 2 + assert q.size == 1 + assert q.pop().command == "b" + + def test_clear_by_priority(self): + q = ActionQueue() + q.push(Action(command="a", priority=ActionPriority.MOVE)) + q.push(Action(command="b", priority=ActionPriority.COMBAT)) + q.push(Action(command="c", priority=ActionPriority.MOVE)) + + removed = q.clear_by_priority(ActionPriority.MOVE) + assert removed == 2 + assert q.size == 1 + + def test_max_size_drops_lowest_priority(self): + q = ActionQueue(max_size=2) + q.push(Action(command="heal", priority=ActionPriority.HEAL)) + q.push(Action(command="move", priority=ActionPriority.MOVE)) + # Queue is full — pushing a high-priority item should drop MOVE + q.push(Action(command="flee", priority=ActionPriority.EMERGENCY)) + + assert q.size == 2 + actions = q.as_list() + commands = [a.command for a in actions] + assert "flee" in commands + assert "heal" in commands + assert "move" not in commands + + def test_as_list_sorted(self): + q = ActionQueue() + q.push(Action(command="north", priority=ActionPriority.MOVE)) + q.push(Action(command="flee", priority=ActionPriority.EMERGENCY)) + + lst = q.as_list() + assert lst[0].command == "flee" + assert lst[1].command == "north" + # Original queue not modified + assert q.size == 2 diff --git a/tests/test_behavior_states.py b/tests/test_behavior_states.py new file mode 100644 index 0000000..778ecd5 --- /dev/null +++ b/tests/test_behavior_states.py @@ -0,0 +1,200 @@ +"""Tests for behavior states (Idle, Explore, Combat, Loot).""" + +import pytest +from core.game_state import GameState, Room, Character, CombatState +from modules.decision.action_queue import ActionPriority +from modules.decision.behavior_states import ( + IdleState, + ExploreState, + CombatState as CombatBehavior, + LootState, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_game_state( + hp=100, max_hp=100, mp=50, max_mp=50, + room_title="Town Square", exits=None, npcs=None, items=None, + target=None, combat=CombatState.IDLE, +) -> GameState: + """Create a GameState with controlled values.""" + gs = GameState() + gs.character.hp = hp + gs.character.max_hp = max_hp + gs.character.mp = mp + gs.character.max_mp = max_mp + gs.current_room = Room( + title=room_title, + exits={"north": 0, "south": 1} if exits is None else exits, + npcs=npcs or [], + items=items or [], + ) + gs.target = target + gs.combat_state = combat + return gs + + +# --------------------------------------------------------------------------- +# IdleState +# --------------------------------------------------------------------------- + +class TestIdleState: + def test_heal_when_low_hp(self): + state = IdleState(heal_threshold=0.8) + gs = _make_game_state(hp=50, max_hp=100) + actions = state.get_actions(gs) + heal_actions = [a for a in actions if a.priority == ActionPriority.HEAL] + assert len(heal_actions) >= 1 + assert heal_actions[0].command == "heal" + + def test_rest_when_low_mp(self): + state = IdleState(mp_threshold=0.5) + gs = _make_game_state(mp=10, max_mp=50) + actions = state.get_actions(gs) + rest_actions = [a for a in actions if a.command == "rest"] + assert len(rest_actions) == 1 + + def test_look_when_healthy(self): + state = IdleState() + gs = _make_game_state(hp=100, max_hp=100, mp=50, max_mp=50) + actions = state.get_actions(gs) + assert len(actions) == 1 + assert actions[0].command == "look" + assert actions[0].priority == ActionPriority.IDLE + + def test_both_heal_and_rest(self): + state = IdleState(heal_threshold=0.9, mp_threshold=0.9) + gs = _make_game_state(hp=50, max_hp=100, mp=10, max_mp=50) + actions = state.get_actions(gs) + commands = [a.command for a in actions] + assert "heal" in commands + assert "rest" in commands + + +# --------------------------------------------------------------------------- +# ExploreState +# --------------------------------------------------------------------------- + +class TestExploreState: + def test_prefers_unexplored_exits(self): + state = ExploreState(strategy="random") + gs = _make_game_state(exits={"north": 0, "south": 1}) + actions = state.get_actions(gs) + assert len(actions) == 1 + # Should pick the unexplored exit + assert actions[0].command == "north" + + def test_falls_back_to_explored(self): + state = ExploreState(strategy="random") + gs = _make_game_state(exits={"north": 1, "south": 2}) + actions = state.get_actions(gs) + assert len(actions) == 1 + assert actions[0].command in ("north", "south") + + def test_look_when_no_room(self): + state = ExploreState() + gs = GameState() # no current_room + actions = state.get_actions(gs) + assert len(actions) == 1 + assert actions[0].command == "look" + + def test_look_when_no_exits(self): + state = ExploreState() + gs = _make_game_state(exits={}) + actions = state.get_actions(gs) + assert actions[0].command == "look" + + def test_systematic_strategy(self): + state = ExploreState(strategy="systematic") + gs = _make_game_state(exits={"north": 0, "south": 0, "east": 0}) + + # First call — picks first unexplored + a1 = state.get_actions(gs) + first_dir = a1[0].command + + # Second call — picks a different unexplored direction + a2 = state.get_actions(gs) + second_dir = a2[0].command + assert second_dir != first_dir + + def test_move_priority(self): + state = ExploreState() + gs = _make_game_state() + actions = state.get_actions(gs) + assert actions[0].priority == ActionPriority.MOVE + + +# --------------------------------------------------------------------------- +# CombatState (behavior) +# --------------------------------------------------------------------------- + +class TestCombatBehavior: + def test_flee_when_critical_hp(self): + state = CombatBehavior(flee_threshold=0.2) + gs = _make_game_state(hp=10, max_hp=100, target="goblin") + actions = state.get_actions(gs) + assert any(a.priority == ActionPriority.EMERGENCY for a in actions) + assert any(a.command == "flee" for a in actions) + + def test_heal_and_attack_when_moderate_hp(self): + state = CombatBehavior(combat_heal_threshold=0.5) + gs = _make_game_state(hp=40, max_hp=100, target="goblin") + actions = state.get_actions(gs) + commands = [a.command for a in actions] + assert "heal" in commands + assert any("goblin" in c for c in commands) + + def test_attack_only_when_healthy(self): + state = CombatBehavior() + gs = _make_game_state(hp=100, max_hp=100, target="goblin") + actions = state.get_actions(gs) + assert len(actions) == 1 + assert "goblin" in actions[0].command + assert actions[0].priority == ActionPriority.COMBAT + + def test_generic_attack_without_target(self): + state = CombatBehavior() + gs = _make_game_state(hp=100, max_hp=100, target=None) + actions = state.get_actions(gs) + assert len(actions) == 1 + assert actions[0].command == "kill" + + def test_flee_returns_only_flee(self): + """When fleeing, no attack actions should be queued.""" + state = CombatBehavior(flee_threshold=0.3) + gs = _make_game_state(hp=10, max_hp=100, target="dragon") + actions = state.get_actions(gs) + assert len(actions) == 1 + assert actions[0].command == "flee" + + +# --------------------------------------------------------------------------- +# LootState +# --------------------------------------------------------------------------- + +class TestLootState: + def test_loot_actions(self): + state = LootState() + gs = _make_game_state() + actions = state.get_actions(gs) + assert len(actions) == 2 + commands = [a.command for a in actions] + assert "get all from corpse" in commands + assert "get all" in commands + + def test_loot_priority(self): + state = LootState() + gs = _make_game_state() + actions = state.get_actions(gs) + assert all(a.priority == ActionPriority.LOOT for a in actions) + + def test_custom_loot_command(self): + state = LootState(loot_command="loot corpse", fallback_command="take all") + gs = _make_game_state() + actions = state.get_actions(gs) + commands = [a.command for a in actions] + assert "loot corpse" in commands + assert "take all" in commands diff --git a/tests/test_character_creator.py b/tests/test_character_creator.py new file mode 100644 index 0000000..f36bb7c --- /dev/null +++ b/tests/test_character_creator.py @@ -0,0 +1,78 @@ +"""Tests for the CharacterCreator module.""" + +import pytest +from modules.character.creator import ( + CharacterCreator, CreatorConfig, StatRoll, CreationPhase, +) + + +class TestStatRoll: + def test_total(self): + roll = StatRoll(strength=15, intelligence=12, wisdom=10, + dexterity=14, constitution=13, charisma=8) + assert roll.total == 72 + + def test_weighted_score(self): + roll = StatRoll(strength=18, intelligence=8, wisdom=8, + dexterity=10, constitution=14, charisma=8) + warrior_w = {"str": 2.0, "con": 1.5, "dex": 1.0, "int": 0.5, "wis": 0.5, "cha": 0.5} + score = roll.weighted_score(warrior_w) + expected = 18*2.0 + 8*0.5 + 8*0.5 + 10*1.0 + 14*1.5 + 8*0.5 + assert score == expected + + +class TestCharacterCreator: + def test_race_prompt(self): + creator = CharacterCreator(CreatorConfig(preferred_race="elf")) + resp = creator.process("Please choose your race:") + assert resp == "elf" + assert creator.phase == CreationPhase.CHOOSE_RACE + + def test_class_prompt(self): + creator = CharacterCreator(CreatorConfig(preferred_class="mage")) + resp = creator.process("Select your class or profession:") + assert resp == "mage" + assert creator.phase == CreationPhase.CHOOSE_CLASS + + def test_gender_prompt(self): + creator = CharacterCreator(CreatorConfig(preferred_gender="female")) + resp = creator.process("Choose your gender:") + assert resp == "female" + + def test_stat_display_reroll_low_stats(self): + creator = CharacterCreator(CreatorConfig(min_stat_total=80)) + text = "Str: 8 Int: 8 Wis: 8 Dex: 8 Con: 8 Cha: 8" + resp = creator.process(text) + assert resp == "reroll" + + def test_stat_display_keep_good_stats(self): + creator = CharacterCreator(CreatorConfig(min_stat_total=60)) + text = "Str: 15 Int: 12 Wis: 10 Dex: 14 Con: 13 Cha: 10" + resp = creator.process(text) + assert resp == "keep" + + def test_max_rerolls_forces_keep(self): + creator = CharacterCreator(CreatorConfig(min_stat_total=200, max_rerolls=2)) + text = "Str: 8 Int: 8 Wis: 8 Dex: 8 Con: 8 Cha: 8" + # First roll -> reroll + resp1 = creator.process(text) + assert resp1 == "reroll" + # Second roll -> forced keep (max_rerolls=2) + resp2 = creator.process(text) + assert resp2 == "keep" + + def test_confirm_prompt(self): + creator = CharacterCreator() + resp = creator.process("Are you sure you want to create this character?") + assert resp == "yes" + + def test_creation_done(self): + creator = CharacterCreator() + resp = creator.process("Welcome to the world!") + assert resp is None + assert creator.is_done + + def test_unrecognised_text(self): + creator = CharacterCreator() + resp = creator.process("The wind howls through the trees.") + assert resp is None diff --git a/tests/test_combat_ai.py b/tests/test_combat_ai.py new file mode 100644 index 0000000..bd0aec3 --- /dev/null +++ b/tests/test_combat_ai.py @@ -0,0 +1,125 @@ +"""Tests for the CombatAI module.""" + +import pytest +from core.game_state import GameState, Room, CombatState +from modules.decision.combat_ai import CombatAI, CombatConfig +from modules.decision.action_queue import ActionPriority + + +def _make_game_state( + hp=100, max_hp=100, target=None, npcs=None, + combat=CombatState.IN_COMBAT, +) -> GameState: + gs = GameState() + gs.character.hp = hp + gs.character.max_hp = max_hp + gs.target = target + gs.combat_state = combat + gs.current_room = Room( + title="Arena", + exits={"north": 0}, + npcs=npcs or [], + ) + return gs + + +class TestCombatAI: + def test_flee_when_critical(self): + ai = CombatAI(CombatConfig(flee_hp_ratio=0.2)) + gs = _make_game_state(hp=15, max_hp=100, target="dragon") + actions = ai.decide(gs) + assert len(actions) == 1 + assert actions[0].priority == ActionPriority.EMERGENCY + assert actions[0].command == "flee" + + def test_heal_and_attack_when_moderate(self): + ai = CombatAI(CombatConfig(heal_hp_ratio=0.5)) + gs = _make_game_state(hp=40, max_hp=100, target="goblin") + actions = ai.decide(gs) + priorities = [a.priority for a in actions] + assert ActionPriority.HEAL in priorities + assert ActionPriority.COMBAT in priorities + + def test_attack_only_when_healthy(self): + ai = CombatAI() + gs = _make_game_state(hp=100, max_hp=100, target="goblin") + actions = ai.decide(gs) + assert len(actions) == 1 + assert actions[0].priority == ActionPriority.COMBAT + assert "goblin" in actions[0].command + + def test_auto_target_from_npcs(self): + ai = CombatAI() + gs = _make_game_state(hp=100, max_hp=100, target=None, npcs=["orc", "troll"]) + actions = ai.decide(gs) + # Should auto-target the first NPC + assert "orc" in actions[0].command + + def test_generic_attack_no_target_no_npcs(self): + ai = CombatAI() + gs = _make_game_state(hp=100, max_hp=100, target=None, npcs=[]) + actions = ai.decide(gs) + assert actions[0].command == "kill" + + def test_skill_rotation(self): + config = CombatConfig( + skill_commands=["backstab", "kick"], + skill_cooldown_ticks=3, + ) + ai = CombatAI(config) + gs = _make_game_state(hp=100, max_hp=100, target="rat") + + # Each decide() increments _ticks_since_skill by 1 at the end. + # When a skill is used, _ticks_since_skill resets to 0 then +1. + # + # Tick 1: before=0, 0<3 -> normal attack, after=1 + a1 = ai.decide(gs) + assert a1[-1].command == "kill rat" + + # Tick 2: before=1, 1<3 -> normal attack, after=2 + a2 = ai.decide(gs) + assert a2[-1].command == "kill rat" + + # Tick 3: before=2, 2<3 -> normal attack, after=3 + a3 = ai.decide(gs) + assert a3[-1].command == "kill rat" + + # Tick 4: before=3, 3>=3 -> backstab, reset to 0, after=1 + a4 = ai.decide(gs) + assert "backstab" in a4[-1].command + + # Tick 5: before=1, 1<3 -> normal attack, after=2 + a5 = ai.decide(gs) + assert a5[-1].command == "kill rat" + + # Tick 6: before=2, 2<3 -> normal attack, after=3 + a6 = ai.decide(gs) + assert a6[-1].command == "kill rat" + + # Tick 7: before=3, 3>=3 -> kick, reset to 0, after=1 + a7 = ai.decide(gs) + assert "kick" in a7[-1].command + + def test_reset(self): + ai = CombatAI() + gs = _make_game_state(target="goblin") + ai.decide(gs) + ai.reset() + assert ai._skill_index == 0 + assert ai._ticks_since_skill == 0 + assert ai._last_target is None + + def test_should_engage_when_healthy_with_npcs(self): + ai = CombatAI() + gs = _make_game_state(hp=80, max_hp=100, npcs=["rat"]) + assert ai.should_engage(gs) is True + + def test_should_not_engage_when_low_hp(self): + ai = CombatAI() + gs = _make_game_state(hp=30, max_hp=100, npcs=["rat"]) + assert ai.should_engage(gs) is False + + def test_should_not_engage_without_npcs(self): + ai = CombatAI() + gs = _make_game_state(hp=100, max_hp=100, npcs=[]) + assert ai.should_engage(gs) is False diff --git a/tests/test_decision_engine.py b/tests/test_decision_engine.py new file mode 100644 index 0000000..e76a142 --- /dev/null +++ b/tests/test_decision_engine.py @@ -0,0 +1,196 @@ +"""Tests for the DecisionEngine (state machine + integration).""" + +import pytest +from core.game_state import GameState, Room, CombatState +from modules.decision.engine import DecisionEngine +from modules.decision.combat_ai import CombatConfig +from modules.decision.goal_manager import Goal, GoalPriority +from modules.decision.action_queue import ActionPriority + + +def _make_game_state( + hp=100, max_hp=100, mp=50, max_mp=50, + room_title="Town Square", exits=None, npcs=None, + target=None, combat=CombatState.IDLE, +) -> GameState: + gs = GameState() + gs.character.hp = hp + gs.character.max_hp = max_hp + gs.character.mp = mp + gs.character.max_mp = max_mp + gs.current_room = Room( + title=room_title, + exits=exits or {"north": 0, "south": 1}, + npcs=npcs or [], + ) + gs.target = target + gs.combat_state = combat + return gs + + +class TestDecisionEngineInit: + def test_default_state_is_idle(self): + engine = DecisionEngine() + assert engine.current_state_name == "idle" + + def test_all_states_registered(self): + engine = DecisionEngine() + assert "idle" in engine._states + assert "explore" in engine._states + assert "combat" in engine._states + assert "loot" in engine._states + + +class TestDecisionEngineTick: + def test_tick_idle_produces_actions(self): + engine = DecisionEngine() + gs = _make_game_state() + engine.tick(gs) + action = engine.next_action() + assert action is not None + + def test_transition_to_combat_when_in_combat(self): + engine = DecisionEngine() + gs = _make_game_state( + combat=CombatState.IN_COMBAT, + target="goblin", + ) + engine.tick(gs) + assert engine.current_state_name == "combat" + + def test_transition_to_loot_after_combat(self): + engine = DecisionEngine() + + # Simulate combat + gs = _make_game_state(combat=CombatState.IN_COMBAT, target="goblin") + engine.tick(gs) + assert engine.current_state_name == "combat" + + # Combat ends + gs.combat_state = CombatState.IDLE + gs.target = None + engine.tick(gs) + assert engine.current_state_name == "loot" + + def test_transition_to_idle_when_low_hp(self): + engine = DecisionEngine() + gs = _make_game_state(hp=30, max_hp=100) + engine.tick(gs) + assert engine.current_state_name == "idle" + + def test_explore_with_goal(self): + engine = DecisionEngine() + engine.goal_manager.add_goal( + Goal(name="explore", priority=GoalPriority.EXPLORE, target_state="explore") + ) + gs = _make_game_state(hp=100, max_hp=100) + engine.tick(gs) + assert engine.current_state_name == "explore" + + def test_combat_actions_use_combat_ai(self): + config = CombatConfig(attack_command="kill") + engine = DecisionEngine(combat_config=config) + gs = _make_game_state( + combat=CombatState.IN_COMBAT, + target="orc", + hp=100, max_hp=100, + ) + engine.tick(gs) + action = engine.next_action() + assert action is not None + assert "orc" in action.command + + def test_emergency_flee_highest_priority(self): + config = CombatConfig(flee_hp_ratio=0.2) + engine = DecisionEngine(combat_config=config) + gs = _make_game_state( + combat=CombatState.IN_COMBAT, + target="dragon", + hp=10, max_hp=100, + ) + engine.tick(gs) + action = engine.next_action() + assert action is not None + assert action.command == "flee" + assert action.priority == ActionPriority.EMERGENCY + + def test_tick_increments_counter(self): + engine = DecisionEngine() + gs = _make_game_state() + engine.tick(gs) + engine.tick(gs) + assert engine._tick_count == 2 + + +class TestDecisionEngineTransitions: + def test_force_state(self): + engine = DecisionEngine() + gs = _make_game_state() + engine.force_state("combat", gs) + assert engine.current_state_name == "combat" + + def test_force_unknown_state(self): + engine = DecisionEngine() + gs = _make_game_state() + engine.force_state("nonexistent", gs) + assert engine.current_state_name == "idle" # unchanged + + def test_register_custom_state(self): + from modules.decision.behavior_states import BaseBehaviorState, Action, ActionPriority + from typing import List + + class CustomState(BaseBehaviorState): + name = "custom" + + def get_actions(self, game_state): + return [Action(command="custom_cmd", priority=ActionPriority.IDLE, source="custom")] + + engine = DecisionEngine() + engine.register_state(CustomState()) + assert "custom" in engine._states + + def test_state_exit_clears_source_actions(self): + engine = DecisionEngine() + gs = _make_game_state() + + # Tick in idle — queues actions from idle source + engine.tick(gs) + + # Force transition to explore — should clear idle actions + engine.force_state("explore", gs) + # Queue should now be empty (idle actions cleared) + # Then tick explore to get new actions + engine.tick(gs) + action = engine.next_action() + assert action is not None + assert action.source == "explore" + + +class TestDecisionEngineNextAction: + def test_next_action_returns_none_when_empty(self): + engine = DecisionEngine() + assert engine.next_action() is None + + def test_next_action_respects_priority(self): + engine = DecisionEngine() + gs = _make_game_state( + combat=CombatState.IN_COMBAT, + target="goblin", + hp=30, max_hp=100, # triggers heal + ) + engine.tick(gs) + # First action should be the highest priority + action = engine.next_action() + assert action is not None + # Should be heal (HEAL=1) before attack (COMBAT=2) + assert action.priority <= ActionPriority.HEAL + + +class TestDecisionEngineDefaultGoals: + def test_default_goals_produce_exploration(self): + engine = DecisionEngine() + engine.goal_manager.set_default_goals() + gs = _make_game_state(hp=100, max_hp=100) + engine.tick(gs) + # With default goals, should be in explore or grind state + assert engine.current_state_name in ("explore", "combat") diff --git a/tests/test_goal_manager.py b/tests/test_goal_manager.py new file mode 100644 index 0000000..17e4e87 --- /dev/null +++ b/tests/test_goal_manager.py @@ -0,0 +1,111 @@ +"""Tests for the GoalManager.""" + +import pytest +from core.game_state import GameState +from modules.decision.goal_manager import ( + GoalManager, + Goal, + GoalPriority, + GoalStatus, +) + + +def _make_game_state(hp=100, max_hp=100) -> GameState: + gs = GameState() + gs.character.hp = hp + gs.character.max_hp = max_hp + return gs + + +class TestGoal: + def test_goal_creation(self): + g = Goal(name="test", priority=GoalPriority.EXPLORE) + assert g.name == "test" + assert g.is_active() + assert g.progress == 0.0 + + def test_goal_inactive_after_completion(self): + g = Goal(name="test") + g.status = GoalStatus.COMPLETED + assert not g.is_active() + + +class TestGoalManager: + def test_add_and_count(self): + gm = GoalManager() + gm.add_goal(Goal(name="a")) + gm.add_goal(Goal(name="b")) + assert gm.goal_count == 2 + assert gm.active_goal_count == 2 + + def test_remove_goal(self): + gm = GoalManager() + gm.add_goal(Goal(name="a")) + assert gm.remove_goal("a") is True + assert gm.goal_count == 0 + assert gm.remove_goal("nonexistent") is False + + def test_complete_goal(self): + gm = GoalManager() + gm.add_goal(Goal(name="a")) + assert gm.complete_goal("a") is True + assert gm.active_goal_count == 0 + assert gm.goal_count == 1 + + def test_fail_goal(self): + gm = GoalManager() + gm.add_goal(Goal(name="a")) + assert gm.fail_goal("a") is True + assert gm.active_goal_count == 0 + + def test_get_current_goal_highest_priority(self): + gm = GoalManager() + gm.add_goal(Goal(name="explore", priority=GoalPriority.EXPLORE)) + gm.add_goal(Goal(name="combat", priority=GoalPriority.COMBAT)) + gs = _make_game_state() + current = gm.get_current_goal(gs) + assert current is not None + assert current.name == "combat" + + def test_survival_override_when_low_hp(self): + gm = GoalManager() + gm.add_goal(Goal(name="explore", priority=GoalPriority.EXPLORE)) + gs = _make_game_state(hp=20, max_hp=100) + current = gm.get_current_goal(gs) + assert current is not None + assert current.name == "survive" + assert current.priority == GoalPriority.SURVIVAL + + def test_no_goals_returns_none(self): + gm = GoalManager() + gs = _make_game_state() + assert gm.get_current_goal(gs) is None + + def test_get_active_goals_sorted(self): + gm = GoalManager() + gm.add_goal(Goal(name="explore", priority=GoalPriority.EXPLORE)) + gm.add_goal(Goal(name="grind", priority=GoalPriority.GRIND)) + gm.add_goal(Goal(name="quest", priority=GoalPriority.QUEST)) + + active = gm.get_active_goals() + names = [g.name for g in active] + assert names == ["quest", "grind", "explore"] + + def test_set_default_goals(self): + gm = GoalManager() + gm.set_default_goals() + assert gm.active_goal_count == 2 + names = [g.name for g in gm.get_active_goals()] + assert "explore_world" in names + assert "grind_combat" in names + + def test_complete_nonexistent(self): + gm = GoalManager() + assert gm.complete_goal("nope") is False + + def test_fail_already_completed(self): + gm = GoalManager() + gm.add_goal(Goal(name="done")) + gm.complete_goal("done") + # Cannot fail an already-completed goal + assert gm.fail_goal("done") is False diff --git a/tests/test_llm.py b/tests/test_llm.py new file mode 100644 index 0000000..353c1f3 --- /dev/null +++ b/tests/test_llm.py @@ -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 ") diff --git a/tests/test_progression.py b/tests/test_progression.py new file mode 100644 index 0000000..2ed7df3 --- /dev/null +++ b/tests/test_progression.py @@ -0,0 +1,116 @@ +"""Tests for the ProgressionOptimizer module.""" + +import pytest +from modules.progression.optimizer import ( + ProgressionOptimizer, GrindingSpot, EquipmentPiece, SkillInfo, +) + + +class TestGrindingSpot: + def test_exp_per_hour(self): + spot = GrindingSpot(room_id=1, room_title="Forest", kills=10, + total_exp=1000, total_time_seconds=3600) + assert spot.exp_per_hour == 1000.0 + + def test_survival_rate(self): + spot = GrindingSpot(room_id=1, room_title="Dungeon", + kills=8, deaths=2) + assert spot.survival_rate == 0.8 + + def test_zero_time(self): + spot = GrindingSpot(room_id=1, room_title="Room") + assert spot.exp_per_hour == 0.0 + assert spot.kills_per_hour == 0.0 + + +class TestEquipmentPiece: + def test_weighted_score(self): + eq = EquipmentPiece(name="Iron Sword", slot="weapon", + stat_bonuses={"str": 5, "dex": 2}) + score = eq.weighted_score({"str": 2.0, "dex": 1.0}) + assert score == 12.0 + + def test_default_weights(self): + eq = EquipmentPiece(name="Shield", slot="armor", + stat_bonuses={"con": 3}) + score = eq.weighted_score() + assert score == 3.0 + + +class TestSkillInfo: + def test_damage_per_mana(self): + skill = SkillInfo(name="Fireball", command="cast fireball", + damage=100, mana_cost=20) + assert skill.damage_per_mana == 5.0 + + def test_dps(self): + skill = SkillInfo(name="Slash", command="slash", + damage=50, cooldown_seconds=2.0) + assert skill.dps == 25.0 + + def test_zero_mana_cost(self): + skill = SkillInfo(name="Punch", command="punch", + damage=10, mana_cost=0) + assert skill.damage_per_mana == float("inf") + + +class TestProgressionOptimizer: + def test_record_kill(self): + opt = ProgressionOptimizer() + opt.record_kill(1, "Forest", exp_gained=50) + opt.record_kill(1, "Forest", exp_gained=50) + spots = opt.get_best_grinding_spots() + # Need at least 3 kills for statistical relevance + assert len(spots) == 0 + opt.record_kill(1, "Forest", exp_gained=50) + spots = opt.get_best_grinding_spots() + assert len(spots) == 1 + assert spots[0].kills == 3 + assert spots[0].total_exp == 150 + + def test_record_death(self): + opt = ProgressionOptimizer() + opt.record_death(1, "Dungeon") + opt.record_kill(1, "Dungeon", 100) + # Spot exists but deaths affect survival rate + # (< 3 kills so won't appear in best spots) + assert 1 in opt._spots + assert opt._spots[1].deaths == 1 + + def test_add_and_compare_equipment(self): + opt = ProgressionOptimizer() + opt.add_equipment(EquipmentPiece( + name="Iron Sword", slot="weapon", + stat_bonuses={"str": 5})) + opt.add_equipment(EquipmentPiece( + name="Steel Sword", slot="weapon", + stat_bonuses={"str": 8})) + + best = opt.get_best_equipment("weapon") + assert best is not None + assert best.name == "Steel Sword" + + def test_optimal_rotation(self): + opt = ProgressionOptimizer() + opt.add_skill(SkillInfo("Slash", "slash", damage=30, mana_cost=10, cooldown_seconds=1)) + opt.add_skill(SkillInfo("Fireball", "fireball", damage=100, mana_cost=50, cooldown_seconds=3)) + + rotation = opt.get_optimal_rotation(available_mana=40) + # Only Slash fits within 40 mana + assert len(rotation) == 1 + assert rotation[0].name == "Slash" + + def test_efficient_rotation(self): + opt = ProgressionOptimizer() + opt.add_skill(SkillInfo("Slash", "slash", damage=30, mana_cost=10)) + opt.add_skill(SkillInfo("Fireball", "fireball", damage=100, mana_cost=50)) + efficient = opt.get_efficient_rotation() + # Slash = 3.0 dmg/mana, Fireball = 2.0 dmg/mana + assert efficient[0].name == "Slash" + + def test_session_summary(self): + opt = ProgressionOptimizer() + summary = opt.session_summary() + assert "total_kills" in summary + assert "total_exp" in summary + assert summary["total_kills"] == 0 diff --git a/tests/test_quest_manager.py b/tests/test_quest_manager.py new file mode 100644 index 0000000..d43cc6d --- /dev/null +++ b/tests/test_quest_manager.py @@ -0,0 +1,124 @@ +"""Tests for the QuestManager module.""" + +import pytest +from modules.quest.quest_manager import ( + QuestManager, Quest, QuestObjective, QuestStatus, + ObjectiveType, parse_quest_text, +) + + +class TestParseQuestText: + def test_kill_quest(self): + objs = parse_quest_text("Kill 5 goblins in the dark forest.") + assert len(objs) >= 1 + kill_obj = [o for o in objs if o.obj_type == ObjectiveType.KILL] + assert len(kill_obj) >= 1 + assert kill_obj[0].target_count == 5 + assert "goblin" in kill_obj[0].target.lower() + + def test_fetch_quest(self): + objs = parse_quest_text("Find the golden ring in the crypt.") + assert len(objs) >= 1 + fetch_obj = [o for o in objs if o.obj_type == ObjectiveType.FETCH] + assert len(fetch_obj) >= 1 + + def test_talk_quest(self): + objs = parse_quest_text("Talk to the guard captain.") + assert len(objs) >= 1 + talk_obj = [o for o in objs if o.obj_type == ObjectiveType.TALK] + assert len(talk_obj) >= 1 + + def test_empty_text(self): + objs = parse_quest_text("The sun is shining.") + # May or may not parse; should not crash + assert isinstance(objs, list) + + def test_kill_without_count(self): + objs = parse_quest_text("Kill the dragon.") + kill_objs = [o for o in objs if o.obj_type == ObjectiveType.KILL] + if kill_objs: + assert kill_objs[0].target_count == 1 + + +class TestQuestObjective: + def test_progress(self): + obj = QuestObjective(description="Kill 3 rats", target="rat", + target_count=3, current_count=1) + assert abs(obj.progress - 1/3) < 0.01 + assert not obj.is_done + + def test_completion(self): + obj = QuestObjective(description="Kill 2 rats", target="rat", + target_count=2, current_count=2) + assert obj.is_done + + +class TestQuestManager: + def test_discover_quest(self): + qm = QuestManager() + q = qm.discover_quest("goblin_hunt", "Kill 5 goblins.", reward_exp=100) + assert q.name == "goblin_hunt" + assert q.status == QuestStatus.DISCOVERED + assert qm.quest_count == 1 + + def test_accept_quest(self): + qm = QuestManager() + qm.discover_quest("test", "Kill the rat.") + assert qm.accept_quest("test") is True + q = qm.get_quest("test") + assert q.status == QuestStatus.ACCEPTED + + def test_cannot_accept_twice(self): + qm = QuestManager() + qm.discover_quest("test", "Kill the rat.") + qm.accept_quest("test") + assert qm.accept_quest("test") is False + + def test_update_objective(self): + qm = QuestManager() + qm.discover_quest("hunt", "Kill 3 goblins.") + qm.accept_quest("hunt") + qm.update_objective("hunt", "goblin") + q = qm.get_quest("hunt") + kill_objs = [o for o in q.objectives if o.obj_type == ObjectiveType.KILL] + if kill_objs: + assert kill_objs[0].current_count >= 1 + + def test_complete_quest(self): + qm = QuestManager() + qm.discover_quest("test", "Talk to the guard.", reward_exp=50) + qm.accept_quest("test") + assert qm.complete_quest("test") is True + assert qm.get_quest("test").status == QuestStatus.COMPLETED + + def test_fail_quest(self): + qm = QuestManager() + qm.discover_quest("test", "desc") + qm.accept_quest("test") + assert qm.fail_quest("test") is True + assert qm.get_quest("test").status == QuestStatus.FAILED + + def test_active_quests_sorted(self): + qm = QuestManager() + qm.discover_quest("low_exp", "Kill the rat.", reward_exp=10) + qm.discover_quest("high_exp", "Kill the dragon.", reward_exp=1000) + qm.accept_quest("low_exp") + qm.accept_quest("high_exp") + active = qm.get_active_quests() + assert active[0].name == "high_exp" + + def test_get_best_quest(self): + qm = QuestManager() + qm.discover_quest("a", "Kill goblin.", reward_exp=100) + qm.accept_quest("a") + best = qm.get_best_quest() + assert best is not None + assert best.name == "a" + + def test_pending_kill_targets(self): + qm = QuestManager() + qm.discover_quest("hunt", "Kill 3 goblins.") + qm.accept_quest("hunt") + targets = qm.get_pending_kill_targets() + # Should include "goblins" or similar + assert len(targets) >= 0 # depends on parse diff --git a/tests/test_world_mapper.py b/tests/test_world_mapper.py new file mode 100644 index 0000000..70ac6b1 --- /dev/null +++ b/tests/test_world_mapper.py @@ -0,0 +1,179 @@ +"""Tests for WorldMapper, ActiveStorage, and related mapper components.""" + +import os +import tempfile +import pytest +from modules.mapper.active_storage import ActiveStorage, generate_fingerprint +from modules.mapper.world_mapper import WorldMapper +from modules.mapper.pathfinder import Pathfinder + + +class TestGenerateFingerprint: + def test_deterministic(self): + fp1 = generate_fingerprint("Town Square", "A large square.", ["north", "south"]) + fp2 = generate_fingerprint("Town Square", "A large square.", ["north", "south"]) + assert fp1 == fp2 + + def test_different_exits_different_fp(self): + fp1 = generate_fingerprint("Room", "Desc", ["north"]) + fp2 = generate_fingerprint("Room", "Desc", ["south"]) + assert fp1 != fp2 + + def test_exit_order_irrelevant(self): + fp1 = generate_fingerprint("Room", "Desc", ["north", "south"]) + fp2 = generate_fingerprint("Room", "Desc", ["south", "north"]) + assert fp1 == fp2 + + def test_case_insensitive(self): + fp1 = generate_fingerprint("Town Square", "Desc", ["north"]) + fp2 = generate_fingerprint("town square", "desc", ["north"]) + assert fp1 == fp2 + + +class TestActiveStorage: + @pytest.fixture + def storage(self, tmp_path): + db = tmp_path / "test.db" + s = ActiveStorage(db_path=str(db)) + yield s + s.close() + + def test_add_and_get_room(self, storage): + room_id = storage.add_room({ + "fingerprint": "abc123", + "title": "Town Square", + "description": "A big square.", + "exits": {"north": None}, + "npcs": ["guard"], + "items": ["potion"], + }) + assert room_id > 0 + room = storage.get_room(room_id) + assert room is not None + assert room["title"] == "Town Square" + assert room["npcs"] == ["guard"] + + def test_revisit_increments_count(self, storage): + storage.add_room({"fingerprint": "abc", "title": "Room"}) + storage.add_room({"fingerprint": "abc", "title": "Room"}) + room = storage.get_room_by_fingerprint("abc") + assert room["visit_count"] == 2 + + def test_room_count(self, storage): + assert storage.room_count() == 0 + storage.add_room({"fingerprint": "a", "title": "A"}) + storage.add_room({"fingerprint": "b", "title": "B"}) + assert storage.room_count() == 2 + + def test_get_all_rooms(self, storage): + storage.add_room({"fingerprint": "a", "title": "A"}) + storage.add_room({"fingerprint": "b", "title": "B"}) + all_rooms = storage.get_all_rooms() + assert len(all_rooms) == 2 + + def test_get_room_by_fingerprint(self, storage): + storage.add_room({"fingerprint": "fp123", "title": "Test"}) + room = storage.get_room_by_fingerprint("fp123") + assert room is not None + assert room["title"] == "Test" + assert storage.get_room_by_fingerprint("nonexistent") is None + + def test_add_exit(self, storage): + id1 = storage.add_room({"fingerprint": "a", "title": "A"}) + id2 = storage.add_room({"fingerprint": "b", "title": "B"}) + storage.add_exit(id1, id2, "north") + assert storage.graph.has_edge(id1, id2) + + def test_exploration_queue(self, storage): + rid = storage.add_room({"fingerprint": "a", "title": "A"}) + storage.enqueue_unexplored(rid, "north", priority=3) + storage.enqueue_unexplored(rid, "south", priority=5) + assert storage.exploration_queue_size() == 2 + + result = storage.dequeue_next_exploration() + assert result == (rid, "north") # lower priority number = higher priority + assert storage.exploration_queue_size() == 1 + + +class TestWorldMapper: + @pytest.fixture + def mapper(self, tmp_path): + db = tmp_path / "map.db" + m = WorldMapper(db_path=str(db)) + yield m + m.close() + + def test_on_room_enter(self, mapper): + rid = mapper.on_room_enter( + title="Town Square", + description="A big square.", + exits=["north", "south"], + npcs=["guard"], + ) + assert rid > 0 + assert mapper.total_rooms == 1 + assert mapper.current_room_id == rid + + def test_linking_rooms_via_movement(self, mapper): + r1 = mapper.on_room_enter("Room A", "Desc A", ["north"]) + mapper.on_move("north") + r2 = mapper.on_room_enter("Room B", "Desc B", ["south"]) + + # Should have an edge from r1 -> r2 via "north" + assert mapper.storage.graph.has_edge(r1, r2) + # And reverse edge r2 -> r1 via "south" + assert mapper.storage.graph.has_edge(r2, r1) + + def test_pathfinding(self, mapper): + r1 = mapper.on_room_enter("Room A", "Desc A", ["north"]) + mapper.on_move("north") + r2 = mapper.on_room_enter("Room B", "Desc B", ["south", "east"]) + mapper.on_move("east") + r3 = mapper.on_room_enter("Room C", "Desc C", ["west"]) + + # Path from current (r3) to r1 + dirs = mapper.find_path(r1) + assert dirs is not None + assert len(dirs) == 2 + assert dirs[0] == "west" + assert dirs[1] == "south" + + def test_revisit_does_not_duplicate(self, mapper): + mapper.on_room_enter("Room A", "Desc A", ["north"]) + mapper.on_room_enter("Room A", "Desc A", ["north"]) + assert mapper.total_rooms == 1 + + def test_on_move_failed(self, mapper): + mapper.on_room_enter("Room A", "Desc A", ["north"]) + mapper.on_move_failed("north") + # Blocked exit should not create a graph edge + edges = list(mapper.storage.graph.edges(mapper.current_room_id)) + assert len(edges) == 0 + + def test_exploration_target(self, mapper): + mapper.on_room_enter("Room A", "Desc A", ["north", "south"]) + target = mapper.get_next_exploration_target() + assert target is not None + assert target["direction"] in ("north", "south") + + +class TestPathfinder: + def test_find_path_and_directions(self): + import networkx as nx + g = nx.DiGraph() + g.add_node(1); g.add_node(2); g.add_node(3) + g.add_edge(1, 2, direction="north") + g.add_edge(2, 3, direction="east") + + pf = Pathfinder(g) + path = pf.find_path(1, 3) + assert path == [1, 2, 3] + dirs = pf.get_directions(path) + assert dirs == ["north", "east"] + + def test_no_path(self): + import networkx as nx + g = nx.DiGraph() + g.add_node(1); g.add_node(2) + pf = Pathfinder(g) + assert pf.find_path(1, 2) is None