Implement phases 3-4 + all modules: decision engine, automapper, character creator, quest system, progression optimizer, and hybrid LLM integration
- Phase 4 (Decision Engine): ActionQueue with 7 priority tiers, 4 behavior states (Idle/Explore/Combat/Loot), CombatAI with skill rotation and flee logic, GoalManager with survival override, DecisionEngine state machine integrated into the main game loop - Phase 3 (Automapper): Complete hybrid SQLite/Memvid architecture with WorldMapper orchestrator, room fingerprinting (SHA-256), bidirectional exit linking, exploration queue, A* pathfinding, HistoricalStorage with JSON fallback, SemanticSearch with keyword fallback, SyncManager with configurable triggers - Character Creator: State machine for automated character creation with stat rolling/optimization per class weights, race/class/gender selection, reroll logic with configurable thresholds - Quest System: Quest lifecycle tracking (discover->accept->complete), NLP-based objective extraction (kill/fetch/talk/explore), EXP/time prioritization, pending kill target tracking - Progression Optimizer: Grinding spot finder (EXP/hour), equipment comparison by weighted stats, skill rotation optimizer (DPS vs mana efficiency), session summary statistics - LLM Integration (hybrid): Async Ollama client with retry/backoff, LLMBrain coordinator for quest parsing, social AI, decision advice, and ambiguous text parsing fallback. SocialAI for detecting and responding to player messages (say/tell/shout/group/trade). Graceful degradation when Ollama is unavailable. All 214 tests passing.
This commit is contained in:
1 parent
f85c27a742
commit
bd211b6036
34 files changed
+5479
-233
No files matched your search
+23
-6
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"""Character creation module"""
|
||||
|
||||
__all__ = ["CharacterCreator"]
|
||||
"""Character creation module."""
|
||||
|
||||
from .creator import CharacterCreator, CreatorConfig, StatRoll, CreationPhase
|
||||
|
||||
__all__ = ["CharacterCreator", "CreatorConfig", "StatRoll", "CreationPhase"]
|
||||
@@ -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"
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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])
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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}",
|
||||
)
|
||||
@@ -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}"
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -1,3 +1,15 @@
|
||||
"""Progression optimizer module"""
|
||||
|
||||
__all__ = ["ProgressionOptimizer"]
|
||||
"""Progression optimizer module."""
|
||||
|
||||
from .optimizer import (
|
||||
ProgressionOptimizer,
|
||||
GrindingSpot,
|
||||
EquipmentPiece,
|
||||
SkillInfo,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ProgressionOptimizer",
|
||||
"GrindingSpot",
|
||||
"EquipmentPiece",
|
||||
"SkillInfo",
|
||||
]
|
||||
@@ -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
|
||||
),
|
||||
}
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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 ")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user