Implement phases 3-4 + all modules: decision engine, automapper, character creator, quest system, progression optimizer, and hybrid LLM integration

- Phase 4 (Decision Engine): ActionQueue with 7 priority tiers,
  4 behavior states (Idle/Explore/Combat/Loot), CombatAI with skill
  rotation and flee logic, GoalManager with survival override,
  DecisionEngine state machine integrated into the main game loop

- Phase 3 (Automapper): Complete hybrid SQLite/Memvid architecture
  with WorldMapper orchestrator, room fingerprinting (SHA-256),
  bidirectional exit linking, exploration queue, A* pathfinding,
  HistoricalStorage with JSON fallback, SemanticSearch with keyword
  fallback, SyncManager with configurable triggers

- Character Creator: State machine for automated character creation
  with stat rolling/optimization per class weights, race/class/gender
  selection, reroll logic with configurable thresholds

- Quest System: Quest lifecycle tracking (discover->accept->complete),
  NLP-based objective extraction (kill/fetch/talk/explore), EXP/time
  prioritization, pending kill target tracking

- Progression Optimizer: Grinding spot finder (EXP/hour), equipment
  comparison by weighted stats, skill rotation optimizer (DPS vs
  mana efficiency), session summary statistics

- LLM Integration (hybrid): Async Ollama client with retry/backoff,
  LLMBrain coordinator for quest parsing, social AI, decision advice,
  and ambiguous text parsing fallback. SocialAI for detecting and
  responding to player messages (say/tell/shout/group/trade).
  Graceful degradation when Ollama is unavailable.

All 214 tests passing.
This commit is contained in:
truetype committed 2026-02-08 20:55:53 +01:00
1 parent f85c27a742
commit bd211b6036
34 files changed
+5479 -233

No files matched your search

+23 -6
View File
@@ -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):
+133 -2
View File
@@ -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()
+5 -3
View File
@@ -1,3 +1,5 @@
"""Character creation module"""
__all__ = ["CharacterCreator"]
"""Character creation module."""
from .creator import CharacterCreator, CreatorConfig, StatRoll, CreationPhase
__all__ = ["CharacterCreator", "CreatorConfig", "StatRoll", "CreationPhase"]
+244
View File
@@ -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"
+44 -3
View File
@@ -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",
]
+175
View File
@@ -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])
+300
View File
@@ -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
+198
View File
@@ -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)
+217
View File
@@ -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)
+182
View File
@@ -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)
+24
View File
@@ -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",
]
+252
View File
@@ -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
+340
View File
@@ -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}",
)
+153
View File
@@ -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}"
+18 -3
View File
@@ -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",
]
+318 -146
View File
@@ -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
+211 -34
View File
@@ -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
+172 -30
View File
@@ -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)
+123
View File
@@ -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
+218
View File
@@ -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())
+15 -3
View File
@@ -1,3 +1,15 @@
"""Progression optimizer module"""
__all__ = ["ProgressionOptimizer"]
"""Progression optimizer module."""
from .optimizer import (
ProgressionOptimizer,
GrindingSpot,
EquipmentPiece,
SkillInfo,
)
__all__ = [
"ProgressionOptimizer",
"GrindingSpot",
"EquipmentPiece",
"SkillInfo",
]
+228
View File
@@ -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
),
}
+19 -3
View File
@@ -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",
]
+298
View File
@@ -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
+132
View File
@@ -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
+200
View File
@@ -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
+78
View File
@@ -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
+125
View File
@@ -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
+196
View File
@@ -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")
+111
View File
@@ -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
+308
View File
@@ -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 ")
+116
View File
@@ -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
+124
View File
@@ -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
+179
View File
@@ -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