Skip to content

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)CompletionResult
  • complete_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() and to_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)

  1. AI-Powered NPC Dialogue
  2. Players can talk to NPCs using natural language
  3. NPCs respond contextually based on personality and world state
  4. Responses feel appropriate to the game world

  5. Context-Aware Conversations

  6. NPCs know where they are (room, area)
  7. NPCs know who they're talking to (player name, level, class)
  8. NPCs know the current game state (time of day, weather, events)

  9. Conversation History

  10. Track recent messages within a conversation
  11. Maintain context across multiple exchanges
  12. Configurable history depth (default: last 10 messages)

  13. Character Personality Persistence

  14. Each NPC has defined personality traits
  15. Personality influences response style and content
  16. Personalities defined in NPC data, not hardcoded

  17. Multi-Provider Support

  18. Use existing LLMProviderRegistry
  19. Global default provider from settings
  20. Per-NPC provider override (optional)

2.2 Enhanced Features (Post-MVP)

  1. Streaming Responses - Send response chunks to player as generated
  2. Hybrid Dialogue - Fall back to scripted responses for keywords
  3. Emotion/Mood System - NPC responses affected by emotional state
  4. Memory Persistence - Remember conversations across sessions
  5. Knowledge Bases - NPCs can have specific knowledge sets
  6. 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 DialogueComponent in maid-stdlib
  • [ ] Define all fields as specified
  • [ ] Add validation rules
  • [ ] Write unit tests for component
  • [ ] Export from package __init__.py

  • [ ] 1.2 Create ConversationManager in maid-engine

  • [ ] Implement Conversation dataclass
  • [ ] Implement ConversationMessage dataclass
  • [ ] Implement ConversationManager class
  • [ ] Add conversation timeout logic
  • [ ] Write unit tests

  • [ ] 1.3 Create PromptBuilder in maid-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 NPCDialogueSystem in maid-classic-rpg
  • [ ] Implement System base 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 talk command
  • [ ] Update existing stub in economy.py or create dialogue.py
  • [ ] Add target/message parsing
  • [ ] Wire to NPCDialogueSystem
  • [ ] Handle edge cases (no NPC, no message, etc.)
  • [ ] Write integration tests

  • [ ] 3.2 Implement ask command

  • [ ] 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 AIDialogueSettings to config
  • [ ] Add environment variable support
  • [ ] Write configuration validation
  • [ ] Document settings

  • [ ] 4.2 Implement rate limiting

  • [ ] Create RateLimiter class
  • [ ] 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."
    }
}