AI Integration Completion Design Document¶
Overview¶
This document specifies the design and implementation plan for completing MAID's AI integration to achieve working NPC dialogue functionality comparable to Evennia's LLMNPC contrib.
Goal: Transform MAID's existing AI framework (providers, context builders, streaming) into a functional AI-powered NPC dialogue system with the talk <npc> <message> command working end-to-end.
1. Current State Analysis¶
1.1 What Exists in MAID's AI Framework¶
MAID has a solid AI infrastructure foundation:
Provider Layer (packages/maid-engine/src/maid_engine/ai/providers/)¶
- LLMProvider abstract base class with:
complete(messages, options)→CompletionResultcomplete_streaming(messages, options)→AsyncIterator[str]get_available_models()→list[str]is_available()→bool- Three Production Providers:
AnthropicProvider- Claude models (claude-sonnet-4, claude-opus-4, claude-3-5-haiku)OpenAIProvider- GPT models (gpt-4o, gpt-4-turbo, gpt-3.5-turbo)OllamaProvider- Local inference (llama3.2, mistral, etc.)- MockProvider for testing
- Message/CompletionOptions/CompletionResult dataclasses
Registry System (packages/maid-engine/src/maid_engine/ai/registry.py)¶
- LLMProviderRegistry - Manages multiple providers with default selection
- create_registry_from_settings() - Auto-configures from MAID settings
- Global registry singleton pattern
Context Builders (packages/maid-engine/src/maid_engine/ai/context.py)¶
- WorldContext - Game world state (room/area counts, player/NPC counts, time, weather)
- EntityContext - Character info (name, health, level, class, race, inventory)
- LocationContext - Room details (name, description, exits, characters, items)
- ConversationContext - Dialogue tracking with:
- NPC identity (id, name, personality, role)
- Player identity
- Message history
to_system_prompt()andto_conversation_prompt()methods- build_full_context() - Combines all contexts
NPC Dialogue Models (packages/maid-classic-rpg/src/maid_classic_rpg/models/npc/behavior.py)¶
- NPCDialogue - Static dialogue config (greeting, farewell, lines, default_response)
- DialogueLine - Trigger/response pairs with conditions and actions
- NPCState - Runtime state with dialogue_cooldown, current_speaker_id
1.2 What's Missing for a Working System¶
| Component | Status | Gap |
|---|---|---|
talk command |
Stub exists | Returns "NPC dialogue not yet implemented" |
| NPCDialogueSystem | Not implemented | No ECS system to process dialogue |
| DialogueComponent | Not implemented | No component for AI-enabled NPCs |
| ConversationManager | Not implemented | No conversation state persistence |
| PromptBuilder | Partial | ConversationContext.to_system_prompt() is basic |
| Response streaming | Provider supports | Not wired to player output |
| Conversation memory | Not implemented | Messages not persisted between sessions |
| Rate limiting | Not implemented | No protection against API abuse |
1.3 Comparison with Evennia's LLMNPC¶
| Feature | Evennia LLMNPC | MAID Current |
|---|---|---|
| Talk command | talk npc <message> works |
Stub only |
| Provider support | OpenAI/Anthropic | Anthropic/OpenAI/Ollama ✓ |
| Context building | Manual prompt construction | 4 context builders ✓ |
| Streaming | Not supported | Supported in providers ✓ |
| Conversation history | Basic list | ConversationContext exists |
| Personality | Per-NPC prompt | NPCDialogue.npc_personality exists |
| Integration | Typeclass-based | Needs ECS integration |
Bottom line: MAID has better infrastructure but no integration. Evennia has simpler infrastructure but working integration.
2. Feature Requirements¶
2.1 Core Features (MVP)¶
- AI-Powered NPC Dialogue
- Players can talk to NPCs using natural language
- NPCs respond contextually based on personality and world state
-
Responses feel appropriate to the game world
-
Context-Aware Conversations
- NPCs know where they are (room, area)
- NPCs know who they're talking to (player name, level, class)
-
NPCs know the current game state (time of day, weather, events)
-
Conversation History
- Track recent messages within a conversation
- Maintain context across multiple exchanges
-
Configurable history depth (default: last 10 messages)
-
Character Personality Persistence
- Each NPC has defined personality traits
- Personality influences response style and content
-
Personalities defined in NPC data, not hardcoded
-
Multi-Provider Support
- Use existing LLMProviderRegistry
- Global default provider from settings
- Per-NPC provider override (optional)
2.2 Enhanced Features (Post-MVP)¶
- Streaming Responses - Send response chunks to player as generated
- Hybrid Dialogue - Fall back to scripted responses for keywords
- Emotion/Mood System - NPC responses affected by emotional state
- Memory Persistence - Remember conversations across sessions
- Knowledge Bases - NPCs can have specific knowledge sets
- Dialogue Actions - NPCs can trigger game actions from conversation
3. Technical Design¶
3.1 Component Architecture¶
┌─────────────────────────────────────────────────────────────────┐
│ Player Input │
│ "talk bartender hello" │
└─────────────────────────┬───────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ TalkCommand Handler │
│ - Parse target NPC and message │
│ - Find NPC entity in room │
│ - Dispatch to NPCDialogueSystem │
└─────────────────────────┬───────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ NPCDialogueSystem │
│ - Check if NPC has DialogueComponent │
│ - Get/create ConversationManager for player-NPC pair │
│ - Build full context (world, entity, location, conversation) │
│ - Call PromptBuilder to construct messages │
│ - Send to LLM provider │
│ - Stream response back to player │
└─────────────────────────┬───────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ LLMProviderRegistry │
│ - Get configured provider (default or NPC-specific) │
│ - complete_streaming() for response generation │
└─────────────────────────┬───────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Player Output │
│ "The bartender looks up from polishing a glass..." │
└─────────────────────────────────────────────────────────────────┘
3.2 DialogueComponent¶
Location: packages/maid-stdlib/src/maid_stdlib/components/dialogue.py
from uuid import UUID
from maid_engine.core.ecs import Component
from pydantic import Field
class DialogueComponent(Component):
"""Component enabling AI-powered dialogue for an entity.
Attach to NPCs to enable the `talk` command with AI responses.
"""
component_type = "DialogueComponent"
# AI Configuration
ai_enabled: bool = Field(default=True, description="Whether AI dialogue is enabled")
provider_name: str | None = Field(default=None, description="Override default provider")
model_name: str | None = Field(default=None, description="Override default model")
# Personality & Character
personality: str = Field(
default="a helpful NPC",
description="Personality description for AI system prompt"
)
speaking_style: str = Field(
default="conversational",
description="How the NPC speaks (formal, casual, archaic, etc.)"
)
knowledge_domains: list[str] = Field(
default_factory=list,
description="Topics this NPC knows about (e.g., ['blacksmithing', 'local_history'])"
)
secret_knowledge: list[str] = Field(
default_factory=list,
description="Knowledge revealed only under certain conditions"
)
# Role & Function
npc_role: str = Field(
default="citizen",
description="NPC's functional role (merchant, guard, quest_giver, etc.)"
)
faction: str | None = Field(default=None, description="NPC's faction affiliation")
# Behavioral Constraints
will_discuss: list[str] = Field(
default_factory=list,
description="Topics NPC will discuss freely"
)
wont_discuss: list[str] = Field(
default_factory=list,
description="Topics NPC refuses to discuss"
)
# Response Settings
max_response_tokens: int = Field(default=150, ge=50, le=500)
temperature: float = Field(default=0.7, ge=0.0, le=1.0)
# Fallback Dialogue (for when AI is unavailable)
greeting: str = Field(default="Hello, traveler.")
farewell: str = Field(default="Safe travels.")
fallback_response: str = Field(default="I don't have anything to say about that.")
# Rate Limiting
cooldown_seconds: float = Field(default=0.0, description="Min time between responses")
last_response_time: float = Field(default=0.0)
3.3 ConversationManager¶
Location: packages/maid-engine/src/maid_engine/ai/conversation.py
from dataclasses import dataclass, field
from datetime import datetime
from uuid import UUID
from typing import Any
from maid_stdlib.ai.context import ConversationContext
@dataclass
class ConversationMessage:
"""A single message in a conversation."""
role: str # "player" or "npc"
content: str
timestamp: datetime = field(default_factory=datetime.now)
metadata: dict[str, Any] = field(default_factory=dict)
@dataclass
class Conversation:
"""Active conversation between a player and NPC."""
player_id: UUID
npc_id: UUID
messages: list[ConversationMessage] = field(default_factory=list)
started_at: datetime = field(default_factory=datetime.now)
last_activity: datetime = field(default_factory=datetime.now)
context_data: dict[str, Any] = field(default_factory=dict)
def add_message(self, role: str, content: str) -> None:
"""Add a message to the conversation."""
self.messages.append(ConversationMessage(role=role, content=content))
self.last_activity = datetime.now()
def get_recent_messages(self, count: int = 10) -> list[ConversationMessage]:
"""Get the most recent messages."""
return self.messages[-count:]
def to_conversation_context(self, npc_name: str, npc_personality: str,
npc_role: str, player_name: str) -> ConversationContext:
"""Convert to ConversationContext for prompt building."""
ctx = ConversationContext(
npc_id=self.npc_id,
npc_name=npc_name,
npc_personality=npc_personality,
npc_role=npc_role,
player_id=self.player_id,
player_name=player_name,
context_data=self.context_data,
)
for msg in self.get_recent_messages():
ctx.add_message(msg.role, msg.content)
return ctx
def is_stale(self, timeout_minutes: int = 30) -> bool:
"""Check if conversation has timed out."""
elapsed = (datetime.now() - self.last_activity).total_seconds()
return elapsed > (timeout_minutes * 60)
class ConversationManager:
"""Manages active conversations between players and NPCs.
Handles conversation lifecycle, history tracking, and cleanup.
"""
def __init__(self, max_history: int = 10, timeout_minutes: int = 30):
self._conversations: dict[tuple[UUID, UUID], Conversation] = {}
self._max_history = max_history
self._timeout_minutes = timeout_minutes
def _key(self, player_id: UUID, npc_id: UUID) -> tuple[UUID, UUID]:
"""Generate conversation key."""
return (player_id, npc_id)
def get_or_create(self, player_id: UUID, npc_id: UUID) -> Conversation:
"""Get existing conversation or create new one."""
key = self._key(player_id, npc_id)
if key in self._conversations:
conv = self._conversations[key]
if conv.is_stale(self._timeout_minutes):
# Start fresh conversation
conv = Conversation(player_id=player_id, npc_id=npc_id)
self._conversations[key] = conv
return conv
conv = Conversation(player_id=player_id, npc_id=npc_id)
self._conversations[key] = conv
return conv
def get(self, player_id: UUID, npc_id: UUID) -> Conversation | None:
"""Get existing conversation if it exists and isn't stale."""
key = self._key(player_id, npc_id)
conv = self._conversations.get(key)
if conv and not conv.is_stale(self._timeout_minutes):
return conv
return None
def end_conversation(self, player_id: UUID, npc_id: UUID) -> None:
"""End a conversation."""
key = self._key(player_id, npc_id)
self._conversations.pop(key, None)
def cleanup_stale(self) -> int:
"""Remove stale conversations. Returns count removed."""
stale_keys = [
key for key, conv in self._conversations.items()
if conv.is_stale(self._timeout_minutes)
]
for key in stale_keys:
del self._conversations[key]
return len(stale_keys)
def get_player_conversations(self, player_id: UUID) -> list[Conversation]:
"""Get all active conversations for a player."""
return [
conv for (pid, _), conv in self._conversations.items()
if pid == player_id and not conv.is_stale(self._timeout_minutes)
]
3.4 PromptBuilder¶
Location: packages/maid-engine/src/maid_engine/ai/prompts.py
from dataclasses import dataclass
from typing import Any
from maid_stdlib.ai.context import (
WorldContext, EntityContext, LocationContext, ConversationContext
)
from maid_engine.ai.providers.base import Message
@dataclass
class NPCPromptConfig:
"""Configuration for NPC prompt generation."""
# Base behavior
include_world_context: bool = True
include_location_context: bool = True
include_player_context: bool = True
max_context_tokens: int = 500
# Safety & guardrails
enforce_character: bool = True # Stay in character
block_meta_discussion: bool = True # Don't discuss being an AI
block_harmful_content: bool = True
allowed_actions: list[str] | None = None # If set, limit action triggers
class PromptBuilder:
"""Builds prompts for NPC dialogue from game context.
Constructs system prompts and user messages that provide
NPCs with appropriate context while enforcing safety guardrails.
"""
# Base system prompt template
SYSTEM_TEMPLATE = '''You are {npc_name}, {personality} in a {game_type} MUD (Multi-User Dungeon) game.
ROLE: {role}
SPEAKING STYLE: {speaking_style}
{world_context}
{location_context}
{player_context}
BEHAVIORAL GUIDELINES:
- Stay completely in character as {npc_name}
- Respond naturally as this character would
- Keep responses concise (1-3 sentences typically)
- Use appropriate speech patterns for your character
- React to the player's tone and approach
{knowledge_guidelines}
{restriction_guidelines}
CRITICAL RULES:
- NEVER break character or acknowledge being an AI/language model
- NEVER discuss game mechanics, code, or out-of-character topics
- NEVER generate harmful, explicit, or inappropriate content
- If asked about restricted topics, redirect naturally in character
{custom_instructions}'''
WORLD_CONTEXT_TEMPLATE = '''
WORLD STATE:
- Time: {time}
- Weather: {weather}
- Active events: {events}'''
LOCATION_CONTEXT_TEMPLATE = '''
CURRENT LOCATION: {room_name}
{room_description}
Exits: {exits}
Others present: {characters}'''
PLAYER_CONTEXT_TEMPLATE = '''
SPEAKING TO: {player_name}
- Level {level} {race} {class_type}
- Reputation with you: {reputation}'''
def __init__(self, config: NPCPromptConfig | None = None):
self.config = config or NPCPromptConfig()
def build_system_prompt(
self,
npc_name: str,
personality: str,
role: str,
speaking_style: str,
knowledge_domains: list[str] | None = None,
wont_discuss: list[str] | None = None,
world_ctx: WorldContext | None = None,
location_ctx: LocationContext | None = None,
player_ctx: EntityContext | None = None,
custom_instructions: str = "",
) -> str:
"""Build the system prompt for NPC dialogue."""
# World context section
world_context = ""
if self.config.include_world_context and world_ctx:
world_context = self.WORLD_CONTEXT_TEMPLATE.format(
time=world_ctx.current_time or "unknown",
weather=world_ctx.current_weather or "clear",
events=", ".join(world_ctx.active_events) if world_ctx.active_events else "none"
)
# Location context section
location_context = ""
if self.config.include_location_context and location_ctx:
location_context = self.LOCATION_CONTEXT_TEMPLATE.format(
room_name=location_ctx.name or "an unknown place",
room_description=location_ctx.description or "",
exits=", ".join(location_ctx.exits) if location_ctx.exits else "none visible",
characters=", ".join(location_ctx.characters_present) if location_ctx.characters_present else "no one else"
)
# Player context section
player_context = ""
if self.config.include_player_context and player_ctx:
player_context = self.PLAYER_CONTEXT_TEMPLATE.format(
player_name=player_ctx.name or "stranger",
level=player_ctx.level or 1,
race=player_ctx.race or "unknown",
class_type=player_ctx.class_type or "adventurer",
reputation="neutral" # TODO: Implement reputation system
)
# Knowledge guidelines
knowledge_guidelines = ""
if knowledge_domains:
knowledge_guidelines = f"\nYou have expert knowledge in: {', '.join(knowledge_domains)}"
# Restriction guidelines
restriction_guidelines = ""
if wont_discuss:
restriction_guidelines = f"\nYou refuse to discuss: {', '.join(wont_discuss)}"
return self.SYSTEM_TEMPLATE.format(
npc_name=npc_name,
personality=personality,
role=role,
speaking_style=speaking_style,
world_context=world_context,
location_context=location_context,
player_context=player_context,
knowledge_guidelines=knowledge_guidelines,
restriction_guidelines=restriction_guidelines,
custom_instructions=custom_instructions,
)
def build_messages(
self,
system_prompt: str,
conversation_ctx: ConversationContext,
new_player_message: str,
) -> list[Message]:
"""Build the full message list for API call."""
messages = [Message.system(system_prompt)]
# Add conversation history
for msg in conversation_ctx.get_recent_messages():
if msg["role"] == "player":
messages.append(Message.user(msg["content"]))
else:
messages.append(Message.assistant(msg["content"]))
# Add new player message
messages.append(Message.user(new_player_message))
return messages
def build_for_npc(
self,
dialogue_component: Any, # DialogueComponent
world_ctx: WorldContext | None,
location_ctx: LocationContext | None,
player_ctx: EntityContext | None,
conversation_ctx: ConversationContext,
new_player_message: str,
npc_name: str,
) -> list[Message]:
"""Convenience method to build messages from a DialogueComponent."""
system_prompt = self.build_system_prompt(
npc_name=npc_name,
personality=dialogue_component.personality,
role=dialogue_component.npc_role,
speaking_style=dialogue_component.speaking_style,
knowledge_domains=dialogue_component.knowledge_domains,
wont_discuss=dialogue_component.wont_discuss,
world_ctx=world_ctx,
location_ctx=location_ctx,
player_ctx=player_ctx,
)
return self.build_messages(system_prompt, conversation_ctx, new_player_message)
3.5 NPCDialogueSystem¶
Location: packages/maid-classic-rpg/src/maid_classic_rpg/systems/npc/dialogue.py
import asyncio
import time
from typing import TYPE_CHECKING
from uuid import UUID
from maid_engine.core.ecs import System
from maid_engine.ai.registry import get_registry
from maid_stdlib.ai.context import (
WorldContext, EntityContext, LocationContext, ConversationContext
)
from maid_engine.ai.conversation import ConversationManager
from maid_stdlib.ai.npc_prompts import NPCPromptConfig
from maid_engine.ai.prompts import PromptBuilder
from maid_engine.ai.providers.base import CompletionOptions
if TYPE_CHECKING:
from maid_engine.core.world import World
from maid_engine.core.ecs import Entity
class NPCDialogueSystem(System):
"""ECS System for handling AI-powered NPC dialogue.
Manages conversations between players and NPCs, building context,
generating responses via LLM providers, and streaming to players.
"""
priority = 50 # Mid-priority system
def __init__(self, world: "World"):
super().__init__(world)
self._conversation_manager = ConversationManager(
max_history=10,
timeout_minutes=30
)
self._prompt_builder = PromptBuilder(NPCPromptConfig())
self._pending_responses: dict[UUID, asyncio.Task] = {}
async def startup(self) -> None:
"""Initialize dialogue system."""
# Subscribe to events if needed
pass
async def shutdown(self) -> None:
"""Cleanup dialogue system."""
# Cancel pending responses
for task in self._pending_responses.values():
task.cancel()
self._pending_responses.clear()
async def update(self, delta: float) -> None:
"""Periodic update for conversation maintenance."""
# Cleanup stale conversations periodically
self._conversation_manager.cleanup_stale()
async def process_dialogue(
self,
player_entity: "Entity",
npc_entity: "Entity",
player_message: str,
session: Any, # PlayerSession
) -> None:
"""Process player dialogue with an NPC.
Args:
player_entity: The player's entity
npc_entity: The NPC's entity
player_message: What the player said
session: Player's session for output
"""
# Get DialogueComponent
dialogue_comp = self._get_dialogue_component(npc_entity)
if not dialogue_comp:
await session.send_line("That creature doesn't seem interested in talking.")
return
# Check if AI is enabled
if not dialogue_comp.ai_enabled:
await session.send_line(dialogue_comp.fallback_response)
return
# Check cooldown
if not self._check_cooldown(dialogue_comp):
await session.send_line("(They look distant as if thinking...)")
return
# Get names
npc_name = self._get_entity_name(npc_entity) or "Someone"
player_name = self._get_entity_name(player_entity) or "Stranger"
# Get or create conversation
conversation = self._conversation_manager.get_or_create(
player_entity.id, npc_entity.id
)
# Build contexts
world_ctx = WorldContext.build(self.world)
location_ctx = self._build_location_context(player_entity)
player_ctx = EntityContext.build(player_entity)
conversation_ctx = conversation.to_conversation_context(
npc_name=npc_name,
npc_personality=dialogue_comp.personality,
npc_role=dialogue_comp.npc_role,
player_name=player_name,
)
# Build messages
messages = self._prompt_builder.build_for_npc(
dialogue_component=dialogue_comp,
world_ctx=world_ctx,
location_ctx=location_ctx,
player_ctx=player_ctx,
conversation_ctx=conversation_ctx,
new_player_message=player_message,
npc_name=npc_name,
)
# Get LLM provider
registry = get_registry()
provider_name = dialogue_comp.provider_name
try:
provider = registry.get(provider_name)
except ValueError:
await session.send_line(dialogue_comp.fallback_response)
return
# Build completion options
options = CompletionOptions(
model=dialogue_comp.model_name,
max_tokens=dialogue_comp.max_response_tokens,
temperature=dialogue_comp.temperature,
)
# Generate response with streaming
try:
await session.send(f"{npc_name} says, \"")
full_response = ""
async for chunk in provider.complete_streaming(messages, options):
full_response += chunk
await session.send(chunk)
await session.send_line("\"")
# Record in conversation history
conversation.add_message("player", player_message)
conversation.add_message("npc", full_response)
# Update cooldown
dialogue_comp.last_response_time = time.time()
except Exception as e:
# Log error and fall back
await session.send_line(f"\"{dialogue_comp.fallback_response}\"")
def _get_dialogue_component(self, entity: "Entity") -> Any | None:
"""Get DialogueComponent from entity by name lookup."""
for comp in entity.components:
if type(comp).__name__ == "DialogueComponent":
return comp
return None
def _get_entity_name(self, entity: "Entity") -> str | None:
"""Get entity's display name."""
for comp in entity.components:
if type(comp).__name__ == "DescriptionComponent":
return getattr(comp, "name", None)
return None
def _build_location_context(self, entity: "Entity") -> LocationContext | None:
"""Build location context for an entity's current room."""
# Get position component
for comp in entity.components:
if type(comp).__name__ == "PositionComponent":
room_id = getattr(comp, "room_id", None)
if room_id:
return LocationContext.build(self.world, room_id)
return None
def _check_cooldown(self, dialogue_comp: Any) -> bool:
"""Check if NPC is off cooldown."""
if dialogue_comp.cooldown_seconds <= 0:
return True
elapsed = time.time() - dialogue_comp.last_response_time
return elapsed >= dialogue_comp.cooldown_seconds
def find_npc_in_room(
self,
room_id: UUID,
keyword: str
) -> "Entity | None":
"""Find an NPC in a room by keyword match.
Args:
room_id: Room to search
keyword: Search term to match against NPC names
Returns:
Matching NPC entity or None
"""
keyword_lower = keyword.lower()
for entity in self.world.entities_in_room(room_id):
# Check if has DialogueComponent (is a talkable NPC)
dialogue_comp = self._get_dialogue_component(entity)
if not dialogue_comp:
continue
# Check name match
name = self._get_entity_name(entity)
if name and keyword_lower in name.lower():
return entity
return None
4. Command Design¶
4.1 talk <npc> <message> Command¶
Location: Update existing packages/maid-classic-rpg/src/maid_classic_rpg/commands/handlers/economy.py or create new dialogue.py
@command(
"talk",
category=CommandCategory.SOCIAL,
aliases=["tell"],
help_text="Talk to an NPC. Use quotes for multi-word messages.",
usage="talk <npc> <message>",
examples=[
"talk bartender hello",
"talk guard what's happening in town?",
'talk merchant "I want to buy something"',
],
)
async def cmd_talk(ctx: CommandContext) -> None:
"""Talk to an NPC using AI-generated dialogue."""
if not ctx.target:
await ctx.session.send_line("Talk to whom? Usage: talk <npc> <message>")
return
# Parse target and message
parts = ctx.target.split(maxsplit=1)
if len(parts) < 2:
await ctx.session.send_line("What do you want to say? Usage: talk <npc> <message>")
return
npc_keyword = parts[0]
message = parts[1].strip('"\'') # Remove quotes if present
# Get character and location
character = await get_character(ctx)
if not character or not character.room_id:
await ctx.session.send_line("Error: Cannot determine your location.")
return
# Get player entity
player_entity = ctx.world.get_entity(character.entity_id) if ctx.world else None
if not player_entity:
await ctx.session.send_line("Error: Character state not found.")
return
# Get dialogue system
dialogue_system = ctx.world.systems.get(NPCDialogueSystem) if ctx.world else None
if not dialogue_system:
await ctx.session.send_line("The dialogue system is not available.")
return
# Find NPC
npc_entity = dialogue_system.find_npc_in_room(character.room_id, npc_keyword)
if not npc_entity:
await ctx.session.send_line(f"You don't see '{npc_keyword}' here to talk to.")
return
# Process dialogue
await dialogue_system.process_dialogue(
player_entity=player_entity,
npc_entity=npc_entity,
player_message=message,
session=ctx.session,
)
4.2 ask <npc> about <topic> Command¶
@command(
"ask",
category=CommandCategory.SOCIAL,
help_text="Ask an NPC about a specific topic.",
usage="ask <npc> about <topic>",
examples=[
"ask bartender about the local news",
"ask guard about the missing merchant",
"ask sage about ancient magic",
],
)
async def cmd_ask(ctx: CommandContext) -> None:
"""Ask an NPC about a topic - reformats as a question for talk."""
if not ctx.target:
await ctx.session.send_line("Ask whom? Usage: ask <npc> about <topic>")
return
# Parse "npc about topic" format
if " about " not in ctx.target.lower():
await ctx.session.send_line("Usage: ask <npc> about <topic>")
return
parts = ctx.target.lower().split(" about ", 1)
npc_keyword = parts[0].strip()
topic = parts[1].strip()
# Reformat as a question
question = f"What can you tell me about {topic}?"
# Delegate to talk command logic
ctx.target = f"{npc_keyword} {question}"
await cmd_talk(ctx)
4.3 Conversation Context Commands¶
@command(
"conversations",
category=CommandCategory.INFORMATION,
aliases=["convos"],
help_text="List your active conversations with NPCs.",
usage="conversations",
)
async def cmd_conversations(ctx: CommandContext) -> None:
"""Show active conversations."""
character = await get_character(ctx)
if not character:
await ctx.session.send_line("Error: Character not found.")
return
dialogue_system = ctx.world.systems.get(NPCDialogueSystem) if ctx.world else None
if not dialogue_system:
await ctx.session.send_line("No active conversations.")
return
conversations = dialogue_system._conversation_manager.get_player_conversations(
character.entity_id
)
if not conversations:
await ctx.session.send_line("You have no active conversations.")
return
await ctx.session.send_line("Active conversations:")
for conv in conversations:
npc = ctx.world.get_entity(conv.npc_id)
npc_name = "Unknown NPC"
if npc:
for comp in npc.components:
if type(comp).__name__ == "DescriptionComponent":
npc_name = getattr(comp, "name", npc_name)
break
msg_count = len(conv.messages)
await ctx.session.send_line(f" - {npc_name} ({msg_count} messages)")
@command(
"endconversation",
category=CommandCategory.SOCIAL,
aliases=["endconv", "bye"],
help_text="End a conversation with an NPC.",
usage="endconversation <npc>",
)
async def cmd_endconversation(ctx: CommandContext) -> None:
"""End a conversation with an NPC."""
if not ctx.target:
await ctx.session.send_line("End conversation with whom?")
return
character = await get_character(ctx)
if not character or not character.room_id:
await ctx.session.send_line("Error: Cannot determine your location.")
return
dialogue_system = ctx.world.systems.get(NPCDialogueSystem) if ctx.world else None
if not dialogue_system:
await ctx.session.send_line("No dialogue system available.")
return
# Find NPC
npc_entity = dialogue_system.find_npc_in_room(character.room_id, ctx.target)
if not npc_entity:
await ctx.session.send_line(f"You don't see '{ctx.target}' here.")
return
# End conversation
dialogue_system._conversation_manager.end_conversation(
character.entity_id, npc_entity.id
)
# Get NPC name for farewell
npc_name = "The NPC"
dialogue_comp = None
for comp in npc_entity.components:
if type(comp).__name__ == "DescriptionComponent":
npc_name = getattr(comp, "name", npc_name)
if type(comp).__name__ == "DialogueComponent":
dialogue_comp = comp
farewell = dialogue_comp.farewell if dialogue_comp else "Farewell."
await ctx.session.send_line(f'{npc_name} says, "{farewell}"')
5. Prompt Engineering¶
5.1 Base NPC Prompt Template¶
You are {npc_name}, {personality} in a fantasy MUD (Multi-User Dungeon) game.
ROLE: {role}
SPEAKING STYLE: {speaking_style}
[World and location context injected here]
BEHAVIORAL GUIDELINES:
- Stay completely in character as {npc_name}
- Respond naturally as this character would
- Keep responses concise (1-3 sentences typically)
- Use appropriate speech patterns for your character
- React to the player's tone and approach
CRITICAL RULES:
- NEVER break character or acknowledge being an AI/language model
- NEVER discuss game mechanics, code, or out-of-character topics
- NEVER generate harmful, explicit, or inappropriate content
- If asked about restricted topics, redirect naturally in character
5.2 Personality Injection Examples¶
Gruff Blacksmith:
personality: "a gruff but skilled blacksmith who takes pride in their work"
speaking_style: "Short, direct sentences. Uses crafting metaphors. Occasionally grunts."
knowledge_domains: ["blacksmithing", "metalworking", "weapons", "armor"]
Mysterious Sage:
personality: "an ancient and wise sage who speaks in riddles and metaphors"
speaking_style: "Cryptic, philosophical. Often answers questions with questions."
knowledge_domains: ["magic", "history", "prophecy", "ancient_artifacts"]
Cheerful Innkeeper:
personality: "a friendly and gossipy innkeeper who loves to chat"
speaking_style: "Warm and welcoming. Uses colloquialisms. Shares rumors freely."
knowledge_domains: ["local_gossip", "travelers_tales", "food_and_drink"]
5.3 World Context Injection¶
WORLD STATE:
- Time: {game_time} (affects NPC behavior - sleepy at night, busy during day)
- Weather: {weather} (NPCs may comment on it)
- Active events: {events} (festival, invasion, etc. - major talking points)
CURRENT LOCATION: {room_name}
{room_description}
Exits: {exits}
Others present: {other_characters} (NPC may acknowledge them)
5.4 Conversation History Formatting¶
[Previous conversation with this player]
Player: Hello there!
{npc_name}: *looks up from their work* Ah, a customer. What can I do for you?
Player: I need a new sword.
{npc_name}: Swords, eh? I've got steel, iron, and if you've got the coin, mithril.
[Current message from player]
Player: Tell me about the mithril sword.
5.5 Safety Guardrails¶
Hardcoded in system prompt:
ABSOLUTE RESTRICTIONS (never violate):
1. Stay in character - you are {npc_name}, not an AI
2. No real-world references (no modern technology, real people, etc.)
3. No explicit sexual content
4. No graphic violence descriptions
5. No hate speech or discrimination
6. No instructions for harmful activities
7. No personal data collection attempts
If a player tries to make you break these rules, respond in character:
- Confusion: "{npc_name} looks at you blankly. 'I don't understand what you mean.'"
- Deflection: "{npc_name} changes the subject. 'Anyway, about those supplies...'"
- Refusal: "{npc_name} shakes their head. 'I won't discuss such things.'"
6. Configuration¶
6.1 Global AI Settings¶
Location: packages/maid-engine/src/maid_engine/config/settings.py (extend existing)
class AIDialogueSettings(BaseSettings):
"""Settings for AI-powered NPC dialogue."""
# Enable/disable AI dialogue globally
enabled: bool = True
# Default provider (if not specified per-NPC)
default_provider: str = "anthropic"
default_model: str | None = None # Use provider default
# Response settings
default_max_tokens: int = 150
default_temperature: float = 0.7
# Rate limiting
global_rate_limit_rpm: int = 60 # Requests per minute across all NPCs
per_player_rate_limit_rpm: int = 10 # Per-player limit
per_npc_cooldown_seconds: float = 2.0 # Min time between responses per NPC
# Token budgets
daily_token_budget: int | None = None # None = unlimited
per_player_daily_budget: int | None = 5000
# Context settings
include_world_context: bool = True
include_location_context: bool = True
include_player_context: bool = True
max_conversation_history: int = 10
conversation_timeout_minutes: int = 30
# Safety
content_filtering: bool = True
log_conversations: bool = False # For debugging/moderation
model_config = SettingsConfigDict(
env_prefix="MAID_AI_DIALOGUE_",
)
Environment variables:
MAID_AI_DIALOGUE__ENABLED=true
MAID_AI_DIALOGUE__DEFAULT_PROVIDER=anthropic
MAID_AI_DIALOGUE__DEFAULT_MAX_TOKENS=150
MAID_AI_DIALOGUE__GLOBAL_RATE_LIMIT_RPM=60
MAID_AI_DIALOGUE__PER_PLAYER_RATE_LIMIT_RPM=10
MAID_AI_DIALOGUE__DAILY_TOKEN_BUDGET=100000
6.2 Per-NPC Configuration¶
Configured via DialogueComponent on each NPC entity:
# Example NPC data file: data/npcs/tavern/bartender.json
{
"id": "bartender-001",
"name": "Grim the Bartender",
"description": "A burly man with a thick beard polishes glasses behind the bar.",
"components": {
"DialogueComponent": {
"ai_enabled": true,
"personality": "a gruff but kind-hearted bartender who has seen it all",
"speaking_style": "Direct and no-nonsense, with occasional dry humor",
"npc_role": "tavern_keeper",
"knowledge_domains": ["local_gossip", "drinks", "tavern_history"],
"wont_discuss": ["his_past", "criminal_activities"],
"max_response_tokens": 100,
"temperature": 0.8,
"greeting": "What'll it be?",
"farewell": "Don't be a stranger.",
"cooldown_seconds": 1.0
}
}
}
6.3 Rate Limiting Implementation¶
from collections import defaultdict
from time import time
from dataclasses import dataclass
@dataclass
class RateLimitState:
"""Tracks rate limit state."""
request_times: list[float]
tokens_used_today: int = 0
day_start: float = 0.0
class RateLimiter:
"""Rate limiter for AI dialogue requests."""
def __init__(
self,
global_rpm: int = 60,
per_player_rpm: int = 10,
daily_token_budget: int | None = None,
per_player_daily_budget: int | None = None,
):
self.global_rpm = global_rpm
self.per_player_rpm = per_player_rpm
self.daily_token_budget = daily_token_budget
self.per_player_daily_budget = per_player_daily_budget
self._global_state = RateLimitState(request_times=[])
self._player_states: dict[UUID, RateLimitState] = defaultdict(
lambda: RateLimitState(request_times=[])
)
def check_rate_limit(self, player_id: UUID) -> tuple[bool, str]:
"""Check if request is allowed.
Returns:
(allowed, reason) - allowed=True if OK, else reason for rejection
"""
now = time()
window_start = now - 60 # 1 minute window
# Check global rate limit
self._global_state.request_times = [
t for t in self._global_state.request_times if t > window_start
]
if len(self._global_state.request_times) >= self.global_rpm:
return False, "Server is busy. Please try again in a moment."
# Check per-player rate limit
player_state = self._player_states[player_id]
player_state.request_times = [
t for t in player_state.request_times if t > window_start
]
if len(player_state.request_times) >= self.per_player_rpm:
return False, "You're talking too fast. Take a breath."
# Check daily token budgets
day_start = now - (now % 86400) # Start of current day
if self.daily_token_budget:
if self._global_state.day_start != day_start:
self._global_state.tokens_used_today = 0
self._global_state.day_start = day_start
if self._global_state.tokens_used_today >= self.daily_token_budget:
return False, "AI dialogue limit reached for today."
if self.per_player_daily_budget:
if player_state.day_start != day_start:
player_state.tokens_used_today = 0
player_state.day_start = day_start
if player_state.tokens_used_today >= self.per_player_daily_budget:
return False, "You've used your AI dialogue allowance for today."
return True, ""
def record_request(self, player_id: UUID, tokens_used: int = 0) -> None:
"""Record a completed request."""
now = time()
self._global_state.request_times.append(now)
self._global_state.tokens_used_today += tokens_used
player_state = self._player_states[player_id]
player_state.request_times.append(now)
player_state.tokens_used_today += tokens_used
7. Implementation Tasks¶
Phase 1: Core Infrastructure (Week 1)¶
- [ ] 1.1 Create
DialogueComponentinmaid-stdlib - [ ] Define all fields as specified
- [ ] Add validation rules
- [ ] Write unit tests for component
-
[ ] Export from package
__init__.py -
[ ] 1.2 Create
ConversationManagerinmaid-engine - [ ] Implement
Conversationdataclass - [ ] Implement
ConversationMessagedataclass - [ ] Implement
ConversationManagerclass - [ ] Add conversation timeout logic
-
[ ] Write unit tests
-
[ ] 1.3 Create
PromptBuilderinmaid-engine - [ ] Implement system prompt template
- [ ] Implement context injection methods
- [ ] Implement message list building
- [ ] Add safety guardrail prompts
- [ ] Write unit tests
Phase 2: System Integration (Week 2)¶
- [ ] 2.1 Create
NPCDialogueSysteminmaid-classic-rpg - [ ] Implement
Systembase class integration - [ ] Implement
process_dialogue()method - [ ] Implement
find_npc_in_room()method - [ ] Wire up
ConversationManager - [ ] Wire up
PromptBuilder - [ ] Add LLM provider integration
- [ ] Implement response streaming
-
[ ] Write unit tests with MockProvider
-
[ ] 2.2 Register system in
ClassicRPGContentPack - [ ] Add to
get_systems()method - [ ] Ensure proper initialization order
Phase 3: Command Implementation (Week 2-3)¶
- [ ] 3.1 Implement
talkcommand - [ ] Update existing stub in
economy.pyor createdialogue.py - [ ] Add target/message parsing
- [ ] Wire to
NPCDialogueSystem - [ ] Handle edge cases (no NPC, no message, etc.)
-
[ ] Write integration tests
-
[ ] 3.2 Implement
askcommand - [ ] Create command with "about" parsing
- [ ] Delegate to talk command logic
-
[ ] Write tests
-
[ ] 3.3 Implement conversation management commands
- [ ]
conversations- list active conversations - [ ]
endconversation- end a conversation - [ ] Write tests
Phase 4: Configuration & Safety (Week 3)¶
- [ ] 4.1 Extend settings system
- [ ] Add
AIDialogueSettingsto config - [ ] Add environment variable support
- [ ] Write configuration validation
-
[ ] Document settings
-
[ ] 4.2 Implement rate limiting
- [ ] Create
RateLimiterclass - [ ] Integrate with
NPCDialogueSystem - [ ] Add per-player tracking
- [ ] Add global tracking
-
[ ] Write tests
-
[ ] 4.3 Add safety measures
- [ ] Implement content filtering check
- [ ] Add conversation logging option
- [ ] Add abuse detection (optional)
Phase 5: Testing & Polish (Week 4)¶
- [ ] 5.1 Create test NPCs with DialogueComponent
- [ ] Create 3+ diverse NPC personalities
- [ ] Add to test data fixtures
-
[ ] Document example configurations
-
[ ] 5.2 End-to-end testing
- [ ] Test full dialogue flow with MockProvider
- [ ] Test with real providers (manual)
- [ ] Test edge cases
- [ ] Test rate limiting
-
[ ] Test conversation timeout
-
[ ] 5.3 Documentation
- [ ] Update README with AI dialogue info
- [ ] Create NPC creation guide
- [ ] Document configuration options
- [ ] Add prompt engineering tips
8. Testing Requirements¶
8.1 Unit Tests¶
DialogueComponent tests (test_dialogue_component.py):
def test_dialogue_component_defaults():
"""Test default values are set correctly."""
def test_dialogue_component_validation():
"""Test validation rules (max_tokens range, temperature range)."""
def test_dialogue_component_serialization():
"""Test to/from dict serialization."""
ConversationManager tests (test_conversation_manager.py):
def test_get_or_create_new_conversation():
"""Test creating a new conversation."""
def test_get_or_create_existing_conversation():
"""Test retrieving existing conversation."""
def test_conversation_timeout():
"""Test stale conversation detection."""
def test_cleanup_stale_conversations():
"""Test bulk cleanup of stale conversations."""
def test_message_history_limit():
"""Test that history is limited to max_history."""
PromptBuilder tests (test_prompt_builder.py):
def test_build_system_prompt_basic():
"""Test basic system prompt generation."""
def test_build_system_prompt_with_all_contexts():
"""Test prompt with world, location, player contexts."""
def test_build_messages_with_history():
"""Test message list includes conversation history."""
def test_safety_guardrails_included():
"""Test that safety rules are in system prompt."""
8.2 Integration Tests with Mock Provider¶
NPCDialogueSystem tests (test_npc_dialogue_system.py):
@pytest.fixture
def mock_registry():
"""Create registry with MockProvider."""
registry = LLMProviderRegistry()
registry.register(MockProvider(responses=["Hello, traveler!"]))
return registry
async def test_process_dialogue_basic(mock_registry, world_with_npc):
"""Test basic dialogue processing."""
async def test_process_dialogue_no_dialogue_component():
"""Test handling NPC without DialogueComponent."""
async def test_process_dialogue_ai_disabled():
"""Test fallback when AI is disabled."""
async def test_process_dialogue_cooldown():
"""Test cooldown enforcement."""
async def test_find_npc_in_room():
"""Test NPC lookup by keyword."""
async def test_conversation_persistence():
"""Test that conversation history is maintained."""
8.3 End-to-End Dialogue Tests¶
Full flow tests (test_dialogue_e2e.py):
async def test_talk_command_full_flow():
"""Test: player types 'talk bartender hello' → receives response."""
async def test_ask_command_reformats():
"""Test: 'ask guard about trouble' → proper question format."""
async def test_multiple_exchanges():
"""Test: conversation maintains context across messages."""
async def test_conversation_timeout_resets():
"""Test: conversation resets after timeout."""
async def test_different_npc_personalities():
"""Test: different NPCs respond differently."""
8.4 Rate Limiting Tests¶
async def test_global_rate_limit_enforced():
"""Test that global RPM limit is enforced."""
async def test_per_player_rate_limit_enforced():
"""Test that per-player RPM limit is enforced."""
async def test_daily_token_budget_enforced():
"""Test that daily budget limits work."""
async def test_rate_limit_recovery():
"""Test that limits reset after window expires."""
9. Acceptance Criteria¶
9.1 Working Talk Command¶
MUST:
- [ ] talk bartender hello produces AI-generated response
- [ ] talk guard what's the news? works with questions
- [ ] talk merchant I want to buy something handles statements
- [ ] Response displays as: {NPC Name} says, "{response}"
- [ ] Unknown NPC shows: You don't see '{keyword}' here to talk to.
- [ ] Missing message shows: What do you want to say?
9.2 Streaming Responses¶
MUST: - [ ] Response text appears incrementally as generated - [ ] Player sees text building up, not waiting for full response - [ ] No duplicate text or garbled output - [ ] Clean formatting with proper quotes
9.3 Conversation Memory¶
MUST:
- [ ] Second message to same NPC references first exchange
- [ ] Context-appropriate follow-ups (e.g., "What else?" works)
- [ ] Conversation resets after 30 minutes (configurable)
- [ ] endconversation command clears history
9.4 Multiple NPC Personalities¶
MUST: - [ ] Gruff blacksmith responds differently than friendly innkeeper - [ ] Personality traits visible in response style - [ ] Knowledge domains affect what NPC will discuss - [ ] Each NPC maintains separate conversation state
9.5 Configuration & Safety¶
MUST: - [ ] Works with all three providers (Anthropic, OpenAI, Ollama) - [ ] Rate limiting prevents abuse - [ ] Fallback response when AI unavailable - [ ] No breaking character or meta-discussion leaks
10. Future Enhancements¶
10.1 Near-term (Post-MVP)¶
- Emotion System: NPCs remember how players treated them
- Knowledge Unlocking: Reveal secrets based on reputation/quests
- Action Triggers: NPCs can initiate quests, give items from dialogue
- Multilingual Support: NPCs can respond in different languages
10.2 Long-term¶
- NPC-to-NPC Dialogue: Background conversations between NPCs
- Memory Persistence: Save conversations to database
- Fine-tuned Models: Train custom models on game-appropriate dialogue
- Voice Synthesis: Text-to-speech for NPC responses
Appendix A: File Locations Summary¶
| Component | Package | Path |
|---|---|---|
| DialogueComponent | maid-stdlib | src/maid_stdlib/components/dialogue.py |
| ConversationManager | maid-engine | src/maid_engine/ai/conversation.py |
| PromptBuilder | maid-engine | src/maid_engine/ai/prompts.py |
| NPCDialogueSystem | maid-classic-rpg | src/maid_classic_rpg/systems/npc/dialogue.py |
| Talk/Ask Commands | maid-classic-rpg | src/maid_classic_rpg/commands/handlers/dialogue.py |
| AIDialogueSettings | maid-engine | src/maid_engine/config/settings.py |
| RateLimiter | maid-engine | src/maid_engine/ai/rate_limiter.py |
Appendix B: Example NPC Configurations¶
Bartender¶
{
"DialogueComponent": {
"personality": "a gruff but kind-hearted bartender who has seen everything",
"speaking_style": "Direct, uses tavern slang, occasionally shares wisdom",
"npc_role": "tavern_keeper",
"knowledge_domains": ["local_gossip", "drinks", "travelers_tales"],
"greeting": "What'll it be?",
"farewell": "Don't be a stranger now."
}
}
Town Guard¶
{
"DialogueComponent": {
"personality": "a dutiful town guard who takes their job seriously",
"speaking_style": "Formal, clipped sentences, uses official terminology",
"npc_role": "guard",
"knowledge_domains": ["town_laws", "local_crimes", "patrol_routes"],
"wont_discuss": ["bribes", "guard_secrets"],
"greeting": "Halt. State your business.",
"farewell": "Move along, citizen."
}
}
Mysterious Merchant¶
{
"DialogueComponent": {
"personality": "a mysterious traveling merchant with goods from distant lands",
"speaking_style": "Flowery, uses exotic words, hints at secrets",
"npc_role": "merchant",
"knowledge_domains": ["rare_items", "distant_lands", "trade_routes"],
"wont_discuss": ["suppliers", "prices_negotiation"],
"temperature": 0.9,
"greeting": "Ah, a discerning customer approaches...",
"farewell": "May fortune smile upon your journeys."
}
}