first commit
Browse filesThis view is limited to 50 files because it contains too many changes.
See raw diff
- .env +65 -0
- .env.example +65 -0
- .gitattributes +4 -0
- .gitignore +170 -0
- README.md +289 -6
- adventures/sample_characters/fighter_template.json +147 -0
- adventures/sample_characters/rogue_template.json +178 -0
- adventures/sample_characters/wizard_template.json +255 -0
- adventures/tavern_start.json +266 -0
- adventures/tutorial_goblin_cave.json +280 -0
- app.py +19 -5
- pyproject.toml +136 -0
- requirements.txt +527 -0
- src/__init__.py +8 -0
- src/agents/__init__.py +125 -0
- src/agents/dungeon_master.py +675 -0
- src/agents/exceptions.py +314 -0
- src/agents/llm_provider.py +437 -0
- src/agents/models.py +516 -0
- src/agents/orchestrator.py +911 -0
- src/agents/pacing_controller.py +271 -0
- src/agents/rules_arbiter.py +446 -0
- src/agents/special_moments.py +474 -0
- src/agents/voice_narrator.py +449 -0
- src/config/__init__.py +25 -0
- src/config/prompts/dm_combat.txt +46 -0
- src/config/prompts/dm_exploration.txt +56 -0
- src/config/prompts/dm_social.txt +69 -0
- src/config/prompts/dm_system.txt +88 -0
- src/config/prompts/narrator_system.txt +60 -0
- src/config/prompts/rules_system.txt +64 -0
- src/config/settings.py +289 -0
- src/game/__init__.py +86 -0
- src/game/adventure_loader.py +530 -0
- src/game/event_logger.py +724 -0
- src/game/game_state.py +367 -0
- src/game/game_state_manager.py +992 -0
- src/game/models.py +774 -0
- src/game/story_context.py +601 -0
- src/mcp_integration/__init__.py +86 -0
- src/mcp_integration/connection_manager.py +565 -0
- src/mcp_integration/exceptions.py +119 -0
- src/mcp_integration/fallbacks.py +360 -0
- src/mcp_integration/models.py +335 -0
- src/mcp_integration/tool_wrappers.py +1003 -0
- src/mcp_integration/toolkit_client.py +520 -0
- src/utils/__init__.py +53 -0
- src/utils/formatters.py +278 -0
- src/utils/validators.py +276 -0
- src/voice/__init__.py +130 -0
.env
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# DungeonMaster AI - Environment Configuration
|
| 2 |
+
# Copy this file to .env and fill in your actual API keys
|
| 3 |
+
|
| 4 |
+
# ============================================
|
| 5 |
+
# LLM API Keys (Required - at least one)
|
| 6 |
+
# ============================================
|
| 7 |
+
|
| 8 |
+
# Google Gemini API Key (Primary LLM)
|
| 9 |
+
# Get your key at: https://aistudio.google.com/apikey
|
| 10 |
+
GEMINI_API_KEY=AIzaSyBUl4IaLR9jts5aA-IzLIB6WO2PklYuFY8
|
| 11 |
+
|
| 12 |
+
# OpenAI API Key (Fallback LLM)
|
| 13 |
+
# Get your key at: https://platform.openai.com/api-keys
|
| 14 |
+
OPENAI_API_KEY=your_openai_api_key_here
|
| 15 |
+
|
| 16 |
+
# LLM Models (optional - defaults shown)
|
| 17 |
+
GEMINI_MODEL=gemini-2.5-flash
|
| 18 |
+
OPENAI_MODEL=gpt-4o
|
| 19 |
+
|
| 20 |
+
# ============================================
|
| 21 |
+
# Voice Synthesis (Required for voice feature)
|
| 22 |
+
# ============================================
|
| 23 |
+
|
| 24 |
+
# ElevenLabs API Key
|
| 25 |
+
# Get your key at: https://elevenlabs.io/api
|
| 26 |
+
ELEVENLABS_API_KEY=sk_d286e70102ddd8b3ba44389bfd3a883d57f704a9e949228c
|
| 27 |
+
|
| 28 |
+
# Voice Model (optional)
|
| 29 |
+
VOICE_MODEL=eleven_flash_v2_5
|
| 30 |
+
|
| 31 |
+
# ============================================
|
| 32 |
+
# TTRPG Toolkit MCP Server
|
| 33 |
+
# ============================================
|
| 34 |
+
|
| 35 |
+
# MCP Server URL (SSE endpoint)
|
| 36 |
+
# For local development: http://localhost:8000/sse
|
| 37 |
+
# For Blaxel deployment: https://your-deployment.blaxel.app/sse
|
| 38 |
+
TTRPG_TOOLKIT_MCP_URL=http://localhost:8000/mcp
|
| 39 |
+
|
| 40 |
+
# ============================================
|
| 41 |
+
# HuggingFace Deployment
|
| 42 |
+
# ============================================
|
| 43 |
+
|
| 44 |
+
# HuggingFace Token (for Spaces deployment)
|
| 45 |
+
# Get your token at: https://huggingface.co/settings/tokens
|
| 46 |
+
HUGGINGFACE_TOKEN=your_hf_token_here
|
| 47 |
+
|
| 48 |
+
# ============================================
|
| 49 |
+
# Application Settings
|
| 50 |
+
# ============================================
|
| 51 |
+
|
| 52 |
+
# Debug mode (true/false)
|
| 53 |
+
DEBUG_MODE=false
|
| 54 |
+
|
| 55 |
+
# Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
| 56 |
+
LOG_LEVEL=INFO
|
| 57 |
+
|
| 58 |
+
# Voice settings
|
| 59 |
+
VOICE_ENABLED=true
|
| 60 |
+
VOICE_AUTO_PLAY=true
|
| 61 |
+
VOICE_SPEED=1.0
|
| 62 |
+
|
| 63 |
+
# Game settings
|
| 64 |
+
MAX_CONVERSATION_HISTORY=10
|
| 65 |
+
DEFAULT_ADVENTURE=tavern_start
|
.env.example
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# DungeonMaster AI - Environment Configuration
|
| 2 |
+
# Copy this file to .env and fill in your actual API keys
|
| 3 |
+
|
| 4 |
+
# ============================================
|
| 5 |
+
# LLM API Keys (Required - at least one)
|
| 6 |
+
# ============================================
|
| 7 |
+
|
| 8 |
+
# Google Gemini API Key (Primary LLM)
|
| 9 |
+
# Get your key at: https://aistudio.google.com/apikey
|
| 10 |
+
GEMINI_API_KEY=your_gemini_api_key_here
|
| 11 |
+
|
| 12 |
+
# OpenAI API Key (Fallback LLM)
|
| 13 |
+
# Get your key at: https://platform.openai.com/api-keys
|
| 14 |
+
OPENAI_API_KEY=your_openai_api_key_here
|
| 15 |
+
|
| 16 |
+
# LLM Models (optional - defaults shown)
|
| 17 |
+
GEMINI_MODEL=gemini-1.5-pro
|
| 18 |
+
OPENAI_MODEL=gpt-4o
|
| 19 |
+
|
| 20 |
+
# ============================================
|
| 21 |
+
# Voice Synthesis (Required for voice feature)
|
| 22 |
+
# ============================================
|
| 23 |
+
|
| 24 |
+
# ElevenLabs API Key
|
| 25 |
+
# Get your key at: https://elevenlabs.io/api
|
| 26 |
+
ELEVENLABS_API_KEY=your_elevenlabs_api_key_here
|
| 27 |
+
|
| 28 |
+
# Voice Model (optional)
|
| 29 |
+
VOICE_MODEL=eleven_turbo_v2
|
| 30 |
+
|
| 31 |
+
# ============================================
|
| 32 |
+
# TTRPG Toolkit MCP Server
|
| 33 |
+
# ============================================
|
| 34 |
+
|
| 35 |
+
# MCP Server URL (SSE endpoint)
|
| 36 |
+
# For local development: http://localhost:8000/sse
|
| 37 |
+
# For Blaxel deployment: https://your-deployment.blaxel.app/sse
|
| 38 |
+
TTRPG_TOOLKIT_MCP_URL=http://localhost:8000/sse
|
| 39 |
+
|
| 40 |
+
# ============================================
|
| 41 |
+
# HuggingFace Deployment
|
| 42 |
+
# ============================================
|
| 43 |
+
|
| 44 |
+
# HuggingFace Token (for Spaces deployment)
|
| 45 |
+
# Get your token at: https://huggingface.co/settings/tokens
|
| 46 |
+
HUGGINGFACE_TOKEN=your_hf_token_here
|
| 47 |
+
|
| 48 |
+
# ============================================
|
| 49 |
+
# Application Settings
|
| 50 |
+
# ============================================
|
| 51 |
+
|
| 52 |
+
# Debug mode (true/false)
|
| 53 |
+
DEBUG_MODE=false
|
| 54 |
+
|
| 55 |
+
# Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
|
| 56 |
+
LOG_LEVEL=INFO
|
| 57 |
+
|
| 58 |
+
# Voice settings
|
| 59 |
+
VOICE_ENABLED=true
|
| 60 |
+
VOICE_AUTO_PLAY=true
|
| 61 |
+
VOICE_SPEED=1.0
|
| 62 |
+
|
| 63 |
+
# Game settings
|
| 64 |
+
MAX_CONVERSATION_HISTORY=10
|
| 65 |
+
DEFAULT_ADVENTURE=tavern_start
|
.gitattributes
CHANGED
|
@@ -33,3 +33,7 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
ui/assets/images/scenes/castle.jpg filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
ui/assets/images/scenes/default.jpg filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
ui/assets/images/scenes/dungeon.jpg filter=lfs diff=lfs merge=lfs -text
|
| 39 |
+
ui/assets/images/scenes/tavern.jpg filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Byte-compiled / optimized / DLL files
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
|
| 6 |
+
# C extensions
|
| 7 |
+
*.so
|
| 8 |
+
|
| 9 |
+
# Distribution / packaging
|
| 10 |
+
.Python
|
| 11 |
+
build/
|
| 12 |
+
develop-eggs/
|
| 13 |
+
dist/
|
| 14 |
+
downloads/
|
| 15 |
+
eggs/
|
| 16 |
+
.eggs/
|
| 17 |
+
lib/
|
| 18 |
+
lib64/
|
| 19 |
+
parts/
|
| 20 |
+
sdist/
|
| 21 |
+
var/
|
| 22 |
+
wheels/
|
| 23 |
+
share/python-wheels/
|
| 24 |
+
*.egg-info/
|
| 25 |
+
.installed.cfg
|
| 26 |
+
*.egg
|
| 27 |
+
MANIFEST
|
| 28 |
+
|
| 29 |
+
# PyInstaller
|
| 30 |
+
*.manifest
|
| 31 |
+
*.spec
|
| 32 |
+
|
| 33 |
+
# Installer logs
|
| 34 |
+
pip-log.txt
|
| 35 |
+
pip-delete-this-directory.txt
|
| 36 |
+
|
| 37 |
+
# Unit test / coverage reports
|
| 38 |
+
htmlcov/
|
| 39 |
+
.tox/
|
| 40 |
+
.nox/
|
| 41 |
+
.coverage
|
| 42 |
+
.coverage.*
|
| 43 |
+
.cache
|
| 44 |
+
nosetests.xml
|
| 45 |
+
coverage.xml
|
| 46 |
+
*.cover
|
| 47 |
+
*.py,cover
|
| 48 |
+
.hypothesis/
|
| 49 |
+
.pytest_cache/
|
| 50 |
+
cover/
|
| 51 |
+
|
| 52 |
+
# Translations
|
| 53 |
+
*.mo
|
| 54 |
+
*.pot
|
| 55 |
+
|
| 56 |
+
# Django stuff:
|
| 57 |
+
*.log
|
| 58 |
+
local_settings.py
|
| 59 |
+
db.sqlite3
|
| 60 |
+
db.sqlite3-journal
|
| 61 |
+
|
| 62 |
+
# Flask stuff:
|
| 63 |
+
instance/
|
| 64 |
+
.webassets-cache
|
| 65 |
+
|
| 66 |
+
# Scrapy stuff:
|
| 67 |
+
.scrapy
|
| 68 |
+
|
| 69 |
+
# Sphinx documentation
|
| 70 |
+
docs/_build/
|
| 71 |
+
|
| 72 |
+
# PyBuilder
|
| 73 |
+
.pybuilder/
|
| 74 |
+
target/
|
| 75 |
+
|
| 76 |
+
# Jupyter Notebook
|
| 77 |
+
.ipynb_checkpoints
|
| 78 |
+
|
| 79 |
+
# IPython
|
| 80 |
+
profile_default/
|
| 81 |
+
ipython_config.py
|
| 82 |
+
|
| 83 |
+
# pyenv
|
| 84 |
+
.python-version
|
| 85 |
+
|
| 86 |
+
# pipenv
|
| 87 |
+
Pipfile.lock
|
| 88 |
+
|
| 89 |
+
# UV
|
| 90 |
+
uv.lock
|
| 91 |
+
|
| 92 |
+
# PEP 582
|
| 93 |
+
__pypackages__/
|
| 94 |
+
|
| 95 |
+
# Celery stuff
|
| 96 |
+
celerybeat-schedule
|
| 97 |
+
celerybeat.pid
|
| 98 |
+
|
| 99 |
+
# SageMath parsed files
|
| 100 |
+
*.sage.py
|
| 101 |
+
|
| 102 |
+
# Environments
|
| 103 |
+
.env
|
| 104 |
+
.venv
|
| 105 |
+
env/
|
| 106 |
+
venv/
|
| 107 |
+
ENV/
|
| 108 |
+
env.bak/
|
| 109 |
+
venv.bak/
|
| 110 |
+
|
| 111 |
+
# Spyder project settings
|
| 112 |
+
.spyderproject
|
| 113 |
+
.spyproject
|
| 114 |
+
|
| 115 |
+
# Rope project settings
|
| 116 |
+
.ropeproject
|
| 117 |
+
|
| 118 |
+
# mkdocs documentation
|
| 119 |
+
/site
|
| 120 |
+
|
| 121 |
+
# mypy
|
| 122 |
+
.mypy_cache/
|
| 123 |
+
.dmypy.json
|
| 124 |
+
dmypy.json
|
| 125 |
+
|
| 126 |
+
# Pyre type checker
|
| 127 |
+
.pyre/
|
| 128 |
+
|
| 129 |
+
# pytype static type analyzer
|
| 130 |
+
.pytype/
|
| 131 |
+
|
| 132 |
+
# Cython debug symbols
|
| 133 |
+
cython_debug/
|
| 134 |
+
|
| 135 |
+
# IDE
|
| 136 |
+
.idea/
|
| 137 |
+
.vscode/
|
| 138 |
+
*.swp
|
| 139 |
+
*.swo
|
| 140 |
+
*~
|
| 141 |
+
|
| 142 |
+
# OS
|
| 143 |
+
.DS_Store
|
| 144 |
+
.DS_Store?
|
| 145 |
+
._*
|
| 146 |
+
.Spotlight-V100
|
| 147 |
+
.Trashes
|
| 148 |
+
ehthumbs.db
|
| 149 |
+
Thumbs.db
|
| 150 |
+
|
| 151 |
+
# Project-specific
|
| 152 |
+
*.mp3
|
| 153 |
+
*.wav
|
| 154 |
+
*.ogg
|
| 155 |
+
audio_cache/
|
| 156 |
+
saves/
|
| 157 |
+
data/indices/
|
| 158 |
+
data/characters/
|
| 159 |
+
|
| 160 |
+
# Secrets
|
| 161 |
+
.env.local
|
| 162 |
+
.env.*.local
|
| 163 |
+
secrets.json
|
| 164 |
+
credentials.json
|
| 165 |
+
|
| 166 |
+
# Gradio
|
| 167 |
+
flagged/
|
| 168 |
+
|
| 169 |
+
# Claude
|
| 170 |
+
.claude/
|
README.md
CHANGED
|
@@ -1,14 +1,297 @@
|
|
| 1 |
---
|
| 2 |
title: DungeonMaster AI
|
| 3 |
-
emoji:
|
| 4 |
colorFrom: yellow
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version: 6.0.
|
| 8 |
app_file: app.py
|
| 9 |
-
pinned:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
license: mit
|
| 11 |
-
short_description: AI powered D&D game master
|
| 12 |
---
|
| 13 |
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: DungeonMaster AI
|
| 3 |
+
emoji: "\U0001F3B2"
|
| 4 |
colorFrom: yellow
|
| 5 |
+
colorTo: red
|
| 6 |
sdk: gradio
|
| 7 |
+
sdk_version: "6.0.0"
|
| 8 |
app_file: app.py
|
| 9 |
+
pinned: true
|
| 10 |
+
tags:
|
| 11 |
+
- agent-demo-track
|
| 12 |
+
- mcp
|
| 13 |
+
- dnd
|
| 14 |
+
- llama-index
|
| 15 |
+
- gemini
|
| 16 |
+
- elevenlabs
|
| 17 |
+
- gaming
|
| 18 |
+
- voice
|
| 19 |
license: mit
|
|
|
|
| 20 |
---
|
| 21 |
|
| 22 |
+
# DungeonMaster AI
|
| 23 |
+
|
| 24 |
+
**Your AI-Powered D&D 5e Game Master with Voice Narration**
|
| 25 |
+
|
| 26 |
+
*Roll for initiative! An immersive tabletop experience powered by cutting-edge AI technology.*
|
| 27 |
+
|
| 28 |
+
---
|
| 29 |
+
|
| 30 |
+
## Demo Video
|
| 31 |
+
|
| 32 |
+
[](https://youtube.com/your-video-link)
|
| 33 |
+
|
| 34 |
+
---
|
| 35 |
+
|
| 36 |
+
## Features
|
| 37 |
+
|
| 38 |
+
### AI Dungeon Master
|
| 39 |
+
- **Intelligent Storytelling**: Dynamic narrative generation that adapts to your choices
|
| 40 |
+
- **Authentic D&D Experience**: Full D&D 5e rules implementation via TTRPG Toolkit MCP
|
| 41 |
+
- **Multi-Modal Agents**: Specialized agents for storytelling, rules lookup, and voice narration
|
| 42 |
+
|
| 43 |
+
### Voice Narration
|
| 44 |
+
- **Immersive Audio**: Professional voice narration powered by ElevenLabs
|
| 45 |
+
- **Character Voices**: Different voice profiles for the DM, NPCs, and monsters
|
| 46 |
+
- **Real-Time Streaming**: Low-latency voice synthesis for smooth gameplay
|
| 47 |
+
|
| 48 |
+
### Complete Game System
|
| 49 |
+
- **Full Character Management**: Create, track, and level up D&D 5e characters
|
| 50 |
+
- **Combat Engine**: Turn-based combat with initiative tracking and condition management
|
| 51 |
+
- **Dice Rolling**: All standard D&D dice with advantage/disadvantage and modifiers
|
| 52 |
+
- **Rules Lookup**: RAG-powered rules search for instant rule clarification
|
| 53 |
+
|
| 54 |
+
### Beautiful Fantasy UI
|
| 55 |
+
- **Themed Interface**: Dark fantasy aesthetic with parchment textures and golden accents
|
| 56 |
+
- **Responsive Design**: Works on desktop, tablet, and mobile
|
| 57 |
+
- **Real-Time Updates**: Live character sheet, combat tracker, and dice roll history
|
| 58 |
+
|
| 59 |
+
---
|
| 60 |
+
|
| 61 |
+
## How to Play
|
| 62 |
+
|
| 63 |
+
### Starting a New Game
|
| 64 |
+
|
| 65 |
+
1. **Launch the App**: Visit the HuggingFace Space or run locally
|
| 66 |
+
2. **Create Your Character**: Use the character creation wizard or load a pre-made template
|
| 67 |
+
3. **Choose Your Adventure**: Start with "The Rusty Dragon Tavern" or "The Goblin Cave"
|
| 68 |
+
4. **Start Playing**: Type your actions and watch the story unfold!
|
| 69 |
+
|
| 70 |
+
### Basic Commands
|
| 71 |
+
|
| 72 |
+
| Action | What to Type |
|
| 73 |
+
|--------|-------------|
|
| 74 |
+
| **Attack** | "I attack the goblin with my sword" |
|
| 75 |
+
| **Investigate** | "I search the room for hidden doors" |
|
| 76 |
+
| **Talk** | "I ask the bartender about recent rumors" |
|
| 77 |
+
| **Cast a Spell** | "I cast Magic Missile at the enemy" |
|
| 78 |
+
| **Move** | "I sneak down the dark corridor" |
|
| 79 |
+
|
| 80 |
+
### Special Commands
|
| 81 |
+
|
| 82 |
+
- `/roll [notation]` - Roll dice directly (e.g., `/roll 2d6+3`)
|
| 83 |
+
- `/character` - View your character sheet
|
| 84 |
+
- `/inventory` - Check your items
|
| 85 |
+
- `/rest` - Take a short or long rest
|
| 86 |
+
|
| 87 |
+
### Combat Tips
|
| 88 |
+
|
| 89 |
+
- The DM will call for initiative when combat begins
|
| 90 |
+
- Describe your intended action - the AI will handle the mechanics
|
| 91 |
+
- Use the combat tracker to see turn order and HP
|
| 92 |
+
- Strategic positioning and creative solutions are rewarded!
|
| 93 |
+
|
| 94 |
+
---
|
| 95 |
+
|
| 96 |
+
## Technical Architecture
|
| 97 |
+
|
| 98 |
+
```
|
| 99 |
+
┌─────────────────────────────────────────────────────────────────────────┐
|
| 100 |
+
│ DUNGEONMASTER AI │
|
| 101 |
+
├─────────────────────────────────────────────────────────────────────────┤
|
| 102 |
+
│ │
|
| 103 |
+
│ LAYER 1: GRADIO 6 UI │
|
| 104 |
+
│ ├── Chat Panel (with voice playback) │
|
| 105 |
+
│ ├── Character Sheet Panel │
|
| 106 |
+
│ ├── Dice & Roll History Panel │
|
| 107 |
+
│ ├── Combat Tracker Panel │
|
| 108 |
+
│ └── Game Controls Panel │
|
| 109 |
+
│ │
|
| 110 |
+
│ LAYER 2: AGENT ORCHESTRATION (LlamaIndex) │
|
| 111 |
+
│ ├── Dungeon Master Agent (storytelling, game flow) │
|
| 112 |
+
│ ├── Rules Arbiter Agent (rules lookup, adjudication) │
|
| 113 |
+
│ └── Voice Narrator Agent (ElevenLabs integration) │
|
| 114 |
+
│ │
|
| 115 |
+
│ LAYER 3: GAME STATE MANAGEMENT │
|
| 116 |
+
│ ├── Game State Manager (session, party, location, combat) │
|
| 117 |
+
│ ├── Story Context Builder (LLM context preparation) │
|
| 118 |
+
│ └── Event Logger (action history tracking) │
|
| 119 |
+
│ │
|
| 120 |
+
│ LAYER 4: MCP INTEGRATION │
|
| 121 |
+
│ ├── TTRPG Toolkit Client (connection to MCP server) │
|
| 122 |
+
│ ├── Tool Wrappers (game-aware tool enhancements) │
|
| 123 |
+
│ └── Connection Manager (health checks, reconnection) │
|
| 124 |
+
│ │
|
| 125 |
+
└─────────────────────────────────────────────────────────────────────────┘
|
| 126 |
+
│
|
| 127 |
+
▼
|
| 128 |
+
┌─────────────────────────────────────────────────────────────────────────┐
|
| 129 |
+
│ TTRPG TOOLKIT MCP │
|
| 130 |
+
│ (Separate MCP Server - Blaxel) │
|
| 131 |
+
│ ├── Dice Roller Server │
|
| 132 |
+
│ ├── Character Manager Server │
|
| 133 |
+
│ ├── Rules Lookup Server (LlamaIndex RAG) │
|
| 134 |
+
│ ├── Generators Server (NPCs, encounters, loot) │
|
| 135 |
+
│ ├── Combat Engine Server │
|
| 136 |
+
│ └── Session Manager Server │
|
| 137 |
+
└─────────────────────────────────────────────────────────────────────────┘
|
| 138 |
+
```
|
| 139 |
+
|
| 140 |
+
---
|
| 141 |
+
|
| 142 |
+
## Built With
|
| 143 |
+
|
| 144 |
+
### AI & Agents
|
| 145 |
+
- **[Google Gemini](https://ai.google.dev/)** - Primary LLM for storytelling and game mastering
|
| 146 |
+
- **[OpenAI GPT-4](https://openai.com/)** - Fallback LLM for reliability
|
| 147 |
+
- **[LlamaIndex](https://www.llamaindex.ai/)** - Agent framework and RAG for rules lookup
|
| 148 |
+
|
| 149 |
+
### Voice
|
| 150 |
+
- **[ElevenLabs](https://elevenlabs.io/)** - Voice synthesis for immersive narration
|
| 151 |
+
|
| 152 |
+
### MCP & Backend
|
| 153 |
+
- **[TTRPG Toolkit MCP](https://github.com/ttrpg-toolkit/ttrpg-toolkit-mcp)** - Game mechanics server
|
| 154 |
+
- **[Blaxel](https://blaxel.com/)** - MCP server deployment
|
| 155 |
+
|
| 156 |
+
### UI & Deployment
|
| 157 |
+
- **[Gradio 6](https://gradio.app/)** - Modern UI framework
|
| 158 |
+
- **[HuggingFace Spaces](https://huggingface.co/spaces)** - Deployment platform
|
| 159 |
+
|
| 160 |
+
---
|
| 161 |
+
|
| 162 |
+
## Local Development
|
| 163 |
+
|
| 164 |
+
### Prerequisites
|
| 165 |
+
|
| 166 |
+
- Python 3.10+
|
| 167 |
+
- UV or pip
|
| 168 |
+
- API keys for Gemini, OpenAI, and ElevenLabs
|
| 169 |
+
|
| 170 |
+
### Installation
|
| 171 |
+
|
| 172 |
+
```bash
|
| 173 |
+
# Clone the repository
|
| 174 |
+
git clone https://github.com/dungeonmaster-ai/dungeonmaster-ai.git
|
| 175 |
+
cd dungeonmaster-ai
|
| 176 |
+
|
| 177 |
+
# Create virtual environment
|
| 178 |
+
python -m venv .venv
|
| 179 |
+
source .venv/bin/activate # On Windows: .venv\Scripts\activate
|
| 180 |
+
|
| 181 |
+
# Install dependencies
|
| 182 |
+
pip install -r requirements.txt
|
| 183 |
+
|
| 184 |
+
# Copy environment file and add your API keys
|
| 185 |
+
cp .env.example .env
|
| 186 |
+
# Edit .env with your actual API keys
|
| 187 |
+
|
| 188 |
+
# Run the application
|
| 189 |
+
python -m ui.app
|
| 190 |
+
```
|
| 191 |
+
|
| 192 |
+
### Running with TTRPG Toolkit (Local)
|
| 193 |
+
|
| 194 |
+
```bash
|
| 195 |
+
# In one terminal, run the MCP server
|
| 196 |
+
cd ../TTRPG-Toolkit
|
| 197 |
+
python -m src.main
|
| 198 |
+
|
| 199 |
+
# In another terminal, run DungeonMaster AI
|
| 200 |
+
cd ../DungeonMaster-AI
|
| 201 |
+
python -m ui.app
|
| 202 |
+
```
|
| 203 |
+
|
| 204 |
+
---
|
| 205 |
+
|
| 206 |
+
## Project Structure
|
| 207 |
+
|
| 208 |
+
```
|
| 209 |
+
dungeonmaster-ai/
|
| 210 |
+
├── src/
|
| 211 |
+
│ ├── agents/ # LlamaIndex agents
|
| 212 |
+
│ ├── voice/ # ElevenLabs integration
|
| 213 |
+
│ ├── mcp_integration/ # TTRPG Toolkit MCP client
|
| 214 |
+
│ ├── game/ # Game state management
|
| 215 |
+
│ ├── config/ # Settings and prompts
|
| 216 |
+
│ └── utils/ # Helpers and formatters
|
| 217 |
+
├── ui/
|
| 218 |
+
│ ├── app.py # Main Gradio application
|
| 219 |
+
│ ├── components/ # UI components
|
| 220 |
+
│ ├── handlers/ # Event handlers
|
| 221 |
+
│ ├── themes/ # Fantasy theme
|
| 222 |
+
│ └── assets/ # Images and CSS
|
| 223 |
+
├── adventures/ # Pre-made adventure content
|
| 224 |
+
│ ├── tutorial_goblin_cave.json
|
| 225 |
+
│ ├── tavern_start.json
|
| 226 |
+
│ └── sample_characters/
|
| 227 |
+
├── tests/ # Test suite
|
| 228 |
+
└── docs/ # Documentation
|
| 229 |
+
```
|
| 230 |
+
|
| 231 |
+
---
|
| 232 |
+
|
| 233 |
+
## Adventures Included
|
| 234 |
+
|
| 235 |
+
### The Rusty Dragon Tavern
|
| 236 |
+
A classic sandbox start. Meet colorful NPCs, hear rumors, and find your first adventure hook. Perfect for open-ended exploration.
|
| 237 |
+
|
| 238 |
+
### The Goblin Cave
|
| 239 |
+
A beginner-friendly adventure where heroes investigate goblin raids. Learn combat, exploration, and puzzle-solving in a focused 30-45 minute experience.
|
| 240 |
+
|
| 241 |
+
---
|
| 242 |
+
|
| 243 |
+
## Sample Characters
|
| 244 |
+
|
| 245 |
+
Get started immediately with pre-made characters:
|
| 246 |
+
|
| 247 |
+
- **Valric the Bold** - Human Fighter, Level 1
|
| 248 |
+
- **Elara Starweaver** - High Elf Wizard, Level 1
|
| 249 |
+
- **Shade Quickfingers** - Halfling Rogue, Level 1
|
| 250 |
+
|
| 251 |
+
---
|
| 252 |
+
|
| 253 |
+
## Hackathon Submission
|
| 254 |
+
|
| 255 |
+
This project was built for the **MCP Hackathon 2025** on HuggingFace.
|
| 256 |
+
|
| 257 |
+
### Track
|
| 258 |
+
**Agentic Demo Track** - Showcasing the power of AI agents in gaming
|
| 259 |
+
|
| 260 |
+
### Sponsor Technologies Used
|
| 261 |
+
|
| 262 |
+
| Sponsor | Usage |
|
| 263 |
+
|---------|-------|
|
| 264 |
+
| **Gemini** | Primary LLM for DM narration and storytelling |
|
| 265 |
+
| **OpenAI** | Fallback LLM for reliability |
|
| 266 |
+
| **ElevenLabs** | Voice synthesis for immersive narration |
|
| 267 |
+
| **LlamaIndex** | Agent framework and RAG-powered rules lookup |
|
| 268 |
+
| **Blaxel** | TTRPG Toolkit MCP server hosting |
|
| 269 |
+
| **Gradio** | UI framework for the demo |
|
| 270 |
+
| **HuggingFace** | Application deployment |
|
| 271 |
+
|
| 272 |
+
---
|
| 273 |
+
|
| 274 |
+
## Contributing
|
| 275 |
+
|
| 276 |
+
Contributions are welcome! Please read our contributing guidelines before submitting PRs.
|
| 277 |
+
|
| 278 |
+
---
|
| 279 |
+
|
| 280 |
+
## License
|
| 281 |
+
|
| 282 |
+
MIT License - see [LICENSE](LICENSE) for details.
|
| 283 |
+
|
| 284 |
+
---
|
| 285 |
+
|
| 286 |
+
## Acknowledgments
|
| 287 |
+
|
| 288 |
+
- The D&D 5e SRD for game rules and content
|
| 289 |
+
- The MCP protocol for enabling modular game systems
|
| 290 |
+
- All the amazing open-source libraries that made this possible
|
| 291 |
+
- The hackathon organizers and sponsors
|
| 292 |
+
|
| 293 |
+
---
|
| 294 |
+
|
| 295 |
+
**May your rolls be ever in your favor!**
|
| 296 |
+
|
| 297 |
+
*Built with passion for tabletop gaming and AI technology.*
|
adventures/sample_characters/fighter_template.json
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"metadata": {
|
| 3 |
+
"template_name": "Valric the Bold",
|
| 4 |
+
"template_type": "fighter",
|
| 5 |
+
"description": "A classic sword-and-board fighter. Durable, straightforward, and reliable in combat.",
|
| 6 |
+
"playstyle": "Front-line defender and damage dealer. Simple to play, hard to kill."
|
| 7 |
+
},
|
| 8 |
+
|
| 9 |
+
"character": {
|
| 10 |
+
"name": "Valric the Bold",
|
| 11 |
+
"race": "Human",
|
| 12 |
+
"class": "Fighter",
|
| 13 |
+
"subclass": null,
|
| 14 |
+
"level": 1,
|
| 15 |
+
"background": "Soldier",
|
| 16 |
+
"alignment": "Lawful Good",
|
| 17 |
+
|
| 18 |
+
"ability_scores": {
|
| 19 |
+
"strength": 16,
|
| 20 |
+
"dexterity": 12,
|
| 21 |
+
"constitution": 15,
|
| 22 |
+
"intelligence": 10,
|
| 23 |
+
"wisdom": 13,
|
| 24 |
+
"charisma": 8
|
| 25 |
+
},
|
| 26 |
+
|
| 27 |
+
"proficiency_bonus": 2,
|
| 28 |
+
|
| 29 |
+
"saving_throws": {
|
| 30 |
+
"strength": 5,
|
| 31 |
+
"constitution": 4
|
| 32 |
+
},
|
| 33 |
+
|
| 34 |
+
"skills": {
|
| 35 |
+
"athletics": 5,
|
| 36 |
+
"intimidation": 1,
|
| 37 |
+
"perception": 3,
|
| 38 |
+
"survival": 3
|
| 39 |
+
},
|
| 40 |
+
|
| 41 |
+
"armor_class": 18,
|
| 42 |
+
"armor_class_breakdown": "Chain mail (16) + Shield (2)",
|
| 43 |
+
|
| 44 |
+
"hit_points": {
|
| 45 |
+
"maximum": 12,
|
| 46 |
+
"current": 12,
|
| 47 |
+
"temporary": 0
|
| 48 |
+
},
|
| 49 |
+
|
| 50 |
+
"hit_dice": {
|
| 51 |
+
"total": "1d10",
|
| 52 |
+
"remaining": 1
|
| 53 |
+
},
|
| 54 |
+
|
| 55 |
+
"speed": 30,
|
| 56 |
+
|
| 57 |
+
"attacks": [
|
| 58 |
+
{
|
| 59 |
+
"name": "Longsword",
|
| 60 |
+
"attack_bonus": 5,
|
| 61 |
+
"damage": "1d8+3",
|
| 62 |
+
"damage_type": "slashing",
|
| 63 |
+
"properties": ["versatile (1d10)"]
|
| 64 |
+
},
|
| 65 |
+
{
|
| 66 |
+
"name": "Longsword (Two-Handed)",
|
| 67 |
+
"attack_bonus": 5,
|
| 68 |
+
"damage": "1d10+3",
|
| 69 |
+
"damage_type": "slashing",
|
| 70 |
+
"properties": ["versatile"],
|
| 71 |
+
"notes": "When not using shield"
|
| 72 |
+
},
|
| 73 |
+
{
|
| 74 |
+
"name": "Handaxe (Thrown)",
|
| 75 |
+
"attack_bonus": 5,
|
| 76 |
+
"damage": "1d6+3",
|
| 77 |
+
"damage_type": "slashing",
|
| 78 |
+
"range": "20/60",
|
| 79 |
+
"properties": ["light", "thrown"]
|
| 80 |
+
}
|
| 81 |
+
],
|
| 82 |
+
|
| 83 |
+
"equipment": {
|
| 84 |
+
"weapons": ["Longsword", "Shield", "2 Handaxes"],
|
| 85 |
+
"armor": ["Chain Mail"],
|
| 86 |
+
"other": [
|
| 87 |
+
"Explorer's Pack",
|
| 88 |
+
"Insignia of rank (military)",
|
| 89 |
+
"Trophy from fallen enemy (broken blade)",
|
| 90 |
+
"Set of bone dice",
|
| 91 |
+
"Common clothes"
|
| 92 |
+
],
|
| 93 |
+
"currency": {
|
| 94 |
+
"gp": 10,
|
| 95 |
+
"sp": 0,
|
| 96 |
+
"cp": 0
|
| 97 |
+
}
|
| 98 |
+
},
|
| 99 |
+
|
| 100 |
+
"features": [
|
| 101 |
+
{
|
| 102 |
+
"name": "Fighting Style: Defense",
|
| 103 |
+
"description": "While wearing armor, you gain a +1 bonus to AC (already included)."
|
| 104 |
+
},
|
| 105 |
+
{
|
| 106 |
+
"name": "Second Wind",
|
| 107 |
+
"description": "On your turn, you can use a bonus action to regain 1d10 + 1 hit points. Once used, you must finish a short or long rest to use it again.",
|
| 108 |
+
"uses": 1,
|
| 109 |
+
"recharge": "short rest"
|
| 110 |
+
}
|
| 111 |
+
],
|
| 112 |
+
|
| 113 |
+
"proficiencies": {
|
| 114 |
+
"armor": ["All armor", "Shields"],
|
| 115 |
+
"weapons": ["Simple weapons", "Martial weapons"],
|
| 116 |
+
"tools": ["Playing card set", "Vehicles (land)"],
|
| 117 |
+
"languages": ["Common", "Dwarvish"]
|
| 118 |
+
},
|
| 119 |
+
|
| 120 |
+
"personality": {
|
| 121 |
+
"traits": [
|
| 122 |
+
"I'm always polite and respectful.",
|
| 123 |
+
"I face problems head-on. A simple, direct solution is the best path to success."
|
| 124 |
+
],
|
| 125 |
+
"ideals": ["Greater Good: Our lot is to lay down our lives in defense of others."],
|
| 126 |
+
"bonds": ["I fight for those who cannot fight for themselves."],
|
| 127 |
+
"flaws": ["I made a terrible mistake in battle that cost many lives, and I would do anything to keep that secret."]
|
| 128 |
+
},
|
| 129 |
+
|
| 130 |
+
"backstory": "Valric served in the king's army for five years, rising to the rank of sergeant. After a devastating battle where his unit was ambushed due to faulty intelligence, he was the sole survivor. Wracked with guilt and seeking redemption, he left military service to become an adventurer, hoping to save lives rather than take them. He's haunted by the faces of fallen comrades but channels that pain into protecting the innocent."
|
| 131 |
+
},
|
| 132 |
+
|
| 133 |
+
"tips": {
|
| 134 |
+
"combat": [
|
| 135 |
+
"Stay on the front line - you're the party's shield",
|
| 136 |
+
"Use your shield for high AC and tank hits",
|
| 137 |
+
"Save Second Wind for when you're below half HP",
|
| 138 |
+
"Handaxes are great for ranged enemies you can't reach"
|
| 139 |
+
],
|
| 140 |
+
"roleplay": [
|
| 141 |
+
"Valric is formal and military in manner",
|
| 142 |
+
"He's protective of allies, sometimes overly so",
|
| 143 |
+
"He has nightmares about his past failure",
|
| 144 |
+
"He respects authority but questions orders that endanger innocents"
|
| 145 |
+
]
|
| 146 |
+
}
|
| 147 |
+
}
|
adventures/sample_characters/rogue_template.json
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"metadata": {
|
| 3 |
+
"template_name": "Shade Quickfingers",
|
| 4 |
+
"template_type": "rogue",
|
| 5 |
+
"description": "A cunning halfling rogue specializing in stealth and precision strikes.",
|
| 6 |
+
"playstyle": "Sneak, scout, and strike from the shadows. High single-target damage with Sneak Attack."
|
| 7 |
+
},
|
| 8 |
+
|
| 9 |
+
"character": {
|
| 10 |
+
"name": "Shade Quickfingers",
|
| 11 |
+
"race": "Lightfoot Halfling",
|
| 12 |
+
"class": "Rogue",
|
| 13 |
+
"subclass": null,
|
| 14 |
+
"level": 1,
|
| 15 |
+
"background": "Criminal",
|
| 16 |
+
"alignment": "Chaotic Good",
|
| 17 |
+
|
| 18 |
+
"ability_scores": {
|
| 19 |
+
"strength": 8,
|
| 20 |
+
"dexterity": 17,
|
| 21 |
+
"constitution": 12,
|
| 22 |
+
"intelligence": 13,
|
| 23 |
+
"wisdom": 10,
|
| 24 |
+
"charisma": 14
|
| 25 |
+
},
|
| 26 |
+
|
| 27 |
+
"proficiency_bonus": 2,
|
| 28 |
+
|
| 29 |
+
"saving_throws": {
|
| 30 |
+
"dexterity": 5,
|
| 31 |
+
"intelligence": 3
|
| 32 |
+
},
|
| 33 |
+
|
| 34 |
+
"skills": {
|
| 35 |
+
"acrobatics": 5,
|
| 36 |
+
"deception": 4,
|
| 37 |
+
"perception": 2,
|
| 38 |
+
"sleight_of_hand": 7,
|
| 39 |
+
"stealth": 7,
|
| 40 |
+
"thieves_tools": 7
|
| 41 |
+
},
|
| 42 |
+
|
| 43 |
+
"expertise": ["Stealth", "Thieves' Tools"],
|
| 44 |
+
|
| 45 |
+
"armor_class": 14,
|
| 46 |
+
"armor_class_breakdown": "Leather armor (11) + Dexterity modifier (3)",
|
| 47 |
+
|
| 48 |
+
"hit_points": {
|
| 49 |
+
"maximum": 9,
|
| 50 |
+
"current": 9,
|
| 51 |
+
"temporary": 0
|
| 52 |
+
},
|
| 53 |
+
|
| 54 |
+
"hit_dice": {
|
| 55 |
+
"total": "1d8",
|
| 56 |
+
"remaining": 1
|
| 57 |
+
},
|
| 58 |
+
|
| 59 |
+
"speed": 25,
|
| 60 |
+
|
| 61 |
+
"attacks": [
|
| 62 |
+
{
|
| 63 |
+
"name": "Shortsword",
|
| 64 |
+
"attack_bonus": 5,
|
| 65 |
+
"damage": "1d6+3",
|
| 66 |
+
"damage_type": "piercing",
|
| 67 |
+
"properties": ["finesse", "light"],
|
| 68 |
+
"notes": "Add 1d6 Sneak Attack when applicable"
|
| 69 |
+
},
|
| 70 |
+
{
|
| 71 |
+
"name": "Dagger",
|
| 72 |
+
"attack_bonus": 5,
|
| 73 |
+
"damage": "1d4+3",
|
| 74 |
+
"damage_type": "piercing",
|
| 75 |
+
"range": "20/60",
|
| 76 |
+
"properties": ["finesse", "light", "thrown"],
|
| 77 |
+
"notes": "Add 1d6 Sneak Attack when applicable"
|
| 78 |
+
},
|
| 79 |
+
{
|
| 80 |
+
"name": "Shortbow",
|
| 81 |
+
"attack_bonus": 5,
|
| 82 |
+
"damage": "1d6+3",
|
| 83 |
+
"damage_type": "piercing",
|
| 84 |
+
"range": "80/320",
|
| 85 |
+
"properties": ["ammunition", "two-handed"],
|
| 86 |
+
"notes": "Add 1d6 Sneak Attack when applicable"
|
| 87 |
+
}
|
| 88 |
+
],
|
| 89 |
+
|
| 90 |
+
"equipment": {
|
| 91 |
+
"weapons": ["Shortsword", "Shortbow", "Quiver with 20 arrows", "2 Daggers"],
|
| 92 |
+
"armor": ["Leather Armor"],
|
| 93 |
+
"other": [
|
| 94 |
+
"Thieves' tools",
|
| 95 |
+
"Burglar's pack",
|
| 96 |
+
"Crowbar",
|
| 97 |
+
"Dark common clothes with hood",
|
| 98 |
+
"Belt pouch"
|
| 99 |
+
],
|
| 100 |
+
"currency": {
|
| 101 |
+
"gp": 15,
|
| 102 |
+
"sp": 0,
|
| 103 |
+
"cp": 0
|
| 104 |
+
}
|
| 105 |
+
},
|
| 106 |
+
|
| 107 |
+
"features": [
|
| 108 |
+
{
|
| 109 |
+
"name": "Sneak Attack",
|
| 110 |
+
"description": "Once per turn, deal extra 1d6 damage when you have advantage on attack, or when an ally is within 5 feet of target. Must use finesse or ranged weapon.",
|
| 111 |
+
"damage": "1d6"
|
| 112 |
+
},
|
| 113 |
+
{
|
| 114 |
+
"name": "Expertise",
|
| 115 |
+
"description": "Double proficiency bonus for Stealth and Thieves' Tools (already calculated)."
|
| 116 |
+
},
|
| 117 |
+
{
|
| 118 |
+
"name": "Thieves' Cant",
|
| 119 |
+
"description": "Secret language of thieves. Can convey hidden messages and recognize signs left by other criminals."
|
| 120 |
+
},
|
| 121 |
+
{
|
| 122 |
+
"name": "Lucky",
|
| 123 |
+
"description": "When you roll a 1 on an attack roll, ability check, or saving throw, reroll and use the new roll.",
|
| 124 |
+
"source": "Halfling"
|
| 125 |
+
},
|
| 126 |
+
{
|
| 127 |
+
"name": "Brave",
|
| 128 |
+
"description": "Advantage on saving throws against being frightened.",
|
| 129 |
+
"source": "Halfling"
|
| 130 |
+
},
|
| 131 |
+
{
|
| 132 |
+
"name": "Halfling Nimbleness",
|
| 133 |
+
"description": "Move through the space of any creature that is of a size larger than yours.",
|
| 134 |
+
"source": "Halfling"
|
| 135 |
+
},
|
| 136 |
+
{
|
| 137 |
+
"name": "Naturally Stealthy",
|
| 138 |
+
"description": "Can attempt to hide even when obscured only by a creature at least one size larger than you.",
|
| 139 |
+
"source": "Lightfoot Halfling"
|
| 140 |
+
}
|
| 141 |
+
],
|
| 142 |
+
|
| 143 |
+
"proficiencies": {
|
| 144 |
+
"armor": ["Light armor"],
|
| 145 |
+
"weapons": ["Simple weapons", "Hand crossbows", "Longswords", "Rapiers", "Shortswords"],
|
| 146 |
+
"tools": ["Thieves' tools", "Playing card set", "Disguise kit"],
|
| 147 |
+
"languages": ["Common", "Halfling", "Thieves' Cant"]
|
| 148 |
+
},
|
| 149 |
+
|
| 150 |
+
"personality": {
|
| 151 |
+
"traits": [
|
| 152 |
+
"I always have a plan for what to do when things go wrong.",
|
| 153 |
+
"I am incredibly slow to trust. Those who seem fairest often have the most to hide."
|
| 154 |
+
],
|
| 155 |
+
"ideals": ["Freedom: Chains are meant to be broken, as are those who would forge them."],
|
| 156 |
+
"bonds": ["I'm trying to pay off a debt I owe to a generous benefactor who saved my life."],
|
| 157 |
+
"flaws": ["When I see something valuable, I can't think about anything but how to steal it."]
|
| 158 |
+
},
|
| 159 |
+
|
| 160 |
+
"backstory": "Shade grew up on the streets of Baldur's Gate, learning to survive through quick fingers and quicker wits. They fell in with the Guild, becoming a capable second-story worker. But when ordered to rob a family that reminded them of their own lost parents, Shade refused and fled the city with a price on their head. Now they use their skills for good, redistributing wealth from the corrupt to those in need. They're still paying off the debt to the mysterious benefactor who helped them escape - a debt that seems to grow rather than shrink."
|
| 161 |
+
},
|
| 162 |
+
|
| 163 |
+
"tips": {
|
| 164 |
+
"combat": [
|
| 165 |
+
"Always try to get Sneak Attack - it's most of your damage",
|
| 166 |
+
"Have an ally engage the enemy, then attack for Sneak Attack",
|
| 167 |
+
"Use Cunning Action (level 2) to Disengage after attacking",
|
| 168 |
+
"The shortbow lets you sneak attack from safety",
|
| 169 |
+
"Hide behind larger allies using Naturally Stealthy"
|
| 170 |
+
],
|
| 171 |
+
"roleplay": [
|
| 172 |
+
"Shade is cautious and always looking for exits",
|
| 173 |
+
"They have a Robin Hood mentality - steal from the rich",
|
| 174 |
+
"They're paranoid about the Guild finding them",
|
| 175 |
+
"Despite their past, they have a good heart"
|
| 176 |
+
]
|
| 177 |
+
}
|
| 178 |
+
}
|
adventures/sample_characters/wizard_template.json
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"metadata": {
|
| 3 |
+
"template_name": "Elara Starweaver",
|
| 4 |
+
"template_type": "wizard",
|
| 5 |
+
"description": "An elven wizard specializing in evocation. Glass cannon with powerful offensive magic.",
|
| 6 |
+
"playstyle": "Stay in the back, cast spells, avoid getting hit. High damage but fragile."
|
| 7 |
+
},
|
| 8 |
+
|
| 9 |
+
"character": {
|
| 10 |
+
"name": "Elara Starweaver",
|
| 11 |
+
"race": "High Elf",
|
| 12 |
+
"class": "Wizard",
|
| 13 |
+
"subclass": null,
|
| 14 |
+
"level": 1,
|
| 15 |
+
"background": "Sage",
|
| 16 |
+
"alignment": "Neutral Good",
|
| 17 |
+
|
| 18 |
+
"ability_scores": {
|
| 19 |
+
"strength": 8,
|
| 20 |
+
"dexterity": 14,
|
| 21 |
+
"constitution": 13,
|
| 22 |
+
"intelligence": 16,
|
| 23 |
+
"wisdom": 12,
|
| 24 |
+
"charisma": 10
|
| 25 |
+
},
|
| 26 |
+
|
| 27 |
+
"proficiency_bonus": 2,
|
| 28 |
+
|
| 29 |
+
"saving_throws": {
|
| 30 |
+
"intelligence": 5,
|
| 31 |
+
"wisdom": 3
|
| 32 |
+
},
|
| 33 |
+
|
| 34 |
+
"skills": {
|
| 35 |
+
"arcana": 5,
|
| 36 |
+
"history": 5,
|
| 37 |
+
"investigation": 5,
|
| 38 |
+
"perception": 3
|
| 39 |
+
},
|
| 40 |
+
|
| 41 |
+
"armor_class": 12,
|
| 42 |
+
"armor_class_breakdown": "10 + Dexterity modifier (unarmored)",
|
| 43 |
+
|
| 44 |
+
"hit_points": {
|
| 45 |
+
"maximum": 7,
|
| 46 |
+
"current": 7,
|
| 47 |
+
"temporary": 0
|
| 48 |
+
},
|
| 49 |
+
|
| 50 |
+
"hit_dice": {
|
| 51 |
+
"total": "1d6",
|
| 52 |
+
"remaining": 1
|
| 53 |
+
},
|
| 54 |
+
|
| 55 |
+
"speed": 30,
|
| 56 |
+
|
| 57 |
+
"attacks": [
|
| 58 |
+
{
|
| 59 |
+
"name": "Fire Bolt",
|
| 60 |
+
"attack_bonus": 5,
|
| 61 |
+
"damage": "1d10",
|
| 62 |
+
"damage_type": "fire",
|
| 63 |
+
"range": "120 feet",
|
| 64 |
+
"properties": ["cantrip"]
|
| 65 |
+
},
|
| 66 |
+
{
|
| 67 |
+
"name": "Quarterstaff",
|
| 68 |
+
"attack_bonus": 1,
|
| 69 |
+
"damage": "1d6-1",
|
| 70 |
+
"damage_type": "bludgeoning",
|
| 71 |
+
"properties": ["versatile (1d8)"]
|
| 72 |
+
},
|
| 73 |
+
{
|
| 74 |
+
"name": "Dagger",
|
| 75 |
+
"attack_bonus": 4,
|
| 76 |
+
"damage": "1d4+2",
|
| 77 |
+
"damage_type": "piercing",
|
| 78 |
+
"range": "20/60",
|
| 79 |
+
"properties": ["finesse", "light", "thrown"]
|
| 80 |
+
}
|
| 81 |
+
],
|
| 82 |
+
|
| 83 |
+
"spellcasting": {
|
| 84 |
+
"spellcasting_ability": "Intelligence",
|
| 85 |
+
"spell_save_dc": 13,
|
| 86 |
+
"spell_attack_bonus": 5,
|
| 87 |
+
|
| 88 |
+
"spell_slots": {
|
| 89 |
+
"1st": 2
|
| 90 |
+
},
|
| 91 |
+
|
| 92 |
+
"cantrips": [
|
| 93 |
+
{
|
| 94 |
+
"name": "Fire Bolt",
|
| 95 |
+
"casting_time": "1 action",
|
| 96 |
+
"range": "120 feet",
|
| 97 |
+
"components": "V, S",
|
| 98 |
+
"duration": "Instantaneous",
|
| 99 |
+
"description": "Hurl a mote of fire. Ranged spell attack for 1d10 fire damage."
|
| 100 |
+
},
|
| 101 |
+
{
|
| 102 |
+
"name": "Mage Hand",
|
| 103 |
+
"casting_time": "1 action",
|
| 104 |
+
"range": "30 feet",
|
| 105 |
+
"components": "V, S",
|
| 106 |
+
"duration": "1 minute",
|
| 107 |
+
"description": "A spectral hand that can manipulate objects up to 10 pounds."
|
| 108 |
+
},
|
| 109 |
+
{
|
| 110 |
+
"name": "Prestidigitation",
|
| 111 |
+
"casting_time": "1 action",
|
| 112 |
+
"range": "10 feet",
|
| 113 |
+
"components": "V, S",
|
| 114 |
+
"duration": "Up to 1 hour",
|
| 115 |
+
"description": "Minor magical tricks for practicing spellcasters."
|
| 116 |
+
},
|
| 117 |
+
{
|
| 118 |
+
"name": "Light",
|
| 119 |
+
"casting_time": "1 action",
|
| 120 |
+
"range": "Touch",
|
| 121 |
+
"components": "V, M (firefly or phosphorescent moss)",
|
| 122 |
+
"duration": "1 hour",
|
| 123 |
+
"description": "Object sheds bright light in 20-foot radius. (High Elf bonus cantrip)"
|
| 124 |
+
}
|
| 125 |
+
],
|
| 126 |
+
|
| 127 |
+
"prepared_spells": [
|
| 128 |
+
{
|
| 129 |
+
"name": "Magic Missile",
|
| 130 |
+
"level": 1,
|
| 131 |
+
"casting_time": "1 action",
|
| 132 |
+
"range": "120 feet",
|
| 133 |
+
"components": "V, S",
|
| 134 |
+
"duration": "Instantaneous",
|
| 135 |
+
"description": "Three darts of magical force hit automatically for 1d4+1 force damage each."
|
| 136 |
+
},
|
| 137 |
+
{
|
| 138 |
+
"name": "Sleep",
|
| 139 |
+
"level": 1,
|
| 140 |
+
"casting_time": "1 action",
|
| 141 |
+
"range": "90 feet",
|
| 142 |
+
"components": "V, S, M (sand)",
|
| 143 |
+
"duration": "1 minute",
|
| 144 |
+
"description": "5d8 HP worth of creatures fall unconscious, starting with lowest HP."
|
| 145 |
+
},
|
| 146 |
+
{
|
| 147 |
+
"name": "Shield",
|
| 148 |
+
"level": 1,
|
| 149 |
+
"casting_time": "1 reaction",
|
| 150 |
+
"range": "Self",
|
| 151 |
+
"components": "V, S",
|
| 152 |
+
"duration": "1 round",
|
| 153 |
+
"description": "+5 AC until start of next turn, including against triggering attack."
|
| 154 |
+
},
|
| 155 |
+
{
|
| 156 |
+
"name": "Detect Magic",
|
| 157 |
+
"level": 1,
|
| 158 |
+
"casting_time": "1 action (ritual)",
|
| 159 |
+
"range": "Self",
|
| 160 |
+
"components": "V, S",
|
| 161 |
+
"duration": "10 minutes (concentration)",
|
| 162 |
+
"description": "Sense magic within 30 feet. Can see aura and determine school."
|
| 163 |
+
}
|
| 164 |
+
],
|
| 165 |
+
|
| 166 |
+
"spellbook": [
|
| 167 |
+
"Magic Missile",
|
| 168 |
+
"Sleep",
|
| 169 |
+
"Shield",
|
| 170 |
+
"Detect Magic",
|
| 171 |
+
"Mage Armor",
|
| 172 |
+
"Thunderwave"
|
| 173 |
+
]
|
| 174 |
+
},
|
| 175 |
+
|
| 176 |
+
"equipment": {
|
| 177 |
+
"weapons": ["Quarterstaff", "Dagger"],
|
| 178 |
+
"armor": [],
|
| 179 |
+
"other": [
|
| 180 |
+
"Spellbook",
|
| 181 |
+
"Component pouch",
|
| 182 |
+
"Scholar's pack",
|
| 183 |
+
"Bottle of black ink",
|
| 184 |
+
"Quill",
|
| 185 |
+
"Small knife",
|
| 186 |
+
"Letter from dead colleague with unanswered question",
|
| 187 |
+
"Common clothes"
|
| 188 |
+
],
|
| 189 |
+
"currency": {
|
| 190 |
+
"gp": 10,
|
| 191 |
+
"sp": 0,
|
| 192 |
+
"cp": 0
|
| 193 |
+
}
|
| 194 |
+
},
|
| 195 |
+
|
| 196 |
+
"features": [
|
| 197 |
+
{
|
| 198 |
+
"name": "Arcane Recovery",
|
| 199 |
+
"description": "Once per day during a short rest, recover spell slots up to half your wizard level (rounded up). At level 1, recover one 1st-level slot.",
|
| 200 |
+
"uses": 1,
|
| 201 |
+
"recharge": "long rest"
|
| 202 |
+
},
|
| 203 |
+
{
|
| 204 |
+
"name": "Fey Ancestry",
|
| 205 |
+
"description": "Advantage on saving throws against being charmed. Magic can't put you to sleep.",
|
| 206 |
+
"source": "High Elf"
|
| 207 |
+
},
|
| 208 |
+
{
|
| 209 |
+
"name": "Darkvision",
|
| 210 |
+
"description": "See in dim light within 60 feet as if bright light, and darkness as dim light.",
|
| 211 |
+
"source": "High Elf"
|
| 212 |
+
},
|
| 213 |
+
{
|
| 214 |
+
"name": "Trance",
|
| 215 |
+
"description": "Elves don't sleep. Instead, meditate deeply for 4 hours to gain rest benefits.",
|
| 216 |
+
"source": "High Elf"
|
| 217 |
+
}
|
| 218 |
+
],
|
| 219 |
+
|
| 220 |
+
"proficiencies": {
|
| 221 |
+
"armor": [],
|
| 222 |
+
"weapons": ["Daggers", "Darts", "Slings", "Quarterstaffs", "Light crossbows", "Longsword", "Shortsword", "Shortbow", "Longbow"],
|
| 223 |
+
"tools": [],
|
| 224 |
+
"languages": ["Common", "Elvish", "Draconic", "Celestial"]
|
| 225 |
+
},
|
| 226 |
+
|
| 227 |
+
"personality": {
|
| 228 |
+
"traits": [
|
| 229 |
+
"I'm used to helping out those who aren't as smart as I am.",
|
| 230 |
+
"I'm willing to listen to every side of an argument before I make my own judgment."
|
| 231 |
+
],
|
| 232 |
+
"ideals": ["Knowledge: The path to power and self-improvement is through knowledge."],
|
| 233 |
+
"bonds": ["I've been searching my whole life for the answer to a certain question about the nature of magic."],
|
| 234 |
+
"flaws": ["I overlook obvious solutions in favor of complicated ones."]
|
| 235 |
+
},
|
| 236 |
+
|
| 237 |
+
"backstory": "Elara spent two centuries in the great elven library of Myth Drannor, studying the fundamental nature of magic. When she discovered a fragment of a text hinting at a unified theory of arcane and divine magic, she left the safety of her studies to seek more fragments scattered across the world. She's brilliant but sometimes condescending, and tends to overcomplicate simple problems. The destroyed library haunts her dreams - she arrived a day late to save it."
|
| 238 |
+
},
|
| 239 |
+
|
| 240 |
+
"tips": {
|
| 241 |
+
"combat": [
|
| 242 |
+
"Stay FAR from enemies - you have only 7 HP!",
|
| 243 |
+
"Fire Bolt is your bread and butter cantrip",
|
| 244 |
+
"Magic Missile never misses - use on important targets",
|
| 245 |
+
"Sleep can trivialize early encounters",
|
| 246 |
+
"Save Shield for critical hits or when you'll drop to 0 HP"
|
| 247 |
+
],
|
| 248 |
+
"roleplay": [
|
| 249 |
+
"Elara speaks formally and uses academic terminology",
|
| 250 |
+
"She's curious about all forms of magic",
|
| 251 |
+
"She can be condescending but means well",
|
| 252 |
+
"She's haunted by the loss of her library"
|
| 253 |
+
]
|
| 254 |
+
}
|
| 255 |
+
}
|
adventures/tavern_start.json
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"metadata": {
|
| 3 |
+
"name": "The Rusty Dragon Tavern",
|
| 4 |
+
"description": "A classic tavern start. Meet colorful NPCs, hear rumors, and find your first adventure hook. The perfect sandbox beginning.",
|
| 5 |
+
"difficulty": "easy",
|
| 6 |
+
"estimated_time": "open-ended",
|
| 7 |
+
"recommended_level": 1,
|
| 8 |
+
"tags": ["social", "sandbox", "roleplay", "starter"],
|
| 9 |
+
"author": "DungeonMaster AI",
|
| 10 |
+
"version": "1.0.0"
|
| 11 |
+
},
|
| 12 |
+
|
| 13 |
+
"starting_scene": {
|
| 14 |
+
"scene_id": "tavern_common_room",
|
| 15 |
+
"image": "tavern",
|
| 16 |
+
"narrative": "The door of the Rusty Dragon creaks open, releasing a wave of warmth, laughter, and the mouthwatering aroma of roasting meat. This famous establishment sits at the crossroads of adventure - a place where mercenaries find work, travelers share tales, and heroes are born. The common room buzzes with activity. A crackling fire wards off the evening chill. What catches your attention first?",
|
| 17 |
+
"context": {
|
| 18 |
+
"time_of_day": "evening",
|
| 19 |
+
"weather": "clear but cold outside",
|
| 20 |
+
"mood": "warm and bustling"
|
| 21 |
+
}
|
| 22 |
+
},
|
| 23 |
+
|
| 24 |
+
"scenes": [
|
| 25 |
+
{
|
| 26 |
+
"scene_id": "tavern_common_room",
|
| 27 |
+
"name": "The Common Room",
|
| 28 |
+
"image": "tavern",
|
| 29 |
+
"description": "The heart of the Rusty Dragon - a large room filled with wooden tables, a roaring fireplace, and the sounds of merriment.",
|
| 30 |
+
"details": {
|
| 31 |
+
"sensory": {
|
| 32 |
+
"sight": "Adventurers of all stripes crowd the tables. A half-orc arm-wrestles a dwarf. A hooded figure sits alone in the corner. The bartender, a jovial woman, pulls ale from a massive cask.",
|
| 33 |
+
"sound": "Laughter, the clink of mugs, a bard strumming in the corner, and snippets of a dozen conversations.",
|
| 34 |
+
"smell": "Roasted boar, fresh bread, spilled ale, and pipe smoke."
|
| 35 |
+
},
|
| 36 |
+
"notable_features": [
|
| 37 |
+
"A job board near the entrance, covered in notices",
|
| 38 |
+
"A large fireplace with comfortable chairs",
|
| 39 |
+
"The bar where drinks flow freely",
|
| 40 |
+
"A corner where a mysterious figure lurks"
|
| 41 |
+
]
|
| 42 |
+
},
|
| 43 |
+
"exits": {
|
| 44 |
+
"outside": "village_square",
|
| 45 |
+
"upstairs": "inn_rooms",
|
| 46 |
+
"back": "kitchen"
|
| 47 |
+
},
|
| 48 |
+
"npcs_present": ["marta_innkeeper", "bard_silverstring", "mysterious_stranger", "gruff_dwarf"],
|
| 49 |
+
"items": [],
|
| 50 |
+
"encounter": null
|
| 51 |
+
},
|
| 52 |
+
{
|
| 53 |
+
"scene_id": "village_square",
|
| 54 |
+
"name": "Crossroads Village Square",
|
| 55 |
+
"image": "village",
|
| 56 |
+
"description": "The village square where several roads meet. Shops line the streets, and a notice board stands near a well.",
|
| 57 |
+
"details": {
|
| 58 |
+
"sensory": {
|
| 59 |
+
"sight": "Cobblestone streets lit by lanterns. A general store, blacksmith, and temple are visible.",
|
| 60 |
+
"sound": "The distant bark of a dog. The town bell marking the hour.",
|
| 61 |
+
"smell": "Night air, coal smoke from the smithy."
|
| 62 |
+
}
|
| 63 |
+
},
|
| 64 |
+
"exits": {
|
| 65 |
+
"tavern": "tavern_common_room",
|
| 66 |
+
"north_road": "north_road_start",
|
| 67 |
+
"east_road": "east_road_start",
|
| 68 |
+
"general_store": "general_store",
|
| 69 |
+
"blacksmith": "blacksmith_shop"
|
| 70 |
+
},
|
| 71 |
+
"npcs_present": [],
|
| 72 |
+
"items": [],
|
| 73 |
+
"encounter": null
|
| 74 |
+
},
|
| 75 |
+
{
|
| 76 |
+
"scene_id": "inn_rooms",
|
| 77 |
+
"name": "Inn Rooms (Upstairs)",
|
| 78 |
+
"image": "tavern",
|
| 79 |
+
"description": "A hallway with several doors leading to modest but clean rooms for rent.",
|
| 80 |
+
"details": {
|
| 81 |
+
"sensory": {
|
| 82 |
+
"sight": "Worn wooden floors, numbered doors, a window overlooking the square.",
|
| 83 |
+
"sound": "Muffled revelry from below. Someone snoring behind one door.",
|
| 84 |
+
"smell": "Clean linens and old wood."
|
| 85 |
+
}
|
| 86 |
+
},
|
| 87 |
+
"exits": {
|
| 88 |
+
"downstairs": "tavern_common_room"
|
| 89 |
+
},
|
| 90 |
+
"npcs_present": [],
|
| 91 |
+
"items": [],
|
| 92 |
+
"encounter": null
|
| 93 |
+
}
|
| 94 |
+
],
|
| 95 |
+
|
| 96 |
+
"npcs": [
|
| 97 |
+
{
|
| 98 |
+
"npc_id": "marta_innkeeper",
|
| 99 |
+
"name": "Marta Brightkettle",
|
| 100 |
+
"description": "A stout, red-cheeked woman with kind eyes and a booming laugh. She runs the Rusty Dragon with an iron will and a warm heart.",
|
| 101 |
+
"personality": "Motherly but shrewd. Knows everyone's business but keeps secrets well. Quick with advice and quicker with a rolling pin for troublemakers.",
|
| 102 |
+
"voice_profile": "npc_female_gentle",
|
| 103 |
+
"dialogue_hooks": [
|
| 104 |
+
"Welcome, welcome! Sit yourself down. You look like you could use a hot meal and a cold drink!",
|
| 105 |
+
"New in town, are you? Well, you've come to the right place. The Rusty Dragon sees all sorts.",
|
| 106 |
+
"Looking for work? Check the notice board. Plenty of folk need capable hands these days.",
|
| 107 |
+
"That one in the corner? Been here three days, hardly speaks. Gives me the shivers, but pays in gold."
|
| 108 |
+
],
|
| 109 |
+
"knowledge": [
|
| 110 |
+
"Knows all the local gossip",
|
| 111 |
+
"Can point adventurers to the job board",
|
| 112 |
+
"Knows the mysterious stranger has been asking about 'the old tower'",
|
| 113 |
+
"Her husband was an adventurer who never returned from the northern mountains"
|
| 114 |
+
],
|
| 115 |
+
"services": {
|
| 116 |
+
"room": "5 sp per night",
|
| 117 |
+
"meal": "3 sp",
|
| 118 |
+
"ale": "4 cp",
|
| 119 |
+
"information": "Freely given to paying customers"
|
| 120 |
+
}
|
| 121 |
+
},
|
| 122 |
+
{
|
| 123 |
+
"npc_id": "bard_silverstring",
|
| 124 |
+
"name": "Lyric Silverstring",
|
| 125 |
+
"description": "A flamboyant half-elf bard with silver-streaked hair and an infectious smile. Plays a well-worn lute.",
|
| 126 |
+
"personality": "Dramatic, curious, and always looking for the next great story. Collects tales of heroism.",
|
| 127 |
+
"voice_profile": "npc_mysterious",
|
| 128 |
+
"dialogue_hooks": [
|
| 129 |
+
"Ah, new faces! New stories! Come, sit, tell me of your travels!",
|
| 130 |
+
"I've performed from Waterdeep to Baldur's Gate, but the best tales are found in places like this.",
|
| 131 |
+
"Looking for adventure? I heard the merchant over there needs an escort through the Thornwood.",
|
| 132 |
+
"Buy me a drink and I'll tell you the legend of the Weeping Tower. Locals don't like to speak of it..."
|
| 133 |
+
],
|
| 134 |
+
"knowledge": [
|
| 135 |
+
"Knows legends and lore of the region",
|
| 136 |
+
"Has heard rumors of undead in the northern hills",
|
| 137 |
+
"Knows the mysterious stranger is a wizard seeking something",
|
| 138 |
+
"Can tell the full story of the Weeping Tower for a drink"
|
| 139 |
+
],
|
| 140 |
+
"quest_hooks": [
|
| 141 |
+
"Offers 20 gp for any adventurer who brings back a firsthand account of something legendary"
|
| 142 |
+
]
|
| 143 |
+
},
|
| 144 |
+
{
|
| 145 |
+
"npc_id": "mysterious_stranger",
|
| 146 |
+
"name": "The Hooded Figure (Valdris)",
|
| 147 |
+
"description": "A thin figure in a dark cloak, face hidden in shadow. Pale hands curl around an untouched drink. Arcane symbols occasionally flicker on their sleeves.",
|
| 148 |
+
"personality": "Secretive, calculating, but ultimately good-intentioned. A wizard on a personal quest.",
|
| 149 |
+
"voice_profile": "npc_mysterious",
|
| 150 |
+
"dialogue_hooks": [
|
| 151 |
+
"...You have the look of capable souls. Perhaps we can help each other.",
|
| 152 |
+
"I seek the Weeping Tower. Dark forces stir there, and I must stop them before it's too late.",
|
| 153 |
+
"I am Valdris. My order has watched the tower for centuries. Now, something has awakened.",
|
| 154 |
+
"I can offer gold, knowledge, magical aid. I only need brave companions willing to face the darkness."
|
| 155 |
+
],
|
| 156 |
+
"knowledge": [
|
| 157 |
+
"Knows the location of the Weeping Tower",
|
| 158 |
+
"Knows a necromancer has taken residence there",
|
| 159 |
+
"Has a map to the tower",
|
| 160 |
+
"Can identify magical items"
|
| 161 |
+
],
|
| 162 |
+
"quest_hooks": [
|
| 163 |
+
"Offers 100 gp each to investigate the Weeping Tower",
|
| 164 |
+
"Provides a potion of healing to each party member who agrees",
|
| 165 |
+
"Promises magical knowledge as additional reward"
|
| 166 |
+
]
|
| 167 |
+
},
|
| 168 |
+
{
|
| 169 |
+
"npc_id": "gruff_dwarf",
|
| 170 |
+
"name": "Thorin Ironbeard",
|
| 171 |
+
"description": "A heavily scarred dwarf in worn armor, nursing a tankard. His shield bears the marks of many battles.",
|
| 172 |
+
"personality": "Gruff, skeptical of newcomers, but fiercely loyal once trust is earned. Veteran adventurer.",
|
| 173 |
+
"voice_profile": "npc_male_gruff",
|
| 174 |
+
"dialogue_hooks": [
|
| 175 |
+
"*grunts* Another fresh face looking for glory. Hope ye last longer than the last ones.",
|
| 176 |
+
"I've been delving dungeons since before yer parents were born. Want advice? Don't trust maps.",
|
| 177 |
+
"That wizard in the corner? Been trying to recruit fools for days. Job's too dangerous, I say.",
|
| 178 |
+
"Buy me an ale and I'll tell ye about the time I fought a dragon. Mostly true, too."
|
| 179 |
+
],
|
| 180 |
+
"knowledge": [
|
| 181 |
+
"Veteran adventurer with practical dungeon experience",
|
| 182 |
+
"Knows the northern mountains are full of orc tribes",
|
| 183 |
+
"Has heard of the Weeping Tower - lost a friend there years ago",
|
| 184 |
+
"Can give practical combat advice"
|
| 185 |
+
],
|
| 186 |
+
"quest_hooks": [
|
| 187 |
+
"Might join the party if impressed by their character",
|
| 188 |
+
"Knows the location of an old dwarven outpost with rumored treasure"
|
| 189 |
+
]
|
| 190 |
+
}
|
| 191 |
+
],
|
| 192 |
+
|
| 193 |
+
"job_board_notices": [
|
| 194 |
+
{
|
| 195 |
+
"title": "RATS IN THE CELLAR",
|
| 196 |
+
"description": "The general store has a rat problem. Nothing a capable adventurer can't handle.",
|
| 197 |
+
"reward": "5 gp",
|
| 198 |
+
"difficulty": "trivial",
|
| 199 |
+
"contact": "Merchant Gravis at the General Store"
|
| 200 |
+
},
|
| 201 |
+
{
|
| 202 |
+
"title": "MISSING LIVESTOCK",
|
| 203 |
+
"description": "Something has been taking sheep from the Miller farm at night. Tracks lead to the forest.",
|
| 204 |
+
"reward": "20 gp",
|
| 205 |
+
"difficulty": "easy",
|
| 206 |
+
"contact": "Farmer Miller, north road, first farm"
|
| 207 |
+
},
|
| 208 |
+
{
|
| 209 |
+
"title": "ESCORT NEEDED",
|
| 210 |
+
"description": "Merchant caravan needs guards for journey through Thornwood. Three days travel.",
|
| 211 |
+
"reward": "15 gp per person",
|
| 212 |
+
"difficulty": "easy to medium",
|
| 213 |
+
"contact": "Merchant Consortium, departing in two days"
|
| 214 |
+
},
|
| 215 |
+
{
|
| 216 |
+
"title": "WANTED: INFORMATION",
|
| 217 |
+
"description": "Seeking any knowledge about the whereabouts of the wizard Mordecai. Last seen heading north.",
|
| 218 |
+
"reward": "50 gp for confirmed location",
|
| 219 |
+
"difficulty": "unknown",
|
| 220 |
+
"contact": "Ask Marta - anonymous posting"
|
| 221 |
+
},
|
| 222 |
+
{
|
| 223 |
+
"title": "HELP WANTED - BRAVE SOULS ONLY",
|
| 224 |
+
"description": "The old watchtower on the hill has shown lights at night. Town council seeks investigation.",
|
| 225 |
+
"reward": "30 gp plus whatever you find",
|
| 226 |
+
"difficulty": "medium",
|
| 227 |
+
"contact": "Mayor Aldric at Town Hall"
|
| 228 |
+
}
|
| 229 |
+
],
|
| 230 |
+
|
| 231 |
+
"rumors": [
|
| 232 |
+
{
|
| 233 |
+
"source": "overheard",
|
| 234 |
+
"rumor": "They say the dead walk in the northern hills at night. No one goes there anymore.",
|
| 235 |
+
"truth_level": "mostly_true"
|
| 236 |
+
},
|
| 237 |
+
{
|
| 238 |
+
"source": "drunk_patron",
|
| 239 |
+
"rumor": "My cousin saw a dragon flying over the mountains last month! Big as a barn!",
|
| 240 |
+
"truth_level": "exaggerated"
|
| 241 |
+
},
|
| 242 |
+
{
|
| 243 |
+
"source": "merchant",
|
| 244 |
+
"rumor": "Trade from the east has stopped. Something's happening in the Thornwood.",
|
| 245 |
+
"truth_level": "true"
|
| 246 |
+
},
|
| 247 |
+
{
|
| 248 |
+
"source": "old_timer",
|
| 249 |
+
"rumor": "The Weeping Tower has been silent for a hundred years. If lights are showing... that's bad news.",
|
| 250 |
+
"truth_level": "true"
|
| 251 |
+
}
|
| 252 |
+
],
|
| 253 |
+
|
| 254 |
+
"encounters": [],
|
| 255 |
+
|
| 256 |
+
"victory_conditions": {
|
| 257 |
+
"primary": {
|
| 258 |
+
"description": "This is a sandbox start - there is no set victory condition",
|
| 259 |
+
"trigger": "player_chooses_adventure",
|
| 260 |
+
"narrative": "The night stretches before you full of possibilities. Which path will you choose?"
|
| 261 |
+
},
|
| 262 |
+
"optional": []
|
| 263 |
+
},
|
| 264 |
+
|
| 265 |
+
"completion_narrative": "The Rusty Dragon has served its purpose - you've rested, gathered information, and found purpose. Whatever adventure you choose, you leave this tavern more prepared than when you arrived. The road ahead beckons. Where will your story take you?"
|
| 266 |
+
}
|
adventures/tutorial_goblin_cave.json
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"metadata": {
|
| 3 |
+
"name": "The Goblin Cave",
|
| 4 |
+
"description": "A beginner-friendly adventure where heroes investigate goblin raids on a nearby village. Perfect for learning the basics of D&D combat and exploration.",
|
| 5 |
+
"difficulty": "easy",
|
| 6 |
+
"estimated_time": "30-45 minutes",
|
| 7 |
+
"recommended_level": 1,
|
| 8 |
+
"tags": ["tutorial", "combat", "exploration", "goblins"],
|
| 9 |
+
"author": "DungeonMaster AI",
|
| 10 |
+
"version": "1.0.0"
|
| 11 |
+
},
|
| 12 |
+
|
| 13 |
+
"starting_scene": {
|
| 14 |
+
"scene_id": "forest_path",
|
| 15 |
+
"image": "forest",
|
| 16 |
+
"narrative": "The forest path winds before you, dappled sunlight filtering through ancient oaks. You've been hired by the village of Millbrook to investigate goblin raids that have plagued local farms. A woodcutter reported seeing the creatures disappear into the hills to the north. The trail of small footprints and discarded chicken bones leads you here, to the edge of a rocky outcropping where a dark cave mouth yawns like a hungry maw.",
|
| 17 |
+
"context": {
|
| 18 |
+
"time_of_day": "afternoon",
|
| 19 |
+
"weather": "clear",
|
| 20 |
+
"mood": "tense"
|
| 21 |
+
}
|
| 22 |
+
},
|
| 23 |
+
|
| 24 |
+
"scenes": [
|
| 25 |
+
{
|
| 26 |
+
"scene_id": "forest_path",
|
| 27 |
+
"name": "Forest Path",
|
| 28 |
+
"image": "forest",
|
| 29 |
+
"description": "A well-worn dirt path through dense forest. Broken branches and scattered refuse mark where goblins have passed.",
|
| 30 |
+
"details": {
|
| 31 |
+
"sensory": {
|
| 32 |
+
"sight": "Sunlight streams through the canopy. Small footprints dot the muddy path.",
|
| 33 |
+
"sound": "Birds have fallen silent. Distant chittering echoes from the hills.",
|
| 34 |
+
"smell": "Pine needles and something fouler - rotting meat perhaps."
|
| 35 |
+
},
|
| 36 |
+
"searchable": [
|
| 37 |
+
{
|
| 38 |
+
"object": "footprints",
|
| 39 |
+
"dc": 10,
|
| 40 |
+
"skill": "Survival",
|
| 41 |
+
"success": "At least six goblins passed this way recently, maybe within the last day. They were dragging something heavy.",
|
| 42 |
+
"failure": "The footprints are too jumbled to make out details."
|
| 43 |
+
}
|
| 44 |
+
]
|
| 45 |
+
},
|
| 46 |
+
"exits": {
|
| 47 |
+
"north": "cave_entrance",
|
| 48 |
+
"south": "millbrook_village"
|
| 49 |
+
},
|
| 50 |
+
"npcs_present": [],
|
| 51 |
+
"items": [],
|
| 52 |
+
"encounter": null
|
| 53 |
+
},
|
| 54 |
+
{
|
| 55 |
+
"scene_id": "cave_entrance",
|
| 56 |
+
"name": "Cave Entrance",
|
| 57 |
+
"image": "cave",
|
| 58 |
+
"description": "A jagged cave mouth opens in the hillside. Crude scratches mark the stone, and the stench of goblins wafts from within.",
|
| 59 |
+
"details": {
|
| 60 |
+
"sensory": {
|
| 61 |
+
"sight": "The cave opening is about 8 feet wide and 6 feet tall. Darkness swallows everything beyond the first few feet.",
|
| 62 |
+
"sound": "Faint cackling and the clatter of bones echoes from inside.",
|
| 63 |
+
"smell": "Unwashed bodies, spoiled food, and smoke."
|
| 64 |
+
},
|
| 65 |
+
"searchable": [
|
| 66 |
+
{
|
| 67 |
+
"object": "cave_entrance_area",
|
| 68 |
+
"dc": 12,
|
| 69 |
+
"skill": "Perception",
|
| 70 |
+
"success": "You spot a crude tripwire stretched across the entrance, about ankle height. A pile of loose stones sits precariously above.",
|
| 71 |
+
"failure": "The entrance appears unguarded."
|
| 72 |
+
}
|
| 73 |
+
],
|
| 74 |
+
"traps": [
|
| 75 |
+
{
|
| 76 |
+
"name": "Falling Rocks Trap",
|
| 77 |
+
"trigger": "tripwire at entrance",
|
| 78 |
+
"dc_detect": 12,
|
| 79 |
+
"dc_disarm": 10,
|
| 80 |
+
"effect": "2d6 bludgeoning damage, DC 12 Dexterity save for half",
|
| 81 |
+
"description": "Loose stones tumble down from above!"
|
| 82 |
+
}
|
| 83 |
+
]
|
| 84 |
+
},
|
| 85 |
+
"exits": {
|
| 86 |
+
"inside": "guard_room",
|
| 87 |
+
"south": "forest_path"
|
| 88 |
+
},
|
| 89 |
+
"npcs_present": [],
|
| 90 |
+
"items": [],
|
| 91 |
+
"encounter": null
|
| 92 |
+
},
|
| 93 |
+
{
|
| 94 |
+
"scene_id": "guard_room",
|
| 95 |
+
"name": "Guard Room",
|
| 96 |
+
"image": "dungeon",
|
| 97 |
+
"description": "A rough-hewn chamber lit by a sputtering torch. Two goblins crouch around a pile of dice, arguing in their guttural tongue.",
|
| 98 |
+
"details": {
|
| 99 |
+
"sensory": {
|
| 100 |
+
"sight": "Shadows dance on uneven walls. A passage continues deeper, and another branches left.",
|
| 101 |
+
"sound": "The goblins bicker over their gambling. One accuses the other of cheating.",
|
| 102 |
+
"smell": "Torch smoke and goblin musk."
|
| 103 |
+
}
|
| 104 |
+
},
|
| 105 |
+
"exits": {
|
| 106 |
+
"deeper": "chieftain_chamber",
|
| 107 |
+
"left": "treasure_room",
|
| 108 |
+
"back": "cave_entrance"
|
| 109 |
+
},
|
| 110 |
+
"npcs_present": [],
|
| 111 |
+
"items": [
|
| 112 |
+
{
|
| 113 |
+
"name": "Crude Dice",
|
| 114 |
+
"description": "Bone dice with suspicious weighting",
|
| 115 |
+
"value": "1 sp"
|
| 116 |
+
}
|
| 117 |
+
],
|
| 118 |
+
"encounter": {
|
| 119 |
+
"encounter_id": "goblin_guards",
|
| 120 |
+
"trigger": "automatic",
|
| 121 |
+
"surprise_dc": 12
|
| 122 |
+
}
|
| 123 |
+
},
|
| 124 |
+
{
|
| 125 |
+
"scene_id": "treasure_room",
|
| 126 |
+
"name": "Treasure Stash",
|
| 127 |
+
"image": "dungeon",
|
| 128 |
+
"description": "A smaller cave filled with the goblins' ill-gotten gains. Sacks of stolen grain, a chicken in a cage, and a small chest.",
|
| 129 |
+
"details": {
|
| 130 |
+
"sensory": {
|
| 131 |
+
"sight": "Heaps of mundane stolen goods. A locked wooden chest catches your eye.",
|
| 132 |
+
"sound": "The chicken clucks nervously.",
|
| 133 |
+
"smell": "Grain, feathers, and old leather."
|
| 134 |
+
},
|
| 135 |
+
"searchable": [
|
| 136 |
+
{
|
| 137 |
+
"object": "chest",
|
| 138 |
+
"dc": 12,
|
| 139 |
+
"skill": "Thieves' Tools or Strength",
|
| 140 |
+
"success": "The chest opens to reveal 25 gold pieces and a potion of healing!",
|
| 141 |
+
"failure": "The lock holds fast."
|
| 142 |
+
}
|
| 143 |
+
]
|
| 144 |
+
},
|
| 145 |
+
"exits": {
|
| 146 |
+
"back": "guard_room"
|
| 147 |
+
},
|
| 148 |
+
"npcs_present": [],
|
| 149 |
+
"items": [
|
| 150 |
+
{
|
| 151 |
+
"name": "Stolen Grain",
|
| 152 |
+
"description": "Sacks of wheat and barley from Millbrook farms",
|
| 153 |
+
"value": "5 gp"
|
| 154 |
+
},
|
| 155 |
+
{
|
| 156 |
+
"name": "Frightened Chicken",
|
| 157 |
+
"description": "A hen that seems relieved to see non-goblins",
|
| 158 |
+
"value": "2 sp"
|
| 159 |
+
}
|
| 160 |
+
],
|
| 161 |
+
"encounter": null
|
| 162 |
+
},
|
| 163 |
+
{
|
| 164 |
+
"scene_id": "chieftain_chamber",
|
| 165 |
+
"name": "Chieftain's Chamber",
|
| 166 |
+
"image": "dungeon",
|
| 167 |
+
"description": "The largest cave, where the goblin boss sits on a throne of bones. A massive wolf growls at his side.",
|
| 168 |
+
"details": {
|
| 169 |
+
"sensory": {
|
| 170 |
+
"sight": "A goblin larger than the others wears a rusty crown. His pet wolf bares yellowed fangs.",
|
| 171 |
+
"sound": "The wolf's low growl. The chieftain's cackling laugh.",
|
| 172 |
+
"smell": "Blood and wet fur."
|
| 173 |
+
}
|
| 174 |
+
},
|
| 175 |
+
"exits": {
|
| 176 |
+
"back": "guard_room"
|
| 177 |
+
},
|
| 178 |
+
"npcs_present": ["goblin_boss"],
|
| 179 |
+
"items": [
|
| 180 |
+
{
|
| 181 |
+
"name": "Rusty Crown",
|
| 182 |
+
"description": "A dented iron crown, worthless but treasured by its owner",
|
| 183 |
+
"value": "5 sp"
|
| 184 |
+
},
|
| 185 |
+
{
|
| 186 |
+
"name": "Boss's Stash",
|
| 187 |
+
"description": "A leather pouch with 15 gold pieces",
|
| 188 |
+
"value": "15 gp"
|
| 189 |
+
}
|
| 190 |
+
],
|
| 191 |
+
"encounter": {
|
| 192 |
+
"encounter_id": "goblin_boss_fight",
|
| 193 |
+
"trigger": "automatic",
|
| 194 |
+
"surprise_dc": null
|
| 195 |
+
}
|
| 196 |
+
}
|
| 197 |
+
],
|
| 198 |
+
|
| 199 |
+
"npcs": [
|
| 200 |
+
{
|
| 201 |
+
"npc_id": "goblin_boss",
|
| 202 |
+
"name": "Grukk the Goblin Boss",
|
| 203 |
+
"description": "A larger, meaner goblin who has crowned himself king of this small tribe",
|
| 204 |
+
"personality": "Cowardly but cruel, Grukk bullies those weaker than him",
|
| 205 |
+
"voice_profile": "monster",
|
| 206 |
+
"dialogue_hooks": [
|
| 207 |
+
"Flee, pink-skin, or Grukk feeds you to Bitey!",
|
| 208 |
+
"This Grukk's cave! Grukk's treasure!",
|
| 209 |
+
"Mercy! Grukk surrender! Grukk tell you where more gold is!"
|
| 210 |
+
],
|
| 211 |
+
"monster_stat_block": "goblin_boss"
|
| 212 |
+
}
|
| 213 |
+
],
|
| 214 |
+
|
| 215 |
+
"encounters": [
|
| 216 |
+
{
|
| 217 |
+
"encounter_id": "goblin_guards",
|
| 218 |
+
"name": "Goblin Guards",
|
| 219 |
+
"description": "Two goblins guarding the cave entrance, distracted by gambling",
|
| 220 |
+
"enemies": [
|
| 221 |
+
{"monster": "goblin", "count": 2}
|
| 222 |
+
],
|
| 223 |
+
"difficulty": "easy",
|
| 224 |
+
"tactics": "The goblins shriek an alarm and attack. One tries to flee deeper into the cave if badly wounded.",
|
| 225 |
+
"rewards": {
|
| 226 |
+
"xp": 50,
|
| 227 |
+
"loot": "2d6 copper pieces on each goblin"
|
| 228 |
+
}
|
| 229 |
+
},
|
| 230 |
+
{
|
| 231 |
+
"encounter_id": "goblin_boss_fight",
|
| 232 |
+
"name": "Grukk and Bitey",
|
| 233 |
+
"description": "The goblin boss and his pet wolf make their stand",
|
| 234 |
+
"enemies": [
|
| 235 |
+
{"monster": "goblin_boss", "count": 1, "name": "Grukk"},
|
| 236 |
+
{"monster": "wolf", "count": 1, "name": "Bitey"}
|
| 237 |
+
],
|
| 238 |
+
"difficulty": "medium",
|
| 239 |
+
"tactics": "Grukk hangs back and uses his Redirect Attack while Bitey charges. If reduced to half HP, Grukk begs for mercy and offers information about a 'bigger treasure' (a lie).",
|
| 240 |
+
"rewards": {
|
| 241 |
+
"xp": 150,
|
| 242 |
+
"loot": "Grukk's stash, rusty crown"
|
| 243 |
+
}
|
| 244 |
+
}
|
| 245 |
+
],
|
| 246 |
+
|
| 247 |
+
"loot_tables": [
|
| 248 |
+
{
|
| 249 |
+
"table_id": "goblin_pockets",
|
| 250 |
+
"items": [
|
| 251 |
+
{"weight": 40, "result": "1d6 copper pieces"},
|
| 252 |
+
{"weight": 30, "result": "2d6 copper pieces"},
|
| 253 |
+
{"weight": 20, "result": "1d6 silver pieces"},
|
| 254 |
+
{"weight": 10, "result": "A shiny stone (worthless but pretty)"}
|
| 255 |
+
]
|
| 256 |
+
}
|
| 257 |
+
],
|
| 258 |
+
|
| 259 |
+
"victory_conditions": {
|
| 260 |
+
"primary": {
|
| 261 |
+
"description": "Defeat or drive off the goblin threat",
|
| 262 |
+
"trigger": "goblin_boss_fight encounter completed",
|
| 263 |
+
"narrative": "With Grukk defeated, the goblin threat to Millbrook is ended. The villagers will be overjoyed to hear of your success!"
|
| 264 |
+
},
|
| 265 |
+
"optional": [
|
| 266 |
+
{
|
| 267 |
+
"description": "Recover the stolen goods",
|
| 268 |
+
"trigger": "visit treasure_room and take stolen_grain",
|
| 269 |
+
"reward": "Extra 10 gp reward from grateful farmers"
|
| 270 |
+
},
|
| 271 |
+
{
|
| 272 |
+
"description": "Save the chicken",
|
| 273 |
+
"trigger": "take frightened_chicken from treasure_room",
|
| 274 |
+
"reward": "Free eggs for life from a grateful farmer"
|
| 275 |
+
}
|
| 276 |
+
]
|
| 277 |
+
},
|
| 278 |
+
|
| 279 |
+
"completion_narrative": "As you emerge from the cave, victorious, the afternoon sun feels warmer somehow. The threat that plagued Millbrook is ended. When you return to the village, you'll be hailed as heroes. But this is only the beginning - word of your deeds will spread, and greater adventures await. For now, though, you've earned a hot meal and a soft bed at the Millbrook Inn."
|
| 280 |
+
}
|
app.py
CHANGED
|
@@ -1,7 +1,21 @@
|
|
| 1 |
-
|
|
|
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
|
|
|
| 5 |
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DungeonMaster AI - HuggingFace Spaces Entry Point
|
| 3 |
|
| 4 |
+
This is the main entry point for the HuggingFace Spaces deployment.
|
| 5 |
+
It imports and launches the Gradio application from ui/app.py.
|
| 6 |
+
"""
|
| 7 |
|
| 8 |
+
import os
|
| 9 |
+
import sys
|
| 10 |
+
|
| 11 |
+
# Add the project root to the path for imports
|
| 12 |
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 13 |
+
|
| 14 |
+
# Import and run the main application
|
| 15 |
+
from ui.app import create_app, main
|
| 16 |
+
|
| 17 |
+
if __name__ == "__main__":
|
| 18 |
+
main()
|
| 19 |
+
else:
|
| 20 |
+
# For Gradio Spaces, create the app object
|
| 21 |
+
demo = create_app()
|
pyproject.toml
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "dungeonmaster-ai"
|
| 3 |
+
version = "1.0.0"
|
| 4 |
+
description = "AI-powered D&D 5e Game Master with voice narration, powered by TTRPG Toolkit MCP"
|
| 5 |
+
readme = "README.md"
|
| 6 |
+
license = { text = "MIT" }
|
| 7 |
+
requires-python = ">=3.10"
|
| 8 |
+
authors = [
|
| 9 |
+
{ name = "DungeonMaster AI Team" }
|
| 10 |
+
]
|
| 11 |
+
keywords = [
|
| 12 |
+
"dnd",
|
| 13 |
+
"dungeon-master",
|
| 14 |
+
"ai",
|
| 15 |
+
"mcp",
|
| 16 |
+
"gradio",
|
| 17 |
+
"voice",
|
| 18 |
+
"ttrpg",
|
| 19 |
+
"game-master",
|
| 20 |
+
"elevenlabs",
|
| 21 |
+
"gemini",
|
| 22 |
+
"llama-index"
|
| 23 |
+
]
|
| 24 |
+
classifiers = [
|
| 25 |
+
"Development Status :: 4 - Beta",
|
| 26 |
+
"Intended Audience :: End Users/Desktop",
|
| 27 |
+
"License :: OSI Approved :: MIT License",
|
| 28 |
+
"Programming Language :: Python :: 3",
|
| 29 |
+
"Programming Language :: Python :: 3.10",
|
| 30 |
+
"Programming Language :: Python :: 3.11",
|
| 31 |
+
"Programming Language :: Python :: 3.12",
|
| 32 |
+
"Topic :: Games/Entertainment :: Role-Playing",
|
| 33 |
+
]
|
| 34 |
+
|
| 35 |
+
dependencies = [
|
| 36 |
+
# UI Framework
|
| 37 |
+
"gradio>=6.0.0",
|
| 38 |
+
|
| 39 |
+
# Agent Framework - LlamaIndex
|
| 40 |
+
"llama-index>=0.14.0",
|
| 41 |
+
"llama-index-tools-mcp",
|
| 42 |
+
"llama-index-llms-gemini",
|
| 43 |
+
|
| 44 |
+
# LLM APIs
|
| 45 |
+
"google-generativeai>=0.8.0",
|
| 46 |
+
"openai>=1.50.0",
|
| 47 |
+
"google-genai",
|
| 48 |
+
|
| 49 |
+
# Voice
|
| 50 |
+
"elevenlabs>=1.0.0",
|
| 51 |
+
|
| 52 |
+
# MCP
|
| 53 |
+
"mcp>=1.0.0",
|
| 54 |
+
|
| 55 |
+
# Core
|
| 56 |
+
"pydantic>=2.9.0",
|
| 57 |
+
"pydantic-settings>=2.5.0",
|
| 58 |
+
"python-dotenv>=1.0.0",
|
| 59 |
+
"aiohttp>=3.10.0",
|
| 60 |
+
"httpx>=0.27.0",
|
| 61 |
+
]
|
| 62 |
+
|
| 63 |
+
[project.optional-dependencies]
|
| 64 |
+
dev = [
|
| 65 |
+
"pytest>=8.0.0",
|
| 66 |
+
"pytest-asyncio>=0.24.0",
|
| 67 |
+
"ruff>=0.6.0",
|
| 68 |
+
"mypy>=1.11.0",
|
| 69 |
+
]
|
| 70 |
+
|
| 71 |
+
[project.scripts]
|
| 72 |
+
dungeonmaster = "ui.app:main"
|
| 73 |
+
|
| 74 |
+
[project.urls]
|
| 75 |
+
Homepage = "https://huggingface.co/spaces/dungeonmaster-ai/dungeonmaster-ai"
|
| 76 |
+
Repository = "https://github.com/dungeonmaster-ai/dungeonmaster-ai"
|
| 77 |
+
Issues = "https://github.com/dungeonmaster-ai/dungeonmaster-ai/issues"
|
| 78 |
+
|
| 79 |
+
[build-system]
|
| 80 |
+
requires = ["hatchling"]
|
| 81 |
+
build-backend = "hatchling.build"
|
| 82 |
+
|
| 83 |
+
[tool.hatch.build.targets.wheel]
|
| 84 |
+
packages = ["src", "ui"]
|
| 85 |
+
|
| 86 |
+
[tool.ruff]
|
| 87 |
+
target-version = "py310"
|
| 88 |
+
line-length = 100
|
| 89 |
+
|
| 90 |
+
[tool.ruff.lint]
|
| 91 |
+
select = [
|
| 92 |
+
"E", # pycodestyle errors
|
| 93 |
+
"W", # pycodestyle warnings
|
| 94 |
+
"F", # Pyflakes
|
| 95 |
+
"I", # isort
|
| 96 |
+
"B", # flake8-bugbear
|
| 97 |
+
"C4", # flake8-comprehensions
|
| 98 |
+
"UP", # pyupgrade
|
| 99 |
+
"ARG", # flake8-unused-arguments
|
| 100 |
+
"SIM", # flake8-simplify
|
| 101 |
+
]
|
| 102 |
+
ignore = [
|
| 103 |
+
"E501", # line too long (handled by formatter)
|
| 104 |
+
"B008", # do not perform function calls in argument defaults
|
| 105 |
+
]
|
| 106 |
+
|
| 107 |
+
[tool.ruff.lint.isort]
|
| 108 |
+
known-first-party = ["src", "ui"]
|
| 109 |
+
|
| 110 |
+
[tool.mypy]
|
| 111 |
+
python_version = "3.10"
|
| 112 |
+
warn_return_any = true
|
| 113 |
+
warn_unused_configs = true
|
| 114 |
+
disallow_untyped_defs = true
|
| 115 |
+
disallow_incomplete_defs = true
|
| 116 |
+
check_untyped_defs = true
|
| 117 |
+
strict_optional = true
|
| 118 |
+
warn_redundant_casts = true
|
| 119 |
+
warn_unused_ignores = true
|
| 120 |
+
|
| 121 |
+
[[tool.mypy.overrides]]
|
| 122 |
+
module = [
|
| 123 |
+
"llama_index.*",
|
| 124 |
+
"google.generativeai.*",
|
| 125 |
+
"gradio.*",
|
| 126 |
+
"elevenlabs.*",
|
| 127 |
+
"mcp.*",
|
| 128 |
+
]
|
| 129 |
+
ignore_missing_imports = true
|
| 130 |
+
|
| 131 |
+
[tool.pytest.ini_options]
|
| 132 |
+
asyncio_mode = "auto"
|
| 133 |
+
testpaths = ["tests"]
|
| 134 |
+
python_files = ["test_*.py"]
|
| 135 |
+
python_functions = ["test_*"]
|
| 136 |
+
addopts = "-v --tb=short"
|
requirements.txt
ADDED
|
@@ -0,0 +1,527 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# This file was autogenerated by uv via the following command:
|
| 2 |
+
# uv export --no-dev --no-hashes -o requirements.txt
|
| 3 |
+
-e .
|
| 4 |
+
aiofiles==24.1.0
|
| 5 |
+
# via gradio
|
| 6 |
+
aiohappyeyeballs==2.6.1
|
| 7 |
+
# via aiohttp
|
| 8 |
+
aiohttp==3.13.2
|
| 9 |
+
# via
|
| 10 |
+
# dungeonmaster-ai
|
| 11 |
+
# llama-index-core
|
| 12 |
+
aiosignal==1.4.0
|
| 13 |
+
# via aiohttp
|
| 14 |
+
aiosqlite==0.21.0
|
| 15 |
+
# via llama-index-core
|
| 16 |
+
annotated-doc==0.0.4
|
| 17 |
+
# via fastapi
|
| 18 |
+
annotated-types==0.7.0
|
| 19 |
+
# via pydantic
|
| 20 |
+
anyio==4.12.0
|
| 21 |
+
# via
|
| 22 |
+
# google-genai
|
| 23 |
+
# gradio
|
| 24 |
+
# httpx
|
| 25 |
+
# mcp
|
| 26 |
+
# openai
|
| 27 |
+
# sse-starlette
|
| 28 |
+
# starlette
|
| 29 |
+
async-timeout==5.0.1 ; python_full_version < '3.11'
|
| 30 |
+
# via aiohttp
|
| 31 |
+
attrs==25.4.0
|
| 32 |
+
# via
|
| 33 |
+
# aiohttp
|
| 34 |
+
# jsonschema
|
| 35 |
+
# referencing
|
| 36 |
+
audioop-lts==0.2.2 ; python_full_version >= '3.13'
|
| 37 |
+
# via gradio
|
| 38 |
+
banks==2.2.0
|
| 39 |
+
# via llama-index-core
|
| 40 |
+
beautifulsoup4==4.14.2
|
| 41 |
+
# via llama-index-readers-file
|
| 42 |
+
brotli==1.2.0
|
| 43 |
+
# via gradio
|
| 44 |
+
cachetools==6.2.2
|
| 45 |
+
# via google-auth
|
| 46 |
+
certifi==2025.11.12
|
| 47 |
+
# via
|
| 48 |
+
# httpcore
|
| 49 |
+
# httpx
|
| 50 |
+
# llama-cloud
|
| 51 |
+
# requests
|
| 52 |
+
cffi==2.0.0 ; platform_python_implementation != 'PyPy'
|
| 53 |
+
# via cryptography
|
| 54 |
+
charset-normalizer==3.4.4
|
| 55 |
+
# via requests
|
| 56 |
+
click==8.3.1
|
| 57 |
+
# via
|
| 58 |
+
# llama-cloud-services
|
| 59 |
+
# nltk
|
| 60 |
+
# typer
|
| 61 |
+
# typer-slim
|
| 62 |
+
# uvicorn
|
| 63 |
+
colorama==0.4.6
|
| 64 |
+
# via
|
| 65 |
+
# click
|
| 66 |
+
# griffe
|
| 67 |
+
# tqdm
|
| 68 |
+
cryptography==46.0.3
|
| 69 |
+
# via pyjwt
|
| 70 |
+
dataclasses-json==0.6.7
|
| 71 |
+
# via llama-index-core
|
| 72 |
+
defusedxml==0.7.1
|
| 73 |
+
# via llama-index-readers-file
|
| 74 |
+
deprecated==1.2.18
|
| 75 |
+
# via
|
| 76 |
+
# banks
|
| 77 |
+
# llama-index-core
|
| 78 |
+
# llama-index-indices-managed-llama-cloud
|
| 79 |
+
# llama-index-instrumentation
|
| 80 |
+
dirtyjson==1.0.8
|
| 81 |
+
# via llama-index-core
|
| 82 |
+
distro==1.9.0
|
| 83 |
+
# via openai
|
| 84 |
+
elevenlabs==2.24.0
|
| 85 |
+
# via dungeonmaster-ai
|
| 86 |
+
exceptiongroup==1.3.1 ; python_full_version < '3.11'
|
| 87 |
+
# via anyio
|
| 88 |
+
fastapi==0.122.0
|
| 89 |
+
# via gradio
|
| 90 |
+
ffmpy==1.0.0
|
| 91 |
+
# via gradio
|
| 92 |
+
filelock==3.20.0
|
| 93 |
+
# via huggingface-hub
|
| 94 |
+
filetype==1.2.0
|
| 95 |
+
# via llama-index-core
|
| 96 |
+
frozenlist==1.8.0
|
| 97 |
+
# via
|
| 98 |
+
# aiohttp
|
| 99 |
+
# aiosignal
|
| 100 |
+
fsspec==2025.10.0
|
| 101 |
+
# via
|
| 102 |
+
# gradio-client
|
| 103 |
+
# huggingface-hub
|
| 104 |
+
# llama-index-core
|
| 105 |
+
google-ai-generativelanguage==0.6.15
|
| 106 |
+
# via google-generativeai
|
| 107 |
+
google-api-core==2.25.2 ; python_full_version >= '3.14'
|
| 108 |
+
# via
|
| 109 |
+
# google-ai-generativelanguage
|
| 110 |
+
# google-api-python-client
|
| 111 |
+
# google-generativeai
|
| 112 |
+
google-api-core==2.28.1 ; python_full_version < '3.14'
|
| 113 |
+
# via
|
| 114 |
+
# google-ai-generativelanguage
|
| 115 |
+
# google-api-python-client
|
| 116 |
+
# google-generativeai
|
| 117 |
+
google-api-python-client==2.187.0
|
| 118 |
+
# via google-generativeai
|
| 119 |
+
google-auth==2.43.0
|
| 120 |
+
# via
|
| 121 |
+
# google-ai-generativelanguage
|
| 122 |
+
# google-api-core
|
| 123 |
+
# google-api-python-client
|
| 124 |
+
# google-auth-httplib2
|
| 125 |
+
# google-genai
|
| 126 |
+
# google-generativeai
|
| 127 |
+
google-auth-httplib2==0.2.1
|
| 128 |
+
# via google-api-python-client
|
| 129 |
+
google-genai==1.52.0
|
| 130 |
+
# via dungeonmaster-ai
|
| 131 |
+
google-generativeai==0.8.5
|
| 132 |
+
# via
|
| 133 |
+
# dungeonmaster-ai
|
| 134 |
+
# llama-index-llms-gemini
|
| 135 |
+
googleapis-common-protos==1.72.0
|
| 136 |
+
# via
|
| 137 |
+
# google-api-core
|
| 138 |
+
# grpcio-status
|
| 139 |
+
gradio==6.0.1
|
| 140 |
+
# via dungeonmaster-ai
|
| 141 |
+
gradio-client==2.0.0
|
| 142 |
+
# via gradio
|
| 143 |
+
greenlet==3.2.4
|
| 144 |
+
# via sqlalchemy
|
| 145 |
+
griffe==1.15.0
|
| 146 |
+
# via banks
|
| 147 |
+
groovy==0.1.2
|
| 148 |
+
# via gradio
|
| 149 |
+
grpcio==1.76.0
|
| 150 |
+
# via
|
| 151 |
+
# google-api-core
|
| 152 |
+
# grpcio-status
|
| 153 |
+
grpcio-status==1.71.2
|
| 154 |
+
# via google-api-core
|
| 155 |
+
h11==0.16.0
|
| 156 |
+
# via
|
| 157 |
+
# httpcore
|
| 158 |
+
# uvicorn
|
| 159 |
+
hf-xet==1.2.0 ; platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'
|
| 160 |
+
# via huggingface-hub
|
| 161 |
+
httpcore==1.0.9
|
| 162 |
+
# via httpx
|
| 163 |
+
httplib2==0.31.0
|
| 164 |
+
# via
|
| 165 |
+
# google-api-python-client
|
| 166 |
+
# google-auth-httplib2
|
| 167 |
+
httpx==0.28.1
|
| 168 |
+
# via
|
| 169 |
+
# dungeonmaster-ai
|
| 170 |
+
# elevenlabs
|
| 171 |
+
# google-genai
|
| 172 |
+
# gradio
|
| 173 |
+
# gradio-client
|
| 174 |
+
# huggingface-hub
|
| 175 |
+
# llama-cloud
|
| 176 |
+
# llama-index-core
|
| 177 |
+
# mcp
|
| 178 |
+
# openai
|
| 179 |
+
# safehttpx
|
| 180 |
+
httpx-sse==0.4.3
|
| 181 |
+
# via mcp
|
| 182 |
+
huggingface-hub==1.1.6
|
| 183 |
+
# via
|
| 184 |
+
# gradio
|
| 185 |
+
# gradio-client
|
| 186 |
+
idna==3.11
|
| 187 |
+
# via
|
| 188 |
+
# anyio
|
| 189 |
+
# httpx
|
| 190 |
+
# requests
|
| 191 |
+
# yarl
|
| 192 |
+
jinja2==3.1.6
|
| 193 |
+
# via
|
| 194 |
+
# banks
|
| 195 |
+
# gradio
|
| 196 |
+
jiter==0.12.0
|
| 197 |
+
# via openai
|
| 198 |
+
joblib==1.5.2
|
| 199 |
+
# via nltk
|
| 200 |
+
jsonschema==4.25.1
|
| 201 |
+
# via mcp
|
| 202 |
+
jsonschema-specifications==2025.9.1
|
| 203 |
+
# via jsonschema
|
| 204 |
+
llama-cloud==0.1.35
|
| 205 |
+
# via
|
| 206 |
+
# llama-cloud-services
|
| 207 |
+
# llama-index-indices-managed-llama-cloud
|
| 208 |
+
llama-cloud-services==0.6.54
|
| 209 |
+
# via llama-parse
|
| 210 |
+
llama-index==0.14.8
|
| 211 |
+
# via dungeonmaster-ai
|
| 212 |
+
llama-index-cli==0.5.3
|
| 213 |
+
# via llama-index
|
| 214 |
+
llama-index-core==0.14.8
|
| 215 |
+
# via
|
| 216 |
+
# llama-cloud-services
|
| 217 |
+
# llama-index
|
| 218 |
+
# llama-index-cli
|
| 219 |
+
# llama-index-embeddings-openai
|
| 220 |
+
# llama-index-indices-managed-llama-cloud
|
| 221 |
+
# llama-index-llms-gemini
|
| 222 |
+
# llama-index-llms-openai
|
| 223 |
+
# llama-index-readers-file
|
| 224 |
+
# llama-index-readers-llama-parse
|
| 225 |
+
# llama-index-tools-mcp
|
| 226 |
+
llama-index-embeddings-openai==0.5.1
|
| 227 |
+
# via
|
| 228 |
+
# llama-index
|
| 229 |
+
# llama-index-cli
|
| 230 |
+
llama-index-indices-managed-llama-cloud==0.9.4
|
| 231 |
+
# via llama-index
|
| 232 |
+
llama-index-instrumentation==0.4.2
|
| 233 |
+
# via llama-index-workflows
|
| 234 |
+
llama-index-llms-gemini==0.6.1
|
| 235 |
+
# via dungeonmaster-ai
|
| 236 |
+
llama-index-llms-openai==0.6.10
|
| 237 |
+
# via
|
| 238 |
+
# llama-index
|
| 239 |
+
# llama-index-cli
|
| 240 |
+
llama-index-readers-file==0.5.5
|
| 241 |
+
# via llama-index
|
| 242 |
+
llama-index-readers-llama-parse==0.5.1
|
| 243 |
+
# via llama-index
|
| 244 |
+
llama-index-tools-mcp==0.4.3
|
| 245 |
+
# via dungeonmaster-ai
|
| 246 |
+
llama-index-workflows==2.11.5
|
| 247 |
+
# via llama-index-core
|
| 248 |
+
llama-parse==0.6.54
|
| 249 |
+
# via llama-index-readers-llama-parse
|
| 250 |
+
markdown-it-py==4.0.0
|
| 251 |
+
# via rich
|
| 252 |
+
markupsafe==3.0.3
|
| 253 |
+
# via
|
| 254 |
+
# gradio
|
| 255 |
+
# jinja2
|
| 256 |
+
marshmallow==3.26.1
|
| 257 |
+
# via dataclasses-json
|
| 258 |
+
mcp==1.22.0
|
| 259 |
+
# via
|
| 260 |
+
# dungeonmaster-ai
|
| 261 |
+
# llama-index-tools-mcp
|
| 262 |
+
mdurl==0.1.2
|
| 263 |
+
# via markdown-it-py
|
| 264 |
+
multidict==6.7.0
|
| 265 |
+
# via
|
| 266 |
+
# aiohttp
|
| 267 |
+
# yarl
|
| 268 |
+
mypy-extensions==1.1.0
|
| 269 |
+
# via typing-inspect
|
| 270 |
+
nest-asyncio==1.6.0
|
| 271 |
+
# via llama-index-core
|
| 272 |
+
networkx==3.4.2 ; python_full_version < '3.11'
|
| 273 |
+
# via llama-index-core
|
| 274 |
+
networkx==3.6 ; python_full_version >= '3.11'
|
| 275 |
+
# via llama-index-core
|
| 276 |
+
nltk==3.9.2
|
| 277 |
+
# via
|
| 278 |
+
# llama-index
|
| 279 |
+
# llama-index-core
|
| 280 |
+
numpy==2.2.6 ; python_full_version < '3.11'
|
| 281 |
+
# via
|
| 282 |
+
# gradio
|
| 283 |
+
# llama-index-core
|
| 284 |
+
# pandas
|
| 285 |
+
numpy==2.3.5 ; python_full_version >= '3.11'
|
| 286 |
+
# via
|
| 287 |
+
# gradio
|
| 288 |
+
# llama-index-core
|
| 289 |
+
# pandas
|
| 290 |
+
openai==2.8.1
|
| 291 |
+
# via
|
| 292 |
+
# dungeonmaster-ai
|
| 293 |
+
# llama-index-embeddings-openai
|
| 294 |
+
# llama-index-llms-openai
|
| 295 |
+
orjson==3.11.4
|
| 296 |
+
# via gradio
|
| 297 |
+
packaging==25.0
|
| 298 |
+
# via
|
| 299 |
+
# gradio
|
| 300 |
+
# gradio-client
|
| 301 |
+
# huggingface-hub
|
| 302 |
+
# marshmallow
|
| 303 |
+
pandas==2.2.3
|
| 304 |
+
# via
|
| 305 |
+
# gradio
|
| 306 |
+
# llama-index-readers-file
|
| 307 |
+
pillow==10.4.0
|
| 308 |
+
# via
|
| 309 |
+
# gradio
|
| 310 |
+
# llama-index-core
|
| 311 |
+
# llama-index-llms-gemini
|
| 312 |
+
platformdirs==4.5.0
|
| 313 |
+
# via
|
| 314 |
+
# banks
|
| 315 |
+
# llama-cloud-services
|
| 316 |
+
# llama-index-core
|
| 317 |
+
propcache==0.4.1
|
| 318 |
+
# via
|
| 319 |
+
# aiohttp
|
| 320 |
+
# yarl
|
| 321 |
+
proto-plus==1.26.1
|
| 322 |
+
# via
|
| 323 |
+
# google-ai-generativelanguage
|
| 324 |
+
# google-api-core
|
| 325 |
+
protobuf==5.29.5
|
| 326 |
+
# via
|
| 327 |
+
# google-ai-generativelanguage
|
| 328 |
+
# google-api-core
|
| 329 |
+
# google-generativeai
|
| 330 |
+
# googleapis-common-protos
|
| 331 |
+
# grpcio-status
|
| 332 |
+
# proto-plus
|
| 333 |
+
pyasn1==0.6.1
|
| 334 |
+
# via
|
| 335 |
+
# pyasn1-modules
|
| 336 |
+
# rsa
|
| 337 |
+
pyasn1-modules==0.4.2
|
| 338 |
+
# via google-auth
|
| 339 |
+
pycparser==2.23 ; implementation_name != 'PyPy' and platform_python_implementation != 'PyPy'
|
| 340 |
+
# via cffi
|
| 341 |
+
pydantic==2.12.4
|
| 342 |
+
# via
|
| 343 |
+
# banks
|
| 344 |
+
# dungeonmaster-ai
|
| 345 |
+
# elevenlabs
|
| 346 |
+
# fastapi
|
| 347 |
+
# google-genai
|
| 348 |
+
# google-generativeai
|
| 349 |
+
# gradio
|
| 350 |
+
# llama-cloud
|
| 351 |
+
# llama-cloud-services
|
| 352 |
+
# llama-index-core
|
| 353 |
+
# llama-index-instrumentation
|
| 354 |
+
# llama-index-tools-mcp
|
| 355 |
+
# llama-index-workflows
|
| 356 |
+
# mcp
|
| 357 |
+
# openai
|
| 358 |
+
# pydantic-settings
|
| 359 |
+
pydantic-core==2.41.5
|
| 360 |
+
# via
|
| 361 |
+
# elevenlabs
|
| 362 |
+
# pydantic
|
| 363 |
+
pydantic-settings==2.12.0
|
| 364 |
+
# via
|
| 365 |
+
# dungeonmaster-ai
|
| 366 |
+
# mcp
|
| 367 |
+
pydub==0.25.1
|
| 368 |
+
# via gradio
|
| 369 |
+
pygments==2.19.2
|
| 370 |
+
# via rich
|
| 371 |
+
pyjwt==2.10.1
|
| 372 |
+
# via mcp
|
| 373 |
+
pyparsing==3.2.5
|
| 374 |
+
# via httplib2
|
| 375 |
+
pypdf==6.4.0
|
| 376 |
+
# via llama-index-readers-file
|
| 377 |
+
python-dateutil==2.9.0.post0
|
| 378 |
+
# via pandas
|
| 379 |
+
python-dotenv==1.2.1
|
| 380 |
+
# via
|
| 381 |
+
# dungeonmaster-ai
|
| 382 |
+
# llama-cloud-services
|
| 383 |
+
# pydantic-settings
|
| 384 |
+
python-multipart==0.0.20
|
| 385 |
+
# via
|
| 386 |
+
# gradio
|
| 387 |
+
# mcp
|
| 388 |
+
pytz==2025.2
|
| 389 |
+
# via pandas
|
| 390 |
+
pywin32==311 ; sys_platform == 'win32'
|
| 391 |
+
# via mcp
|
| 392 |
+
pyyaml==6.0.3
|
| 393 |
+
# via
|
| 394 |
+
# gradio
|
| 395 |
+
# huggingface-hub
|
| 396 |
+
# llama-index-core
|
| 397 |
+
referencing==0.37.0
|
| 398 |
+
# via
|
| 399 |
+
# jsonschema
|
| 400 |
+
# jsonschema-specifications
|
| 401 |
+
regex==2025.11.3
|
| 402 |
+
# via
|
| 403 |
+
# nltk
|
| 404 |
+
# tiktoken
|
| 405 |
+
requests==2.32.5
|
| 406 |
+
# via
|
| 407 |
+
# elevenlabs
|
| 408 |
+
# google-api-core
|
| 409 |
+
# google-genai
|
| 410 |
+
# llama-index-core
|
| 411 |
+
# tiktoken
|
| 412 |
+
rich==14.2.0
|
| 413 |
+
# via typer
|
| 414 |
+
rpds-py==0.29.0
|
| 415 |
+
# via
|
| 416 |
+
# jsonschema
|
| 417 |
+
# referencing
|
| 418 |
+
rsa==4.9.1
|
| 419 |
+
# via google-auth
|
| 420 |
+
safehttpx==0.1.7
|
| 421 |
+
# via gradio
|
| 422 |
+
semantic-version==2.10.0
|
| 423 |
+
# via gradio
|
| 424 |
+
setuptools==80.9.0
|
| 425 |
+
# via llama-index-core
|
| 426 |
+
shellingham==1.5.4
|
| 427 |
+
# via
|
| 428 |
+
# huggingface-hub
|
| 429 |
+
# typer
|
| 430 |
+
six==1.17.0
|
| 431 |
+
# via python-dateutil
|
| 432 |
+
sniffio==1.3.1
|
| 433 |
+
# via openai
|
| 434 |
+
soupsieve==2.8
|
| 435 |
+
# via beautifulsoup4
|
| 436 |
+
sqlalchemy==2.0.44
|
| 437 |
+
# via llama-index-core
|
| 438 |
+
sse-starlette==3.0.3
|
| 439 |
+
# via mcp
|
| 440 |
+
starlette==0.50.0
|
| 441 |
+
# via
|
| 442 |
+
# fastapi
|
| 443 |
+
# gradio
|
| 444 |
+
# mcp
|
| 445 |
+
striprtf==0.0.26
|
| 446 |
+
# via llama-index-readers-file
|
| 447 |
+
tenacity==9.1.2
|
| 448 |
+
# via
|
| 449 |
+
# google-genai
|
| 450 |
+
# llama-cloud-services
|
| 451 |
+
# llama-index-core
|
| 452 |
+
tiktoken==0.12.0
|
| 453 |
+
# via llama-index-core
|
| 454 |
+
tomlkit==0.13.3
|
| 455 |
+
# via gradio
|
| 456 |
+
tqdm==4.67.1
|
| 457 |
+
# via
|
| 458 |
+
# google-generativeai
|
| 459 |
+
# huggingface-hub
|
| 460 |
+
# llama-index-core
|
| 461 |
+
# nltk
|
| 462 |
+
# openai
|
| 463 |
+
typer==0.20.0
|
| 464 |
+
# via gradio
|
| 465 |
+
typer-slim==0.20.0
|
| 466 |
+
# via huggingface-hub
|
| 467 |
+
typing-extensions==4.15.0
|
| 468 |
+
# via
|
| 469 |
+
# aiosignal
|
| 470 |
+
# aiosqlite
|
| 471 |
+
# anyio
|
| 472 |
+
# beautifulsoup4
|
| 473 |
+
# cryptography
|
| 474 |
+
# elevenlabs
|
| 475 |
+
# exceptiongroup
|
| 476 |
+
# fastapi
|
| 477 |
+
# google-genai
|
| 478 |
+
# google-generativeai
|
| 479 |
+
# gradio
|
| 480 |
+
# gradio-client
|
| 481 |
+
# grpcio
|
| 482 |
+
# huggingface-hub
|
| 483 |
+
# llama-index-core
|
| 484 |
+
# llama-index-workflows
|
| 485 |
+
# mcp
|
| 486 |
+
# multidict
|
| 487 |
+
# openai
|
| 488 |
+
# pydantic
|
| 489 |
+
# pydantic-core
|
| 490 |
+
# pypdf
|
| 491 |
+
# referencing
|
| 492 |
+
# sqlalchemy
|
| 493 |
+
# starlette
|
| 494 |
+
# typer
|
| 495 |
+
# typer-slim
|
| 496 |
+
# typing-inspect
|
| 497 |
+
# typing-inspection
|
| 498 |
+
# uvicorn
|
| 499 |
+
typing-inspect==0.9.0
|
| 500 |
+
# via
|
| 501 |
+
# dataclasses-json
|
| 502 |
+
# llama-index-core
|
| 503 |
+
typing-inspection==0.4.2
|
| 504 |
+
# via
|
| 505 |
+
# mcp
|
| 506 |
+
# pydantic
|
| 507 |
+
# pydantic-settings
|
| 508 |
+
tzdata==2025.2
|
| 509 |
+
# via pandas
|
| 510 |
+
uritemplate==4.2.0
|
| 511 |
+
# via google-api-python-client
|
| 512 |
+
urllib3==2.5.0
|
| 513 |
+
# via requests
|
| 514 |
+
uvicorn==0.38.0
|
| 515 |
+
# via
|
| 516 |
+
# gradio
|
| 517 |
+
# mcp
|
| 518 |
+
websockets==15.0.1
|
| 519 |
+
# via
|
| 520 |
+
# elevenlabs
|
| 521 |
+
# google-genai
|
| 522 |
+
wrapt==1.17.3
|
| 523 |
+
# via
|
| 524 |
+
# deprecated
|
| 525 |
+
# llama-index-core
|
| 526 |
+
yarl==1.22.0
|
| 527 |
+
# via aiohttp
|
src/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DungeonMaster AI - Source Package
|
| 3 |
+
|
| 4 |
+
AI-powered D&D 5e Game Master with voice narration.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
__version__ = "1.0.0"
|
| 8 |
+
__author__ = "DungeonMaster AI Team"
|
src/agents/__init__.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DungeonMaster AI - Agents Package
|
| 3 |
+
|
| 4 |
+
LlamaIndex-based agents for game mastering, rules, and narration.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
# Models and enums
|
| 10 |
+
from src.agents.models import (
|
| 11 |
+
DegradationLevel,
|
| 12 |
+
DMResponse,
|
| 13 |
+
GameContext,
|
| 14 |
+
GameMode,
|
| 15 |
+
LLMProviderHealth,
|
| 16 |
+
LLMResponse,
|
| 17 |
+
PacingStyle,
|
| 18 |
+
RulesResponse,
|
| 19 |
+
SpecialMoment,
|
| 20 |
+
SpecialMomentType,
|
| 21 |
+
ToolCallInfo,
|
| 22 |
+
TurnResult,
|
| 23 |
+
VoiceSegment,
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
# Exceptions
|
| 27 |
+
from src.agents.exceptions import (
|
| 28 |
+
AgentError,
|
| 29 |
+
DMAgentError,
|
| 30 |
+
LLMAllProvidersFailedError,
|
| 31 |
+
LLMProviderError,
|
| 32 |
+
LLMRateLimitError,
|
| 33 |
+
LLMTimeoutError,
|
| 34 |
+
OrchestratorError,
|
| 35 |
+
RulesAgentError,
|
| 36 |
+
VoiceNarratorError,
|
| 37 |
+
get_graceful_message,
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
# LLM Provider
|
| 41 |
+
from src.agents.llm_provider import (
|
| 42 |
+
LLMFallbackChain,
|
| 43 |
+
ProviderCircuitBreaker,
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
# Agents
|
| 47 |
+
from src.agents.dungeon_master import (
|
| 48 |
+
DungeonMasterAgent,
|
| 49 |
+
parse_voice_cues,
|
| 50 |
+
)
|
| 51 |
+
from src.agents.rules_arbiter import (
|
| 52 |
+
RulesArbiterAgent,
|
| 53 |
+
RulesCache,
|
| 54 |
+
)
|
| 55 |
+
from src.agents.voice_narrator import (
|
| 56 |
+
PhraseCache,
|
| 57 |
+
VoiceNarratorAgent,
|
| 58 |
+
create_voice_narrator,
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
# Controllers
|
| 62 |
+
from src.agents.special_moments import (
|
| 63 |
+
SpecialMomentHandler,
|
| 64 |
+
UI_EFFECTS,
|
| 65 |
+
)
|
| 66 |
+
from src.agents.pacing_controller import (
|
| 67 |
+
PACING_INSTRUCTIONS,
|
| 68 |
+
PacingController,
|
| 69 |
+
create_pacing_controller,
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
# Orchestrator
|
| 73 |
+
from src.agents.orchestrator import (
|
| 74 |
+
AgentOrchestrator,
|
| 75 |
+
create_orchestrator,
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
__all__ = [
|
| 80 |
+
# Models
|
| 81 |
+
"DegradationLevel",
|
| 82 |
+
"DMResponse",
|
| 83 |
+
"GameContext",
|
| 84 |
+
"GameMode",
|
| 85 |
+
"LLMProviderHealth",
|
| 86 |
+
"LLMResponse",
|
| 87 |
+
"PacingStyle",
|
| 88 |
+
"RulesResponse",
|
| 89 |
+
"SpecialMoment",
|
| 90 |
+
"SpecialMomentType",
|
| 91 |
+
"ToolCallInfo",
|
| 92 |
+
"TurnResult",
|
| 93 |
+
"VoiceSegment",
|
| 94 |
+
# Exceptions
|
| 95 |
+
"AgentError",
|
| 96 |
+
"DMAgentError",
|
| 97 |
+
"LLMAllProvidersFailedError",
|
| 98 |
+
"LLMProviderError",
|
| 99 |
+
"LLMRateLimitError",
|
| 100 |
+
"LLMTimeoutError",
|
| 101 |
+
"OrchestratorError",
|
| 102 |
+
"RulesAgentError",
|
| 103 |
+
"VoiceNarratorError",
|
| 104 |
+
"get_graceful_message",
|
| 105 |
+
# LLM Provider
|
| 106 |
+
"LLMFallbackChain",
|
| 107 |
+
"ProviderCircuitBreaker",
|
| 108 |
+
# Agents
|
| 109 |
+
"DungeonMasterAgent",
|
| 110 |
+
"parse_voice_cues",
|
| 111 |
+
"RulesArbiterAgent",
|
| 112 |
+
"RulesCache",
|
| 113 |
+
"VoiceNarratorAgent",
|
| 114 |
+
"PhraseCache",
|
| 115 |
+
"create_voice_narrator",
|
| 116 |
+
# Controllers
|
| 117 |
+
"SpecialMomentHandler",
|
| 118 |
+
"UI_EFFECTS",
|
| 119 |
+
"PACING_INSTRUCTIONS",
|
| 120 |
+
"PacingController",
|
| 121 |
+
"create_pacing_controller",
|
| 122 |
+
# Orchestrator
|
| 123 |
+
"AgentOrchestrator",
|
| 124 |
+
"create_orchestrator",
|
| 125 |
+
]
|
src/agents/dungeon_master.py
ADDED
|
@@ -0,0 +1,675 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DungeonMaster AI - Dungeon Master Agent
|
| 3 |
+
|
| 4 |
+
The main storytelling agent using LlamaIndex FunctionAgent.
|
| 5 |
+
Handles narrative generation, tool calling, and game flow.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import logging
|
| 11 |
+
import re
|
| 12 |
+
import time
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
from typing import TYPE_CHECKING
|
| 15 |
+
|
| 16 |
+
from llama_index.core.agent.workflow import FunctionAgent
|
| 17 |
+
from llama_index.core.llms import ChatMessage, MessageRole
|
| 18 |
+
from llama_index.core.memory import ChatMemoryBuffer
|
| 19 |
+
from llama_index.core.tools import FunctionTool
|
| 20 |
+
|
| 21 |
+
from src.config.settings import get_settings
|
| 22 |
+
from src.game.game_state import GameState
|
| 23 |
+
from src.mcp_integration.models import DiceRollResult
|
| 24 |
+
from src.voice.models import VoiceType
|
| 25 |
+
|
| 26 |
+
from .exceptions import DMAgentError
|
| 27 |
+
from .models import (
|
| 28 |
+
DMResponse,
|
| 29 |
+
GameContext,
|
| 30 |
+
GameMode,
|
| 31 |
+
PacingStyle,
|
| 32 |
+
SpecialMoment,
|
| 33 |
+
SpecialMomentType,
|
| 34 |
+
ToolCallInfo,
|
| 35 |
+
VoiceSegment,
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
if TYPE_CHECKING:
|
| 39 |
+
from llama_index.core.llms import LLM
|
| 40 |
+
|
| 41 |
+
logger = logging.getLogger(__name__)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
# =============================================================================
|
| 45 |
+
# Voice Cue Parsing
|
| 46 |
+
# =============================================================================
|
| 47 |
+
|
| 48 |
+
# Pattern to match [VOICE:profile_name] tags
|
| 49 |
+
VOICE_CUE_PATTERN = re.compile(r"\[VOICE:(\w+)\]", re.IGNORECASE)
|
| 50 |
+
|
| 51 |
+
# Mapping from voice cue names to VoiceType
|
| 52 |
+
VOICE_CUE_MAP: dict[str, VoiceType] = {
|
| 53 |
+
"dm": VoiceType.DM,
|
| 54 |
+
"narrator": VoiceType.DM,
|
| 55 |
+
"npc_male_gruff": VoiceType.NPC_MALE_GRUFF,
|
| 56 |
+
"gruff": VoiceType.NPC_MALE_GRUFF,
|
| 57 |
+
"guard": VoiceType.NPC_MALE_GRUFF,
|
| 58 |
+
"dwarf": VoiceType.NPC_MALE_GRUFF,
|
| 59 |
+
"npc_female_gentle": VoiceType.NPC_FEMALE_GENTLE,
|
| 60 |
+
"gentle": VoiceType.NPC_FEMALE_GENTLE,
|
| 61 |
+
"elf": VoiceType.NPC_FEMALE_GENTLE,
|
| 62 |
+
"healer": VoiceType.NPC_FEMALE_GENTLE,
|
| 63 |
+
"npc_mysterious": VoiceType.NPC_MYSTERIOUS,
|
| 64 |
+
"mysterious": VoiceType.NPC_MYSTERIOUS,
|
| 65 |
+
"wizard": VoiceType.NPC_MYSTERIOUS,
|
| 66 |
+
"mage": VoiceType.NPC_MYSTERIOUS,
|
| 67 |
+
"monster": VoiceType.MONSTER,
|
| 68 |
+
"creature": VoiceType.MONSTER,
|
| 69 |
+
"villain": VoiceType.MONSTER,
|
| 70 |
+
"goblin": VoiceType.MONSTER,
|
| 71 |
+
"orc": VoiceType.MONSTER,
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def parse_voice_cues(text: str) -> list[VoiceSegment]:
|
| 76 |
+
"""
|
| 77 |
+
Parse text for voice cues and split into segments.
|
| 78 |
+
|
| 79 |
+
Args:
|
| 80 |
+
text: Text containing optional [VOICE:profile] cues.
|
| 81 |
+
|
| 82 |
+
Returns:
|
| 83 |
+
List of VoiceSegment objects.
|
| 84 |
+
"""
|
| 85 |
+
segments: list[VoiceSegment] = []
|
| 86 |
+
|
| 87 |
+
# Find all voice cue positions
|
| 88 |
+
matches = list(VOICE_CUE_PATTERN.finditer(text))
|
| 89 |
+
|
| 90 |
+
if not matches:
|
| 91 |
+
# No voice cues - treat as single DM segment
|
| 92 |
+
# But check for quoted dialogue
|
| 93 |
+
return _parse_dialogue_segments(text)
|
| 94 |
+
|
| 95 |
+
current_pos = 0
|
| 96 |
+
current_voice = VoiceType.DM
|
| 97 |
+
|
| 98 |
+
for match in matches:
|
| 99 |
+
# Get text before this voice cue
|
| 100 |
+
before_text = text[current_pos : match.start()].strip()
|
| 101 |
+
if before_text:
|
| 102 |
+
segments.append(
|
| 103 |
+
VoiceSegment(
|
| 104 |
+
text=before_text,
|
| 105 |
+
voice_type=current_voice,
|
| 106 |
+
is_dialogue=False,
|
| 107 |
+
)
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
# Get the new voice type
|
| 111 |
+
voice_name = match.group(1).lower()
|
| 112 |
+
current_voice = VOICE_CUE_MAP.get(voice_name, VoiceType.DM)
|
| 113 |
+
current_pos = match.end()
|
| 114 |
+
|
| 115 |
+
# Get remaining text after last voice cue
|
| 116 |
+
remaining_text = text[current_pos:].strip()
|
| 117 |
+
if remaining_text:
|
| 118 |
+
# Check if it's dialogue (in quotes)
|
| 119 |
+
is_dialogue = remaining_text.startswith('"') or remaining_text.startswith("'")
|
| 120 |
+
segments.append(
|
| 121 |
+
VoiceSegment(
|
| 122 |
+
text=remaining_text,
|
| 123 |
+
voice_type=current_voice,
|
| 124 |
+
is_dialogue=is_dialogue,
|
| 125 |
+
pause_before_ms=200 if is_dialogue else 0,
|
| 126 |
+
)
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
return segments
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def _parse_dialogue_segments(text: str) -> list[VoiceSegment]:
|
| 133 |
+
"""
|
| 134 |
+
Parse text without voice cues, detecting dialogue from quotes.
|
| 135 |
+
|
| 136 |
+
Args:
|
| 137 |
+
text: Text to parse.
|
| 138 |
+
|
| 139 |
+
Returns:
|
| 140 |
+
List of VoiceSegment objects.
|
| 141 |
+
"""
|
| 142 |
+
segments: list[VoiceSegment] = []
|
| 143 |
+
|
| 144 |
+
# Simple pattern: split on quotes
|
| 145 |
+
# This handles: Narration "Dialogue" more narration
|
| 146 |
+
parts = re.split(r'("[^"]+"|\'[^\']+\')', text)
|
| 147 |
+
|
| 148 |
+
for part in parts:
|
| 149 |
+
part = part.strip()
|
| 150 |
+
if not part:
|
| 151 |
+
continue
|
| 152 |
+
|
| 153 |
+
is_dialogue = part.startswith('"') or part.startswith("'")
|
| 154 |
+
# Remove quotes for dialogue
|
| 155 |
+
clean_text = part.strip("\"'") if is_dialogue else part
|
| 156 |
+
|
| 157 |
+
if clean_text:
|
| 158 |
+
segments.append(
|
| 159 |
+
VoiceSegment(
|
| 160 |
+
text=clean_text if not is_dialogue else part, # Keep quotes for display
|
| 161 |
+
voice_type=VoiceType.NPC_MALE_GRUFF if is_dialogue else VoiceType.DM,
|
| 162 |
+
is_dialogue=is_dialogue,
|
| 163 |
+
pause_before_ms=200 if is_dialogue else 0,
|
| 164 |
+
pause_after_ms=200 if is_dialogue else 0,
|
| 165 |
+
)
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
# If no segments were created, return the whole text as DM
|
| 169 |
+
if not segments:
|
| 170 |
+
segments.append(VoiceSegment(text=text, voice_type=VoiceType.DM))
|
| 171 |
+
|
| 172 |
+
return segments
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
# =============================================================================
|
| 176 |
+
# Prompt Loading
|
| 177 |
+
# =============================================================================
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def load_system_prompt(mode: GameMode = GameMode.EXPLORATION) -> str:
|
| 181 |
+
"""
|
| 182 |
+
Load the system prompt for the given game mode.
|
| 183 |
+
|
| 184 |
+
Args:
|
| 185 |
+
mode: Current game mode.
|
| 186 |
+
|
| 187 |
+
Returns:
|
| 188 |
+
Combined system prompt string.
|
| 189 |
+
"""
|
| 190 |
+
settings = get_settings()
|
| 191 |
+
prompts_dir = settings.prompts_dir
|
| 192 |
+
|
| 193 |
+
# Load base prompt
|
| 194 |
+
base_path = Path(prompts_dir) / "dm_system.txt"
|
| 195 |
+
base_prompt = ""
|
| 196 |
+
if base_path.exists():
|
| 197 |
+
base_prompt = base_path.read_text()
|
| 198 |
+
else:
|
| 199 |
+
logger.warning(f"Base DM prompt not found at {base_path}")
|
| 200 |
+
base_prompt = _get_fallback_prompt()
|
| 201 |
+
|
| 202 |
+
# Load mode-specific additions
|
| 203 |
+
mode_prompts = {
|
| 204 |
+
GameMode.COMBAT: "dm_combat.txt",
|
| 205 |
+
GameMode.EXPLORATION: "dm_exploration.txt",
|
| 206 |
+
GameMode.SOCIAL: "dm_social.txt",
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
mode_file = mode_prompts.get(mode)
|
| 210 |
+
if mode_file:
|
| 211 |
+
mode_path = Path(prompts_dir) / mode_file
|
| 212 |
+
if mode_path.exists():
|
| 213 |
+
mode_addition = mode_path.read_text()
|
| 214 |
+
base_prompt = f"{base_prompt}\n\n## Current Mode: {mode.value.title()}\n{mode_addition}"
|
| 215 |
+
|
| 216 |
+
# Add voice cue instructions
|
| 217 |
+
voice_instructions = """
|
| 218 |
+
## Voice Cues for Multi-Voice Narration
|
| 219 |
+
When NPCs or creatures speak dialogue, prefix their speech with a voice tag:
|
| 220 |
+
- [VOICE:gruff] for guards, warriors, dwarves
|
| 221 |
+
- [VOICE:gentle] for elves, healers, kind NPCs
|
| 222 |
+
- [VOICE:mysterious] for wizards, seers, mystical beings
|
| 223 |
+
- [VOICE:monster] for creatures, goblins, villains
|
| 224 |
+
|
| 225 |
+
Example: The dwarf slams his tankard. [VOICE:gruff]"Information costs gold, stranger!"
|
| 226 |
+
|
| 227 |
+
Narration without a voice tag will use the default DM voice.
|
| 228 |
+
"""
|
| 229 |
+
base_prompt = f"{base_prompt}\n{voice_instructions}"
|
| 230 |
+
|
| 231 |
+
return base_prompt
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def _get_fallback_prompt() -> str:
|
| 235 |
+
"""Get fallback DM prompt if file not found."""
|
| 236 |
+
return """You are an experienced Dungeon Master for D&D 5e. Create immersive, engaging narratives.
|
| 237 |
+
|
| 238 |
+
CRITICAL: Always use tools for dice rolls and game mechanics. Never decide outcomes arbitrarily.
|
| 239 |
+
|
| 240 |
+
Keep responses to 2-4 sentences. Be dramatic but fair. Use voice cues for NPC dialogue."""
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
# =============================================================================
|
| 244 |
+
# DungeonMasterAgent
|
| 245 |
+
# =============================================================================
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
class DungeonMasterAgent:
|
| 249 |
+
"""
|
| 250 |
+
Main storytelling agent using LlamaIndex FunctionAgent.
|
| 251 |
+
|
| 252 |
+
Features:
|
| 253 |
+
- Dynamic mode switching (exploration, combat, social)
|
| 254 |
+
- Game state context injection
|
| 255 |
+
- Voice cue parsing for multi-voice narration
|
| 256 |
+
- Special moment detection (critical hits, death saves)
|
| 257 |
+
"""
|
| 258 |
+
|
| 259 |
+
def __init__(
|
| 260 |
+
self,
|
| 261 |
+
llm: LLM,
|
| 262 |
+
tools: list[FunctionTool],
|
| 263 |
+
game_state: GameState,
|
| 264 |
+
memory_token_limit: int = 8000,
|
| 265 |
+
) -> None:
|
| 266 |
+
"""
|
| 267 |
+
Initialize the Dungeon Master agent.
|
| 268 |
+
|
| 269 |
+
Args:
|
| 270 |
+
llm: LlamaIndex LLM instance.
|
| 271 |
+
tools: List of MCP tools as FunctionTool objects.
|
| 272 |
+
game_state: Game state reference.
|
| 273 |
+
memory_token_limit: Token limit for conversation memory.
|
| 274 |
+
"""
|
| 275 |
+
self._llm = llm
|
| 276 |
+
self._tools = tools
|
| 277 |
+
self._game_state = game_state
|
| 278 |
+
self._current_mode = GameMode.EXPLORATION
|
| 279 |
+
|
| 280 |
+
# Initialize memory
|
| 281 |
+
self._memory = ChatMemoryBuffer.from_defaults(
|
| 282 |
+
llm=llm,
|
| 283 |
+
token_limit=memory_token_limit,
|
| 284 |
+
)
|
| 285 |
+
|
| 286 |
+
# Load system prompt
|
| 287 |
+
self._system_prompt = load_system_prompt(self._current_mode)
|
| 288 |
+
|
| 289 |
+
# Create the FunctionAgent
|
| 290 |
+
self._agent = FunctionAgent(
|
| 291 |
+
llm=llm,
|
| 292 |
+
tools=tools,
|
| 293 |
+
system_prompt=self._system_prompt,
|
| 294 |
+
)
|
| 295 |
+
|
| 296 |
+
logger.info(
|
| 297 |
+
f"DungeonMasterAgent initialized with {len(tools)} tools"
|
| 298 |
+
)
|
| 299 |
+
|
| 300 |
+
@property
|
| 301 |
+
def mode(self) -> GameMode:
|
| 302 |
+
"""Get current game mode."""
|
| 303 |
+
return self._current_mode
|
| 304 |
+
|
| 305 |
+
def set_mode(self, mode: GameMode) -> None:
|
| 306 |
+
"""
|
| 307 |
+
Switch game mode and reload system prompt.
|
| 308 |
+
|
| 309 |
+
Args:
|
| 310 |
+
mode: New game mode.
|
| 311 |
+
"""
|
| 312 |
+
if mode != self._current_mode:
|
| 313 |
+
self._current_mode = mode
|
| 314 |
+
self._system_prompt = load_system_prompt(mode)
|
| 315 |
+
|
| 316 |
+
# Recreate agent with new prompt
|
| 317 |
+
self._agent = FunctionAgent(
|
| 318 |
+
llm=self._llm,
|
| 319 |
+
tools=self._tools,
|
| 320 |
+
system_prompt=self._system_prompt,
|
| 321 |
+
)
|
| 322 |
+
|
| 323 |
+
logger.info(f"DM agent mode changed to {mode.value}")
|
| 324 |
+
|
| 325 |
+
def detect_mode(self, player_input: str, game_state: GameState) -> GameMode:
|
| 326 |
+
"""
|
| 327 |
+
Detect appropriate game mode from context.
|
| 328 |
+
|
| 329 |
+
Args:
|
| 330 |
+
player_input: Player's input text.
|
| 331 |
+
game_state: Current game state.
|
| 332 |
+
|
| 333 |
+
Returns:
|
| 334 |
+
Detected GameMode.
|
| 335 |
+
"""
|
| 336 |
+
# Combat mode if in combat
|
| 337 |
+
if game_state.in_combat:
|
| 338 |
+
return GameMode.COMBAT
|
| 339 |
+
|
| 340 |
+
# Check for combat keywords
|
| 341 |
+
input_lower = player_input.lower()
|
| 342 |
+
combat_keywords = ["attack", "fight", "strike", "cast", "hit", "shoot"]
|
| 343 |
+
if any(kw in input_lower for kw in combat_keywords):
|
| 344 |
+
return GameMode.COMBAT
|
| 345 |
+
|
| 346 |
+
# Check for social keywords
|
| 347 |
+
social_keywords = ["talk", "speak", "ask", "persuade", "intimidate", "convince"]
|
| 348 |
+
if any(kw in input_lower for kw in social_keywords):
|
| 349 |
+
return GameMode.SOCIAL
|
| 350 |
+
|
| 351 |
+
# Default to exploration
|
| 352 |
+
return GameMode.EXPLORATION
|
| 353 |
+
|
| 354 |
+
def _build_context_message(self, game_state: GameState) -> str:
|
| 355 |
+
"""
|
| 356 |
+
Build context message from game state for LLM.
|
| 357 |
+
|
| 358 |
+
Args:
|
| 359 |
+
game_state: Current game state.
|
| 360 |
+
|
| 361 |
+
Returns:
|
| 362 |
+
Formatted context string.
|
| 363 |
+
"""
|
| 364 |
+
context_parts = []
|
| 365 |
+
|
| 366 |
+
# Location
|
| 367 |
+
context_parts.append(f"**Current Location:** {game_state.current_location}")
|
| 368 |
+
|
| 369 |
+
# Party status
|
| 370 |
+
if game_state.party:
|
| 371 |
+
context_parts.append(f"**Party Size:** {len(game_state.party)} characters")
|
| 372 |
+
if game_state.active_character_id:
|
| 373 |
+
char_data = game_state.get_character(game_state.active_character_id)
|
| 374 |
+
if char_data:
|
| 375 |
+
# Handle nested structure - character data can be at root or nested
|
| 376 |
+
char = char_data.get("character", char_data)
|
| 377 |
+
|
| 378 |
+
# Basic info
|
| 379 |
+
name = char.get("name", "Unknown")
|
| 380 |
+
char_class = char.get("class", char.get("character_class", "Unknown"))
|
| 381 |
+
level = char.get("level", 1)
|
| 382 |
+
race = char.get("race", "Unknown")
|
| 383 |
+
|
| 384 |
+
# Hit points
|
| 385 |
+
hp_data = char.get("hit_points", {})
|
| 386 |
+
current_hp = hp_data.get("current", char.get("current_hp", "?"))
|
| 387 |
+
max_hp = hp_data.get("maximum", hp_data.get("max", char.get("max_hp", "?")))
|
| 388 |
+
temp_hp = hp_data.get("temporary", char.get("temp_hp", 0))
|
| 389 |
+
|
| 390 |
+
# Combat stats
|
| 391 |
+
ac = char.get("armor_class", char.get("ac", "?"))
|
| 392 |
+
|
| 393 |
+
# Build character summary
|
| 394 |
+
char_summary = f"**Active Character:** {name} (Level {level} {race} {char_class})"
|
| 395 |
+
context_parts.append(char_summary)
|
| 396 |
+
|
| 397 |
+
# Add HP and AC
|
| 398 |
+
hp_text = f"{current_hp}/{max_hp}"
|
| 399 |
+
if temp_hp:
|
| 400 |
+
hp_text += f" (+{temp_hp} temp)"
|
| 401 |
+
context_parts.append(f"**HP:** {hp_text} | **AC:** {ac}")
|
| 402 |
+
|
| 403 |
+
# Add equipped weapons (if any)
|
| 404 |
+
equipment = char.get("equipment", {})
|
| 405 |
+
weapons = equipment.get("weapons", [])
|
| 406 |
+
if weapons:
|
| 407 |
+
# Take first 2 weapons to keep context concise
|
| 408 |
+
weapon_list = ", ".join(str(w) for w in weapons[:2])
|
| 409 |
+
if len(weapons) > 2:
|
| 410 |
+
weapon_list += f", +{len(weapons) - 2} more"
|
| 411 |
+
context_parts.append(f"**Equipped:** {weapon_list}")
|
| 412 |
+
|
| 413 |
+
# Add key ability scores for quick reference
|
| 414 |
+
abilities = char.get("ability_scores", {})
|
| 415 |
+
if abilities:
|
| 416 |
+
# Show primary combat abilities
|
| 417 |
+
str_score = abilities.get("strength", abilities.get("STR", "?"))
|
| 418 |
+
dex_score = abilities.get("dexterity", abilities.get("DEX", "?"))
|
| 419 |
+
con_score = abilities.get("constitution", abilities.get("CON", "?"))
|
| 420 |
+
context_parts.append(
|
| 421 |
+
f"**Abilities:** STR {str_score}, DEX {dex_score}, CON {con_score}"
|
| 422 |
+
)
|
| 423 |
+
|
| 424 |
+
# Combat state
|
| 425 |
+
if game_state.in_combat and game_state.combat_state:
|
| 426 |
+
combat = game_state.combat_state
|
| 427 |
+
round_num = combat.get("round", 1)
|
| 428 |
+
context_parts.append(f"**Combat Round:** {round_num}")
|
| 429 |
+
current = combat.get("current_combatant")
|
| 430 |
+
if current:
|
| 431 |
+
context_parts.append(f"**Current Turn:** {current}")
|
| 432 |
+
|
| 433 |
+
# Recent events (last 3)
|
| 434 |
+
if game_state.recent_events:
|
| 435 |
+
recent = game_state.recent_events[-3:]
|
| 436 |
+
events_text = "; ".join(e.get("description", "")[:50] for e in recent)
|
| 437 |
+
context_parts.append(f"**Recent Events:** {events_text}")
|
| 438 |
+
|
| 439 |
+
return "\n".join(context_parts)
|
| 440 |
+
|
| 441 |
+
async def process(
|
| 442 |
+
self,
|
| 443 |
+
player_input: str,
|
| 444 |
+
game_context: GameContext | None = None,
|
| 445 |
+
) -> DMResponse:
|
| 446 |
+
"""
|
| 447 |
+
Process player input and generate DM response.
|
| 448 |
+
|
| 449 |
+
Args:
|
| 450 |
+
player_input: The player's action/speech.
|
| 451 |
+
game_context: Optional game context for additional info.
|
| 452 |
+
|
| 453 |
+
Returns:
|
| 454 |
+
DMResponse with narration and metadata.
|
| 455 |
+
"""
|
| 456 |
+
start_time = time.time()
|
| 457 |
+
|
| 458 |
+
try:
|
| 459 |
+
# Auto-detect and switch mode
|
| 460 |
+
detected_mode = self.detect_mode(player_input, self._game_state)
|
| 461 |
+
if detected_mode != self._current_mode:
|
| 462 |
+
self.set_mode(detected_mode)
|
| 463 |
+
|
| 464 |
+
# Build context message
|
| 465 |
+
context_msg = self._build_context_message(self._game_state)
|
| 466 |
+
|
| 467 |
+
# Combine context with player input
|
| 468 |
+
full_input = f"{context_msg}\n\n**Player Action:** {player_input}"
|
| 469 |
+
|
| 470 |
+
# Run the agent
|
| 471 |
+
handler = self._agent.run(user_msg=full_input)
|
| 472 |
+
|
| 473 |
+
# Collect response and tool calls
|
| 474 |
+
response_text = ""
|
| 475 |
+
tool_calls: list[ToolCallInfo] = []
|
| 476 |
+
dice_rolls: list[DiceRollResult] = []
|
| 477 |
+
|
| 478 |
+
async for event in handler.stream_events():
|
| 479 |
+
# Handle different event types
|
| 480 |
+
event_type = type(event).__name__
|
| 481 |
+
|
| 482 |
+
if event_type == "AgentOutput":
|
| 483 |
+
# Final response
|
| 484 |
+
response_text = str(event.response) if hasattr(event, "response") else ""
|
| 485 |
+
|
| 486 |
+
elif event_type == "ToolCall":
|
| 487 |
+
# Tool was called
|
| 488 |
+
if hasattr(event, "tool_name"):
|
| 489 |
+
tool_info = ToolCallInfo(
|
| 490 |
+
tool_name=event.tool_name,
|
| 491 |
+
arguments=getattr(event, "arguments", {}),
|
| 492 |
+
)
|
| 493 |
+
tool_calls.append(tool_info)
|
| 494 |
+
|
| 495 |
+
elif event_type == "ToolCallResult":
|
| 496 |
+
# Tool result received
|
| 497 |
+
if hasattr(event, "result") and tool_calls:
|
| 498 |
+
tool_calls[-1].result = event.result
|
| 499 |
+
tool_calls[-1].success = True
|
| 500 |
+
|
| 501 |
+
# Check if it's a dice roll
|
| 502 |
+
if hasattr(event, "result"):
|
| 503 |
+
roll_result = self._extract_dice_roll(event.result)
|
| 504 |
+
if roll_result:
|
| 505 |
+
dice_rolls.append(roll_result)
|
| 506 |
+
|
| 507 |
+
# Parse voice cues from response
|
| 508 |
+
voice_segments = parse_voice_cues(response_text)
|
| 509 |
+
|
| 510 |
+
# Clean response text (remove voice cues for display)
|
| 511 |
+
clean_text = VOICE_CUE_PATTERN.sub("", response_text).strip()
|
| 512 |
+
|
| 513 |
+
# Detect special moments
|
| 514 |
+
special_moment = self._detect_special_moment(dice_rolls, tool_calls)
|
| 515 |
+
|
| 516 |
+
# Determine pacing
|
| 517 |
+
pacing = self._determine_pacing(detected_mode, special_moment)
|
| 518 |
+
|
| 519 |
+
# Calculate processing time
|
| 520 |
+
processing_time = (time.time() - start_time) * 1000
|
| 521 |
+
|
| 522 |
+
return DMResponse(
|
| 523 |
+
narration=clean_text,
|
| 524 |
+
voice_segments=voice_segments,
|
| 525 |
+
tool_calls=tool_calls,
|
| 526 |
+
dice_rolls=dice_rolls,
|
| 527 |
+
game_state_updates={}, # Will be populated by orchestrator
|
| 528 |
+
special_moment=special_moment,
|
| 529 |
+
ui_effects=special_moment.ui_effects if special_moment else [],
|
| 530 |
+
game_mode=detected_mode,
|
| 531 |
+
pacing=pacing,
|
| 532 |
+
processing_time_ms=processing_time,
|
| 533 |
+
llm_provider=getattr(self._llm, "model", "unknown"),
|
| 534 |
+
)
|
| 535 |
+
|
| 536 |
+
except Exception as e:
|
| 537 |
+
logger.error(f"DM agent processing failed: {e}")
|
| 538 |
+
raise DMAgentError(str(e)) from e
|
| 539 |
+
|
| 540 |
+
def _extract_dice_roll(self, result: object) -> DiceRollResult | None:
|
| 541 |
+
"""Extract DiceRollResult from tool result if it's a roll."""
|
| 542 |
+
if isinstance(result, DiceRollResult):
|
| 543 |
+
return result
|
| 544 |
+
|
| 545 |
+
if isinstance(result, dict):
|
| 546 |
+
# Check if it looks like a roll result
|
| 547 |
+
if "total" in result and ("rolls" in result or "notation" in result):
|
| 548 |
+
try:
|
| 549 |
+
return DiceRollResult(
|
| 550 |
+
notation=str(result.get("notation", "")),
|
| 551 |
+
individual_rolls=list(result.get("rolls", [])),
|
| 552 |
+
total=int(result.get("total", 0)),
|
| 553 |
+
is_critical=result.get("is_critical", False),
|
| 554 |
+
is_fumble=result.get("is_fumble", False),
|
| 555 |
+
raw_result=result,
|
| 556 |
+
)
|
| 557 |
+
except Exception:
|
| 558 |
+
pass
|
| 559 |
+
|
| 560 |
+
return None
|
| 561 |
+
|
| 562 |
+
def _detect_special_moment(
|
| 563 |
+
self,
|
| 564 |
+
dice_rolls: list[DiceRollResult],
|
| 565 |
+
tool_calls: list[ToolCallInfo],
|
| 566 |
+
) -> SpecialMoment | None:
|
| 567 |
+
"""
|
| 568 |
+
Detect if any special dramatic moment occurred.
|
| 569 |
+
|
| 570 |
+
Args:
|
| 571 |
+
dice_rolls: Dice rolls from this turn.
|
| 572 |
+
tool_calls: Tool calls from this turn.
|
| 573 |
+
|
| 574 |
+
Returns:
|
| 575 |
+
SpecialMoment if detected, None otherwise.
|
| 576 |
+
"""
|
| 577 |
+
for roll in dice_rolls:
|
| 578 |
+
# Critical hit (natural 20 on d20)
|
| 579 |
+
if roll.is_critical:
|
| 580 |
+
return SpecialMoment(
|
| 581 |
+
moment_type=SpecialMomentType.CRITICAL_HIT,
|
| 582 |
+
enhanced_narration="CRITICAL HIT! Your strike finds the perfect opening!",
|
| 583 |
+
voice_type=VoiceType.DM,
|
| 584 |
+
ui_effects=["screen_shake", "golden_glow"],
|
| 585 |
+
pause_before_ms=500,
|
| 586 |
+
context={"roll": roll.total, "notation": roll.notation},
|
| 587 |
+
)
|
| 588 |
+
|
| 589 |
+
# Critical miss (natural 1 on d20)
|
| 590 |
+
if roll.is_fumble:
|
| 591 |
+
return SpecialMoment(
|
| 592 |
+
moment_type=SpecialMomentType.CRITICAL_MISS,
|
| 593 |
+
enhanced_narration="A critical fumble! Things have gone terribly wrong...",
|
| 594 |
+
voice_type=VoiceType.DM,
|
| 595 |
+
ui_effects=["red_flash"],
|
| 596 |
+
pause_before_ms=300,
|
| 597 |
+
context={"roll": roll.total, "notation": roll.notation},
|
| 598 |
+
)
|
| 599 |
+
|
| 600 |
+
# Check for death saves in tool calls
|
| 601 |
+
for tool in tool_calls:
|
| 602 |
+
if "death" in tool.tool_name.lower() or "save" in tool.tool_name.lower():
|
| 603 |
+
result = tool.result
|
| 604 |
+
if isinstance(result, dict):
|
| 605 |
+
roll_value = result.get("roll", result.get("total", 0))
|
| 606 |
+
if roll_value == 20:
|
| 607 |
+
return SpecialMoment(
|
| 608 |
+
moment_type=SpecialMomentType.DEATH_SAVE_NAT_20,
|
| 609 |
+
enhanced_narration=(
|
| 610 |
+
"Your eyes SNAP open! Against all odds, you RISE, "
|
| 611 |
+
"defying death itself!"
|
| 612 |
+
),
|
| 613 |
+
voice_type=VoiceType.DM,
|
| 614 |
+
ui_effects=["golden_resurrection", "screen_shake"],
|
| 615 |
+
pause_before_ms=800,
|
| 616 |
+
context={"roll": roll_value},
|
| 617 |
+
)
|
| 618 |
+
elif roll_value == 1:
|
| 619 |
+
return SpecialMoment(
|
| 620 |
+
moment_type=SpecialMomentType.DEATH_SAVE_NAT_1,
|
| 621 |
+
enhanced_narration=(
|
| 622 |
+
"Darkness surges. Two failures. Death's grip tightens..."
|
| 623 |
+
),
|
| 624 |
+
voice_type=VoiceType.DM,
|
| 625 |
+
ui_effects=["darkness_pulse"],
|
| 626 |
+
pause_before_ms=500,
|
| 627 |
+
context={"roll": roll_value},
|
| 628 |
+
)
|
| 629 |
+
|
| 630 |
+
return None
|
| 631 |
+
|
| 632 |
+
def _determine_pacing(
|
| 633 |
+
self,
|
| 634 |
+
mode: GameMode,
|
| 635 |
+
special_moment: SpecialMoment | None,
|
| 636 |
+
) -> PacingStyle:
|
| 637 |
+
"""
|
| 638 |
+
Determine response pacing style.
|
| 639 |
+
|
| 640 |
+
Args:
|
| 641 |
+
mode: Current game mode.
|
| 642 |
+
special_moment: Special moment if any.
|
| 643 |
+
|
| 644 |
+
Returns:
|
| 645 |
+
Appropriate PacingStyle.
|
| 646 |
+
"""
|
| 647 |
+
if special_moment:
|
| 648 |
+
return PacingStyle.DRAMATIC
|
| 649 |
+
|
| 650 |
+
if mode == GameMode.COMBAT:
|
| 651 |
+
return PacingStyle.QUICK
|
| 652 |
+
|
| 653 |
+
if mode == GameMode.SOCIAL:
|
| 654 |
+
return PacingStyle.STANDARD
|
| 655 |
+
|
| 656 |
+
return PacingStyle.STANDARD
|
| 657 |
+
|
| 658 |
+
def clear_memory(self) -> None:
|
| 659 |
+
"""Clear conversation memory for new game."""
|
| 660 |
+
self._memory = ChatMemoryBuffer.from_defaults(
|
| 661 |
+
llm=self._llm,
|
| 662 |
+
token_limit=8000,
|
| 663 |
+
)
|
| 664 |
+
logger.info("DM agent memory cleared")
|
| 665 |
+
|
| 666 |
+
def add_to_memory(self, role: str, content: str) -> None:
|
| 667 |
+
"""
|
| 668 |
+
Add a message to conversation memory.
|
| 669 |
+
|
| 670 |
+
Args:
|
| 671 |
+
role: Message role ('user' or 'assistant').
|
| 672 |
+
content: Message content.
|
| 673 |
+
"""
|
| 674 |
+
message_role = MessageRole.USER if role == "user" else MessageRole.ASSISTANT
|
| 675 |
+
self._memory.put(ChatMessage(role=message_role, content=content))
|
src/agents/exceptions.py
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DungeonMaster AI - Agent Exceptions
|
| 3 |
+
|
| 4 |
+
Exception hierarchy for agent-related errors.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class AgentError(Exception):
|
| 11 |
+
"""Base exception for all agent-related errors."""
|
| 12 |
+
|
| 13 |
+
def __init__(self, message: str, recoverable: bool = True) -> None:
|
| 14 |
+
super().__init__(message)
|
| 15 |
+
self.message = message
|
| 16 |
+
self.recoverable = recoverable
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
# =============================================================================
|
| 20 |
+
# LLM Provider Exceptions
|
| 21 |
+
# =============================================================================
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class LLMProviderError(AgentError):
|
| 25 |
+
"""Base exception for LLM provider errors."""
|
| 26 |
+
|
| 27 |
+
def __init__(
|
| 28 |
+
self,
|
| 29 |
+
message: str,
|
| 30 |
+
provider: str = "",
|
| 31 |
+
recoverable: bool = True,
|
| 32 |
+
) -> None:
|
| 33 |
+
super().__init__(message, recoverable)
|
| 34 |
+
self.provider = provider
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class LLMRateLimitError(LLMProviderError):
|
| 38 |
+
"""LLM provider rate limit exceeded."""
|
| 39 |
+
|
| 40 |
+
def __init__(
|
| 41 |
+
self,
|
| 42 |
+
provider: str,
|
| 43 |
+
retry_after: float | None = None,
|
| 44 |
+
) -> None:
|
| 45 |
+
super().__init__(
|
| 46 |
+
f"Rate limit exceeded for {provider}",
|
| 47 |
+
provider=provider,
|
| 48 |
+
recoverable=True,
|
| 49 |
+
)
|
| 50 |
+
self.retry_after = retry_after
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class LLMTimeoutError(LLMProviderError):
|
| 54 |
+
"""LLM request timed out."""
|
| 55 |
+
|
| 56 |
+
def __init__(
|
| 57 |
+
self,
|
| 58 |
+
provider: str,
|
| 59 |
+
timeout_seconds: float,
|
| 60 |
+
) -> None:
|
| 61 |
+
super().__init__(
|
| 62 |
+
f"Request to {provider} timed out after {timeout_seconds}s",
|
| 63 |
+
provider=provider,
|
| 64 |
+
recoverable=True,
|
| 65 |
+
)
|
| 66 |
+
self.timeout_seconds = timeout_seconds
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
class LLMAuthenticationError(LLMProviderError):
|
| 70 |
+
"""LLM authentication failed (invalid API key)."""
|
| 71 |
+
|
| 72 |
+
def __init__(self, provider: str) -> None:
|
| 73 |
+
super().__init__(
|
| 74 |
+
f"Authentication failed for {provider}. Check API key.",
|
| 75 |
+
provider=provider,
|
| 76 |
+
recoverable=False,
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
class LLMQuotaExhaustedError(LLMProviderError):
|
| 81 |
+
"""LLM quota/credits exhausted."""
|
| 82 |
+
|
| 83 |
+
def __init__(self, provider: str) -> None:
|
| 84 |
+
super().__init__(
|
| 85 |
+
f"Quota exhausted for {provider}",
|
| 86 |
+
provider=provider,
|
| 87 |
+
recoverable=False,
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class LLMAllProvidersFailedError(AgentError):
|
| 92 |
+
"""All LLM providers failed."""
|
| 93 |
+
|
| 94 |
+
def __init__(self, errors: dict[str, str]) -> None:
|
| 95 |
+
providers = ", ".join(errors.keys())
|
| 96 |
+
super().__init__(
|
| 97 |
+
f"All LLM providers failed: {providers}",
|
| 98 |
+
recoverable=False,
|
| 99 |
+
)
|
| 100 |
+
self.errors = errors
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
class LLMCircuitBreakerOpenError(LLMProviderError):
|
| 104 |
+
"""Circuit breaker is open for this provider."""
|
| 105 |
+
|
| 106 |
+
def __init__(
|
| 107 |
+
self,
|
| 108 |
+
provider: str,
|
| 109 |
+
reset_after: float | None = None,
|
| 110 |
+
) -> None:
|
| 111 |
+
super().__init__(
|
| 112 |
+
f"Circuit breaker open for {provider}",
|
| 113 |
+
provider=provider,
|
| 114 |
+
recoverable=True,
|
| 115 |
+
)
|
| 116 |
+
self.reset_after = reset_after
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
# =============================================================================
|
| 120 |
+
# Agent Processing Exceptions
|
| 121 |
+
# =============================================================================
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
class AgentProcessingError(AgentError):
|
| 125 |
+
"""Error during agent processing."""
|
| 126 |
+
|
| 127 |
+
def __init__(
|
| 128 |
+
self,
|
| 129 |
+
message: str,
|
| 130 |
+
agent_name: str = "",
|
| 131 |
+
recoverable: bool = True,
|
| 132 |
+
) -> None:
|
| 133 |
+
super().__init__(message, recoverable)
|
| 134 |
+
self.agent_name = agent_name
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
class DMAgentError(AgentProcessingError):
|
| 138 |
+
"""Error in Dungeon Master agent."""
|
| 139 |
+
|
| 140 |
+
def __init__(self, message: str, recoverable: bool = True) -> None:
|
| 141 |
+
super().__init__(message, agent_name="DungeonMaster", recoverable=recoverable)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
class RulesAgentError(AgentProcessingError):
|
| 145 |
+
"""Error in Rules Arbiter agent."""
|
| 146 |
+
|
| 147 |
+
def __init__(self, message: str, recoverable: bool = True) -> None:
|
| 148 |
+
super().__init__(message, agent_name="RulesArbiter", recoverable=recoverable)
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
class VoiceNarratorError(AgentProcessingError):
|
| 152 |
+
"""Error in Voice Narrator agent."""
|
| 153 |
+
|
| 154 |
+
def __init__(self, message: str, recoverable: bool = True) -> None:
|
| 155 |
+
super().__init__(message, agent_name="VoiceNarrator", recoverable=recoverable)
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
# =============================================================================
|
| 159 |
+
# Tool Execution Exceptions
|
| 160 |
+
# =============================================================================
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
class ToolExecutionError(AgentError):
|
| 164 |
+
"""Error executing a tool."""
|
| 165 |
+
|
| 166 |
+
def __init__(
|
| 167 |
+
self,
|
| 168 |
+
tool_name: str,
|
| 169 |
+
message: str,
|
| 170 |
+
original_error: Exception | None = None,
|
| 171 |
+
recoverable: bool = True,
|
| 172 |
+
) -> None:
|
| 173 |
+
super().__init__(
|
| 174 |
+
f"Tool '{tool_name}' failed: {message}",
|
| 175 |
+
recoverable=recoverable,
|
| 176 |
+
)
|
| 177 |
+
self.tool_name = tool_name
|
| 178 |
+
self.original_error = original_error
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
class ToolTimeoutError(ToolExecutionError):
|
| 182 |
+
"""Tool execution timed out."""
|
| 183 |
+
|
| 184 |
+
def __init__(
|
| 185 |
+
self,
|
| 186 |
+
tool_name: str,
|
| 187 |
+
timeout_seconds: float,
|
| 188 |
+
) -> None:
|
| 189 |
+
super().__init__(
|
| 190 |
+
tool_name,
|
| 191 |
+
f"Execution timed out after {timeout_seconds}s",
|
| 192 |
+
recoverable=True,
|
| 193 |
+
)
|
| 194 |
+
self.timeout_seconds = timeout_seconds
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
class ToolNotFoundError(ToolExecutionError):
|
| 198 |
+
"""Requested tool not found."""
|
| 199 |
+
|
| 200 |
+
def __init__(self, tool_name: str) -> None:
|
| 201 |
+
super().__init__(
|
| 202 |
+
tool_name,
|
| 203 |
+
"Tool not found in available tools",
|
| 204 |
+
recoverable=False,
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
# =============================================================================
|
| 209 |
+
# Orchestration Exceptions
|
| 210 |
+
# =============================================================================
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
class OrchestratorError(AgentError):
|
| 214 |
+
"""Error in agent orchestration."""
|
| 215 |
+
|
| 216 |
+
pass
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
class OrchestratorNotInitializedError(OrchestratorError):
|
| 220 |
+
"""Orchestrator not properly initialized."""
|
| 221 |
+
|
| 222 |
+
def __init__(self) -> None:
|
| 223 |
+
super().__init__(
|
| 224 |
+
"Orchestrator not initialized. Call setup() first.",
|
| 225 |
+
recoverable=True,
|
| 226 |
+
)
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
class TurnProcessingError(OrchestratorError):
|
| 230 |
+
"""Error processing a player turn."""
|
| 231 |
+
|
| 232 |
+
def __init__(
|
| 233 |
+
self,
|
| 234 |
+
message: str,
|
| 235 |
+
partial_result: object | None = None,
|
| 236 |
+
) -> None:
|
| 237 |
+
super().__init__(message, recoverable=True)
|
| 238 |
+
self.partial_result = partial_result
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
# =============================================================================
|
| 242 |
+
# State Exceptions
|
| 243 |
+
# =============================================================================
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
class GameStateError(AgentError):
|
| 247 |
+
"""Error with game state."""
|
| 248 |
+
|
| 249 |
+
pass
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
class StateConsistencyError(GameStateError):
|
| 253 |
+
"""Game state became inconsistent."""
|
| 254 |
+
|
| 255 |
+
def __init__(
|
| 256 |
+
self,
|
| 257 |
+
message: str,
|
| 258 |
+
state_snapshot: dict[str, object] | None = None,
|
| 259 |
+
) -> None:
|
| 260 |
+
super().__init__(message, recoverable=True)
|
| 261 |
+
self.state_snapshot = state_snapshot
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
# =============================================================================
|
| 265 |
+
# Graceful Error Messages
|
| 266 |
+
# =============================================================================
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
GRACEFUL_ERROR_MESSAGES: dict[str, str] = {
|
| 270 |
+
"llm_timeout": (
|
| 271 |
+
"The magical winds of computation blow slowly today. "
|
| 272 |
+
"Please try again in a moment."
|
| 273 |
+
),
|
| 274 |
+
"llm_rate_limit": (
|
| 275 |
+
"The ethereal realm is experiencing heavy traffic. "
|
| 276 |
+
"Take a breath and try again shortly."
|
| 277 |
+
),
|
| 278 |
+
"llm_all_failed": (
|
| 279 |
+
"The arcane servers are temporarily unreachable. "
|
| 280 |
+
"Your adventure continues in text mode."
|
| 281 |
+
),
|
| 282 |
+
"tool_failed": (
|
| 283 |
+
"The mystical tools encountered interference. "
|
| 284 |
+
"Let's try a different approach..."
|
| 285 |
+
),
|
| 286 |
+
"voice_unavailable": (
|
| 287 |
+
"The voice of the narrator is temporarily silenced. "
|
| 288 |
+
"Continuing with text narration."
|
| 289 |
+
),
|
| 290 |
+
"mcp_unavailable": (
|
| 291 |
+
"The game mechanics server is resting. "
|
| 292 |
+
"Using simplified rules for now."
|
| 293 |
+
),
|
| 294 |
+
"general_error": (
|
| 295 |
+
"An unexpected twist in the fabric of reality occurred. "
|
| 296 |
+
"The adventure continues nonetheless."
|
| 297 |
+
),
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
def get_graceful_message(error_type: str) -> str:
|
| 302 |
+
"""
|
| 303 |
+
Get a user-friendly error message.
|
| 304 |
+
|
| 305 |
+
Args:
|
| 306 |
+
error_type: Type of error (key in GRACEFUL_ERROR_MESSAGES)
|
| 307 |
+
|
| 308 |
+
Returns:
|
| 309 |
+
User-friendly error message
|
| 310 |
+
"""
|
| 311 |
+
return GRACEFUL_ERROR_MESSAGES.get(
|
| 312 |
+
error_type,
|
| 313 |
+
GRACEFUL_ERROR_MESSAGES["general_error"],
|
| 314 |
+
)
|
src/agents/llm_provider.py
ADDED
|
@@ -0,0 +1,437 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DungeonMaster AI - LLM Provider Chain
|
| 3 |
+
|
| 4 |
+
Manages LLM providers with automatic fallback from Gemini to OpenAI.
|
| 5 |
+
Includes circuit breaker pattern for reliability.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import asyncio
|
| 11 |
+
import logging
|
| 12 |
+
import time
|
| 13 |
+
from datetime import datetime, timedelta
|
| 14 |
+
from enum import Enum
|
| 15 |
+
|
| 16 |
+
from llama_index.core.llms import ChatMessage, LLM
|
| 17 |
+
from llama_index.llms.gemini import Gemini
|
| 18 |
+
from llama_index.llms.openai import OpenAI
|
| 19 |
+
|
| 20 |
+
from src.config.settings import AppSettings, get_settings
|
| 21 |
+
|
| 22 |
+
from .exceptions import (
|
| 23 |
+
LLMAllProvidersFailedError,
|
| 24 |
+
LLMAuthenticationError,
|
| 25 |
+
LLMCircuitBreakerOpenError,
|
| 26 |
+
LLMQuotaExhaustedError,
|
| 27 |
+
LLMRateLimitError,
|
| 28 |
+
LLMTimeoutError,
|
| 29 |
+
)
|
| 30 |
+
from .models import LLMProviderHealth, LLMResponse
|
| 31 |
+
|
| 32 |
+
logger = logging.getLogger(__name__)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class CircuitState(str, Enum):
|
| 36 |
+
"""Circuit breaker states."""
|
| 37 |
+
|
| 38 |
+
CLOSED = "closed" # Normal operation
|
| 39 |
+
OPEN = "open" # Rejecting requests
|
| 40 |
+
HALF_OPEN = "half_open" # Testing recovery
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class ProviderCircuitBreaker:
|
| 44 |
+
"""
|
| 45 |
+
Circuit breaker for an individual LLM provider.
|
| 46 |
+
|
| 47 |
+
Prevents cascading failures by temporarily blocking requests
|
| 48 |
+
to a failing provider.
|
| 49 |
+
"""
|
| 50 |
+
|
| 51 |
+
def __init__(
|
| 52 |
+
self,
|
| 53 |
+
provider_name: str,
|
| 54 |
+
failure_threshold: int = 3,
|
| 55 |
+
reset_timeout: float = 60.0,
|
| 56 |
+
) -> None:
|
| 57 |
+
self.provider_name = provider_name
|
| 58 |
+
self.failure_threshold = failure_threshold
|
| 59 |
+
self.reset_timeout = reset_timeout
|
| 60 |
+
|
| 61 |
+
self._state = CircuitState.CLOSED
|
| 62 |
+
self._failure_count = 0
|
| 63 |
+
self._last_failure_time: datetime | None = None
|
| 64 |
+
self._last_success_time: datetime | None = None
|
| 65 |
+
|
| 66 |
+
@property
|
| 67 |
+
def state(self) -> CircuitState:
|
| 68 |
+
"""Get current circuit state, checking for timeout transitions."""
|
| 69 |
+
if self._state == CircuitState.OPEN:
|
| 70 |
+
if self._should_attempt_reset():
|
| 71 |
+
self._state = CircuitState.HALF_OPEN
|
| 72 |
+
logger.info(
|
| 73 |
+
f"Circuit breaker for {self.provider_name} "
|
| 74 |
+
"transitioning to HALF_OPEN"
|
| 75 |
+
)
|
| 76 |
+
return self._state
|
| 77 |
+
|
| 78 |
+
@property
|
| 79 |
+
def is_available(self) -> bool:
|
| 80 |
+
"""Check if provider is available for requests."""
|
| 81 |
+
return self.state != CircuitState.OPEN
|
| 82 |
+
|
| 83 |
+
def _should_attempt_reset(self) -> bool:
|
| 84 |
+
"""Check if enough time has passed to attempt reset."""
|
| 85 |
+
if self._last_failure_time is None:
|
| 86 |
+
return True
|
| 87 |
+
elapsed = (datetime.now() - self._last_failure_time).total_seconds()
|
| 88 |
+
return elapsed >= self.reset_timeout
|
| 89 |
+
|
| 90 |
+
def record_success(self) -> None:
|
| 91 |
+
"""Record a successful request."""
|
| 92 |
+
self._failure_count = 0
|
| 93 |
+
self._last_success_time = datetime.now()
|
| 94 |
+
|
| 95 |
+
if self._state == CircuitState.HALF_OPEN:
|
| 96 |
+
self._state = CircuitState.CLOSED
|
| 97 |
+
logger.info(
|
| 98 |
+
f"Circuit breaker for {self.provider_name} "
|
| 99 |
+
"CLOSED after successful test"
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
def record_failure(self, error: Exception) -> None:
|
| 103 |
+
"""Record a failed request."""
|
| 104 |
+
self._failure_count += 1
|
| 105 |
+
self._last_failure_time = datetime.now()
|
| 106 |
+
|
| 107 |
+
logger.warning(
|
| 108 |
+
f"Provider {self.provider_name} failure "
|
| 109 |
+
f"({self._failure_count}/{self.failure_threshold}): {error}"
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
if self._state == CircuitState.HALF_OPEN:
|
| 113 |
+
# Test request failed, back to OPEN
|
| 114 |
+
self._state = CircuitState.OPEN
|
| 115 |
+
logger.warning(
|
| 116 |
+
f"Circuit breaker for {self.provider_name} "
|
| 117 |
+
"OPEN after failed test"
|
| 118 |
+
)
|
| 119 |
+
elif self._failure_count >= self.failure_threshold:
|
| 120 |
+
self._state = CircuitState.OPEN
|
| 121 |
+
logger.warning(
|
| 122 |
+
f"Circuit breaker for {self.provider_name} OPENED "
|
| 123 |
+
f"after {self._failure_count} failures"
|
| 124 |
+
)
|
| 125 |
+
|
| 126 |
+
def get_health(self) -> LLMProviderHealth:
|
| 127 |
+
"""Get health status for this provider."""
|
| 128 |
+
return LLMProviderHealth(
|
| 129 |
+
provider_name=self.provider_name,
|
| 130 |
+
is_available=self.is_available,
|
| 131 |
+
is_primary=False, # Set by parent
|
| 132 |
+
consecutive_failures=self._failure_count,
|
| 133 |
+
last_success=self._last_success_time,
|
| 134 |
+
last_error=None, # Could track this
|
| 135 |
+
circuit_open=self.state == CircuitState.OPEN,
|
| 136 |
+
)
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
class LLMFallbackChain:
|
| 140 |
+
"""
|
| 141 |
+
Manages LLM providers with automatic fallback.
|
| 142 |
+
|
| 143 |
+
Order: Gemini (primary) -> OpenAI (fallback) -> Error
|
| 144 |
+
|
| 145 |
+
Features:
|
| 146 |
+
- Circuit breaker per provider (3 failures, 60s reset)
|
| 147 |
+
- Configurable timeouts (Gemini: 30s, OpenAI: 45s)
|
| 148 |
+
- Automatic fallback on rate limits, timeouts, errors
|
| 149 |
+
- Provider health tracking
|
| 150 |
+
"""
|
| 151 |
+
|
| 152 |
+
# Timeout configuration per provider
|
| 153 |
+
PROVIDER_TIMEOUTS: dict[str, float] = {
|
| 154 |
+
"gemini": 30.0,
|
| 155 |
+
"openai": 45.0,
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
def __init__(
|
| 159 |
+
self,
|
| 160 |
+
settings: AppSettings | None = None,
|
| 161 |
+
gemini_timeout: float | None = None,
|
| 162 |
+
openai_timeout: float | None = None,
|
| 163 |
+
) -> None:
|
| 164 |
+
"""
|
| 165 |
+
Initialize the LLM fallback chain.
|
| 166 |
+
|
| 167 |
+
Args:
|
| 168 |
+
settings: Application settings. Defaults to get_settings().
|
| 169 |
+
gemini_timeout: Override Gemini timeout.
|
| 170 |
+
openai_timeout: Override OpenAI timeout.
|
| 171 |
+
"""
|
| 172 |
+
self._settings = settings or get_settings()
|
| 173 |
+
|
| 174 |
+
# Override timeouts if provided
|
| 175 |
+
if gemini_timeout:
|
| 176 |
+
self.PROVIDER_TIMEOUTS["gemini"] = gemini_timeout
|
| 177 |
+
if openai_timeout:
|
| 178 |
+
self.PROVIDER_TIMEOUTS["openai"] = openai_timeout
|
| 179 |
+
|
| 180 |
+
# Initialize providers (lazy)
|
| 181 |
+
self._gemini: Gemini | None = None
|
| 182 |
+
self._openai: OpenAI | None = None
|
| 183 |
+
|
| 184 |
+
# Circuit breakers
|
| 185 |
+
self._gemini_breaker = ProviderCircuitBreaker("gemini")
|
| 186 |
+
self._openai_breaker = ProviderCircuitBreaker("openai")
|
| 187 |
+
|
| 188 |
+
# Track which provider is currently active
|
| 189 |
+
self._current_provider: str | None = None
|
| 190 |
+
|
| 191 |
+
logger.debug("LLMFallbackChain initialized")
|
| 192 |
+
|
| 193 |
+
def _get_gemini(self) -> Gemini | None:
|
| 194 |
+
"""Get or create Gemini LLM instance."""
|
| 195 |
+
if self._gemini is not None:
|
| 196 |
+
return self._gemini
|
| 197 |
+
|
| 198 |
+
if not self._settings.llm.has_gemini:
|
| 199 |
+
logger.warning("Gemini API key not configured")
|
| 200 |
+
return None
|
| 201 |
+
|
| 202 |
+
try:
|
| 203 |
+
self._gemini = Gemini(
|
| 204 |
+
api_key=self._settings.llm.gemini_api_key,
|
| 205 |
+
model=self._settings.llm.gemini_model,
|
| 206 |
+
temperature=self._settings.llm.temperature,
|
| 207 |
+
max_tokens=self._settings.llm.max_tokens,
|
| 208 |
+
)
|
| 209 |
+
logger.info(f"Gemini LLM initialized: {self._settings.llm.gemini_model}")
|
| 210 |
+
return self._gemini
|
| 211 |
+
except Exception as e:
|
| 212 |
+
logger.error(f"Failed to initialize Gemini: {e}")
|
| 213 |
+
return None
|
| 214 |
+
|
| 215 |
+
def _get_openai(self) -> OpenAI | None:
|
| 216 |
+
"""Get or create OpenAI LLM instance."""
|
| 217 |
+
if self._openai is not None:
|
| 218 |
+
return self._openai
|
| 219 |
+
|
| 220 |
+
if not self._settings.llm.has_openai:
|
| 221 |
+
logger.warning("OpenAI API key not configured")
|
| 222 |
+
return None
|
| 223 |
+
|
| 224 |
+
try:
|
| 225 |
+
self._openai = OpenAI(
|
| 226 |
+
api_key=self._settings.llm.openai_api_key,
|
| 227 |
+
model=self._settings.llm.openai_model,
|
| 228 |
+
temperature=self._settings.llm.temperature,
|
| 229 |
+
max_tokens=self._settings.llm.max_tokens,
|
| 230 |
+
)
|
| 231 |
+
logger.info(f"OpenAI LLM initialized: {self._settings.llm.openai_model}")
|
| 232 |
+
return self._openai
|
| 233 |
+
except Exception as e:
|
| 234 |
+
logger.error(f"Failed to initialize OpenAI: {e}")
|
| 235 |
+
return None
|
| 236 |
+
|
| 237 |
+
def get_primary_llm(self) -> LLM | None:
|
| 238 |
+
"""
|
| 239 |
+
Get the primary LLM for use with LlamaIndex agents.
|
| 240 |
+
|
| 241 |
+
Returns Gemini if available, otherwise OpenAI.
|
| 242 |
+
"""
|
| 243 |
+
gemini = self._get_gemini()
|
| 244 |
+
if gemini and self._gemini_breaker.is_available:
|
| 245 |
+
return gemini
|
| 246 |
+
|
| 247 |
+
openai = self._get_openai()
|
| 248 |
+
if openai and self._openai_breaker.is_available:
|
| 249 |
+
return openai
|
| 250 |
+
|
| 251 |
+
return None
|
| 252 |
+
|
| 253 |
+
def get_fallback_llm(self) -> LLM | None:
|
| 254 |
+
"""
|
| 255 |
+
Get the fallback LLM for use when primary fails.
|
| 256 |
+
|
| 257 |
+
Returns OpenAI if Gemini is primary, otherwise None.
|
| 258 |
+
"""
|
| 259 |
+
# If Gemini is available, OpenAI is fallback
|
| 260 |
+
if self._get_gemini() and self._gemini_breaker.is_available:
|
| 261 |
+
openai = self._get_openai()
|
| 262 |
+
if openai and self._openai_breaker.is_available:
|
| 263 |
+
return openai
|
| 264 |
+
|
| 265 |
+
return None
|
| 266 |
+
|
| 267 |
+
async def generate(
|
| 268 |
+
self,
|
| 269 |
+
messages: list[ChatMessage],
|
| 270 |
+
timeout: float | None = None,
|
| 271 |
+
) -> LLMResponse:
|
| 272 |
+
"""
|
| 273 |
+
Generate a response using the LLM chain with fallback.
|
| 274 |
+
|
| 275 |
+
Args:
|
| 276 |
+
messages: Chat messages to send.
|
| 277 |
+
timeout: Optional timeout override.
|
| 278 |
+
|
| 279 |
+
Returns:
|
| 280 |
+
LLMResponse with generated text and metadata.
|
| 281 |
+
|
| 282 |
+
Raises:
|
| 283 |
+
LLMAllProvidersFailedError: If all providers fail.
|
| 284 |
+
"""
|
| 285 |
+
errors: dict[str, str] = {}
|
| 286 |
+
start_time = time.time()
|
| 287 |
+
|
| 288 |
+
# Try Gemini first
|
| 289 |
+
gemini = self._get_gemini()
|
| 290 |
+
if gemini and self._gemini_breaker.is_available:
|
| 291 |
+
try:
|
| 292 |
+
response = await self._call_provider(
|
| 293 |
+
provider_name="gemini",
|
| 294 |
+
llm=gemini,
|
| 295 |
+
messages=messages,
|
| 296 |
+
timeout=timeout or self.PROVIDER_TIMEOUTS["gemini"],
|
| 297 |
+
breaker=self._gemini_breaker,
|
| 298 |
+
)
|
| 299 |
+
response.latency_ms = (time.time() - start_time) * 1000
|
| 300 |
+
return response
|
| 301 |
+
except Exception as e:
|
| 302 |
+
errors["gemini"] = str(e)
|
| 303 |
+
logger.warning(f"Gemini failed, trying fallback: {e}")
|
| 304 |
+
elif not self._gemini_breaker.is_available:
|
| 305 |
+
errors["gemini"] = "Circuit breaker open"
|
| 306 |
+
|
| 307 |
+
# Try OpenAI as fallback
|
| 308 |
+
openai = self._get_openai()
|
| 309 |
+
if openai and self._openai_breaker.is_available:
|
| 310 |
+
try:
|
| 311 |
+
response = await self._call_provider(
|
| 312 |
+
provider_name="openai",
|
| 313 |
+
llm=openai,
|
| 314 |
+
messages=messages,
|
| 315 |
+
timeout=timeout or self.PROVIDER_TIMEOUTS["openai"],
|
| 316 |
+
breaker=self._openai_breaker,
|
| 317 |
+
)
|
| 318 |
+
response.from_fallback = True
|
| 319 |
+
response.latency_ms = (time.time() - start_time) * 1000
|
| 320 |
+
return response
|
| 321 |
+
except Exception as e:
|
| 322 |
+
errors["openai"] = str(e)
|
| 323 |
+
logger.error(f"OpenAI fallback also failed: {e}")
|
| 324 |
+
elif not self._openai_breaker.is_available:
|
| 325 |
+
errors["openai"] = "Circuit breaker open"
|
| 326 |
+
|
| 327 |
+
# All providers failed
|
| 328 |
+
raise LLMAllProvidersFailedError(errors)
|
| 329 |
+
|
| 330 |
+
async def _call_provider(
|
| 331 |
+
self,
|
| 332 |
+
provider_name: str,
|
| 333 |
+
llm: LLM,
|
| 334 |
+
messages: list[ChatMessage],
|
| 335 |
+
timeout: float,
|
| 336 |
+
breaker: ProviderCircuitBreaker,
|
| 337 |
+
) -> LLMResponse:
|
| 338 |
+
"""
|
| 339 |
+
Call a specific LLM provider with timeout and circuit breaker.
|
| 340 |
+
|
| 341 |
+
Args:
|
| 342 |
+
provider_name: Name of the provider.
|
| 343 |
+
llm: LLM instance to use.
|
| 344 |
+
messages: Messages to send.
|
| 345 |
+
timeout: Request timeout.
|
| 346 |
+
breaker: Circuit breaker for this provider.
|
| 347 |
+
|
| 348 |
+
Returns:
|
| 349 |
+
LLMResponse with generated text.
|
| 350 |
+
|
| 351 |
+
Raises:
|
| 352 |
+
Various LLM exceptions on failure.
|
| 353 |
+
"""
|
| 354 |
+
if not breaker.is_available:
|
| 355 |
+
raise LLMCircuitBreakerOpenError(provider_name)
|
| 356 |
+
|
| 357 |
+
try:
|
| 358 |
+
# Call with timeout
|
| 359 |
+
response = await asyncio.wait_for(
|
| 360 |
+
llm.achat(messages),
|
| 361 |
+
timeout=timeout,
|
| 362 |
+
)
|
| 363 |
+
|
| 364 |
+
# Extract text from response
|
| 365 |
+
text = response.message.content if response.message else ""
|
| 366 |
+
|
| 367 |
+
# Record success
|
| 368 |
+
breaker.record_success()
|
| 369 |
+
self._current_provider = provider_name
|
| 370 |
+
|
| 371 |
+
return LLMResponse(
|
| 372 |
+
text=str(text),
|
| 373 |
+
provider_used=provider_name,
|
| 374 |
+
model_used=getattr(llm, "model", "unknown"),
|
| 375 |
+
)
|
| 376 |
+
|
| 377 |
+
except asyncio.TimeoutError as e:
|
| 378 |
+
breaker.record_failure(e)
|
| 379 |
+
raise LLMTimeoutError(provider_name, timeout) from e
|
| 380 |
+
|
| 381 |
+
except Exception as e:
|
| 382 |
+
# Categorize the error
|
| 383 |
+
error_str = str(e).lower()
|
| 384 |
+
|
| 385 |
+
if "rate" in error_str and "limit" in error_str:
|
| 386 |
+
breaker.record_failure(e)
|
| 387 |
+
raise LLMRateLimitError(provider_name) from e
|
| 388 |
+
|
| 389 |
+
if "auth" in error_str or "api key" in error_str or "401" in error_str:
|
| 390 |
+
breaker.record_failure(e)
|
| 391 |
+
raise LLMAuthenticationError(provider_name) from e
|
| 392 |
+
|
| 393 |
+
if "quota" in error_str or "exceeded" in error_str:
|
| 394 |
+
breaker.record_failure(e)
|
| 395 |
+
raise LLMQuotaExhaustedError(provider_name) from e
|
| 396 |
+
|
| 397 |
+
# Generic error
|
| 398 |
+
breaker.record_failure(e)
|
| 399 |
+
raise
|
| 400 |
+
|
| 401 |
+
def get_health(self) -> dict[str, LLMProviderHealth]:
|
| 402 |
+
"""
|
| 403 |
+
Get health status of all providers.
|
| 404 |
+
|
| 405 |
+
Returns:
|
| 406 |
+
Dictionary mapping provider name to health status.
|
| 407 |
+
"""
|
| 408 |
+
health = {}
|
| 409 |
+
|
| 410 |
+
gemini_health = self._gemini_breaker.get_health()
|
| 411 |
+
gemini_health.is_primary = True
|
| 412 |
+
gemini_health.is_available = (
|
| 413 |
+
self._settings.llm.has_gemini
|
| 414 |
+
and self._gemini_breaker.is_available
|
| 415 |
+
)
|
| 416 |
+
health["gemini"] = gemini_health
|
| 417 |
+
|
| 418 |
+
openai_health = self._openai_breaker.get_health()
|
| 419 |
+
openai_health.is_primary = False
|
| 420 |
+
openai_health.is_available = (
|
| 421 |
+
self._settings.llm.has_openai
|
| 422 |
+
and self._openai_breaker.is_available
|
| 423 |
+
)
|
| 424 |
+
health["openai"] = openai_health
|
| 425 |
+
|
| 426 |
+
return health
|
| 427 |
+
|
| 428 |
+
@property
|
| 429 |
+
def current_provider(self) -> str | None:
|
| 430 |
+
"""Get the name of the most recently used provider."""
|
| 431 |
+
return self._current_provider
|
| 432 |
+
|
| 433 |
+
def reset_circuit_breakers(self) -> None:
|
| 434 |
+
"""Reset all circuit breakers to closed state."""
|
| 435 |
+
self._gemini_breaker = ProviderCircuitBreaker("gemini")
|
| 436 |
+
self._openai_breaker = ProviderCircuitBreaker("openai")
|
| 437 |
+
logger.info("All circuit breakers reset")
|
src/agents/models.py
ADDED
|
@@ -0,0 +1,516 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DungeonMaster AI - Agent Models
|
| 3 |
+
|
| 4 |
+
Pydantic models for agent responses, turn results, and game interactions.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
from enum import Enum
|
| 11 |
+
|
| 12 |
+
from pydantic import BaseModel, Field
|
| 13 |
+
|
| 14 |
+
from src.mcp_integration.models import (
|
| 15 |
+
CombatStateResult,
|
| 16 |
+
DiceRollResult,
|
| 17 |
+
)
|
| 18 |
+
from src.voice.models import VoiceType
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
# =============================================================================
|
| 22 |
+
# Enums
|
| 23 |
+
# =============================================================================
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class GameMode(str, Enum):
|
| 27 |
+
"""Game modes for DM agent context switching."""
|
| 28 |
+
|
| 29 |
+
EXPLORATION = "exploration"
|
| 30 |
+
COMBAT = "combat"
|
| 31 |
+
SOCIAL = "social"
|
| 32 |
+
NARRATIVE = "narrative"
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class DegradationLevel(str, Enum):
|
| 36 |
+
"""System degradation levels for graceful fallbacks."""
|
| 37 |
+
|
| 38 |
+
FULL = "full" # All features working
|
| 39 |
+
PARTIAL = "partial" # Some tools unavailable
|
| 40 |
+
TEXT_ONLY = "text_only" # Voice unavailable
|
| 41 |
+
MINIMAL = "minimal" # Fallback dice only, limited LLM
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class SpecialMomentType(str, Enum):
|
| 45 |
+
"""Types of special dramatic moments."""
|
| 46 |
+
|
| 47 |
+
CRITICAL_HIT = "critical_hit"
|
| 48 |
+
CRITICAL_MISS = "critical_miss"
|
| 49 |
+
DEATH_SAVE_SUCCESS = "death_save_success"
|
| 50 |
+
DEATH_SAVE_FAILURE = "death_save_failure"
|
| 51 |
+
DEATH_SAVE_NAT_20 = "death_save_nat_20"
|
| 52 |
+
DEATH_SAVE_NAT_1 = "death_save_nat_1"
|
| 53 |
+
KILLING_BLOW = "killing_blow"
|
| 54 |
+
PLAYER_DEATH = "player_death"
|
| 55 |
+
LEVEL_UP = "level_up"
|
| 56 |
+
COMBAT_START = "combat_start"
|
| 57 |
+
COMBAT_VICTORY = "combat_victory"
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class PacingStyle(str, Enum):
|
| 61 |
+
"""Response pacing styles."""
|
| 62 |
+
|
| 63 |
+
VERBOSE = "verbose" # New locations, world-building (3-5 sentences)
|
| 64 |
+
STANDARD = "standard" # Normal gameplay (2-3 sentences)
|
| 65 |
+
QUICK = "quick" # Combat actions (1-2 sentences)
|
| 66 |
+
DRAMATIC = "dramatic" # Critical moments (short, punchy)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
# =============================================================================
|
| 70 |
+
# Voice Segment Models
|
| 71 |
+
# =============================================================================
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
class VoiceSegment(BaseModel):
|
| 75 |
+
"""A segment of text with assigned voice profile."""
|
| 76 |
+
|
| 77 |
+
text: str = Field(description="Text content to synthesize")
|
| 78 |
+
voice_type: VoiceType = Field(
|
| 79 |
+
default=VoiceType.DM,
|
| 80 |
+
description="Voice profile to use",
|
| 81 |
+
)
|
| 82 |
+
pause_before_ms: int = Field(
|
| 83 |
+
default=0,
|
| 84 |
+
ge=0,
|
| 85 |
+
description="Pause duration before this segment in milliseconds",
|
| 86 |
+
)
|
| 87 |
+
pause_after_ms: int = Field(
|
| 88 |
+
default=0,
|
| 89 |
+
ge=0,
|
| 90 |
+
description="Pause duration after this segment in milliseconds",
|
| 91 |
+
)
|
| 92 |
+
emphasis: bool = Field(
|
| 93 |
+
default=False,
|
| 94 |
+
description="Whether to emphasize this segment",
|
| 95 |
+
)
|
| 96 |
+
is_dialogue: bool = Field(
|
| 97 |
+
default=False,
|
| 98 |
+
description="Whether this is NPC/character dialogue",
|
| 99 |
+
)
|
| 100 |
+
speaker_name: str | None = Field(
|
| 101 |
+
default=None,
|
| 102 |
+
description="Name of the speaker if dialogue",
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
# =============================================================================
|
| 107 |
+
# Tool Call Models
|
| 108 |
+
# =============================================================================
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
class ToolCallInfo(BaseModel):
|
| 112 |
+
"""Information about a tool call made during processing."""
|
| 113 |
+
|
| 114 |
+
tool_name: str = Field(description="Name of the tool called")
|
| 115 |
+
arguments: dict[str, object] = Field(
|
| 116 |
+
default_factory=dict,
|
| 117 |
+
description="Arguments passed to the tool",
|
| 118 |
+
)
|
| 119 |
+
result: object = Field(
|
| 120 |
+
default=None,
|
| 121 |
+
description="Result returned by the tool",
|
| 122 |
+
)
|
| 123 |
+
success: bool = Field(
|
| 124 |
+
default=True,
|
| 125 |
+
description="Whether the tool call succeeded",
|
| 126 |
+
)
|
| 127 |
+
error_message: str | None = Field(
|
| 128 |
+
default=None,
|
| 129 |
+
description="Error message if tool call failed",
|
| 130 |
+
)
|
| 131 |
+
duration_ms: float = Field(
|
| 132 |
+
default=0.0,
|
| 133 |
+
description="Time taken by the tool call",
|
| 134 |
+
)
|
| 135 |
+
timestamp: datetime = Field(
|
| 136 |
+
default_factory=datetime.now,
|
| 137 |
+
description="When the tool was called",
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
# =============================================================================
|
| 142 |
+
# Special Moment Models
|
| 143 |
+
# =============================================================================
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
class SpecialMoment(BaseModel):
|
| 147 |
+
"""A dramatic moment in the game requiring special handling."""
|
| 148 |
+
|
| 149 |
+
moment_type: SpecialMomentType = Field(description="Type of special moment")
|
| 150 |
+
enhanced_narration: str = Field(
|
| 151 |
+
default="",
|
| 152 |
+
description="Dramatic narration for this moment",
|
| 153 |
+
)
|
| 154 |
+
voice_type: VoiceType = Field(
|
| 155 |
+
default=VoiceType.DM,
|
| 156 |
+
description="Voice to use for narration",
|
| 157 |
+
)
|
| 158 |
+
ui_effects: list[str] = Field(
|
| 159 |
+
default_factory=list,
|
| 160 |
+
description="UI effects to trigger (e.g., 'screen_shake', 'golden_glow')",
|
| 161 |
+
)
|
| 162 |
+
pause_before_ms: int = Field(
|
| 163 |
+
default=500,
|
| 164 |
+
description="Dramatic pause before narration",
|
| 165 |
+
)
|
| 166 |
+
sound_effect: str | None = Field(
|
| 167 |
+
default=None,
|
| 168 |
+
description="Optional sound effect to play",
|
| 169 |
+
)
|
| 170 |
+
context: dict[str, object] = Field(
|
| 171 |
+
default_factory=dict,
|
| 172 |
+
description="Additional context (roll value, weapon, etc.)",
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
# =============================================================================
|
| 177 |
+
# DM Response Models
|
| 178 |
+
# =============================================================================
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
class DMResponse(BaseModel):
|
| 182 |
+
"""Response from the Dungeon Master agent."""
|
| 183 |
+
|
| 184 |
+
narration: str = Field(
|
| 185 |
+
description="The narrative text response from the DM",
|
| 186 |
+
)
|
| 187 |
+
voice_segments: list[VoiceSegment] = Field(
|
| 188 |
+
default_factory=list,
|
| 189 |
+
description="Text segments with voice assignments for multi-voice synthesis",
|
| 190 |
+
)
|
| 191 |
+
tool_calls: list[ToolCallInfo] = Field(
|
| 192 |
+
default_factory=list,
|
| 193 |
+
description="Tools that were called during processing",
|
| 194 |
+
)
|
| 195 |
+
dice_rolls: list[DiceRollResult] = Field(
|
| 196 |
+
default_factory=list,
|
| 197 |
+
description="Dice rolls that occurred",
|
| 198 |
+
)
|
| 199 |
+
game_state_updates: dict[str, object] = Field(
|
| 200 |
+
default_factory=dict,
|
| 201 |
+
description="State changes to apply (hp_changes, location, combat, etc.)",
|
| 202 |
+
)
|
| 203 |
+
special_moment: SpecialMoment | None = Field(
|
| 204 |
+
default=None,
|
| 205 |
+
description="Special dramatic moment if detected",
|
| 206 |
+
)
|
| 207 |
+
ui_effects: list[str] = Field(
|
| 208 |
+
default_factory=list,
|
| 209 |
+
description="UI effects to trigger",
|
| 210 |
+
)
|
| 211 |
+
game_mode: GameMode = Field(
|
| 212 |
+
default=GameMode.EXPLORATION,
|
| 213 |
+
description="Current game mode after processing",
|
| 214 |
+
)
|
| 215 |
+
pacing: PacingStyle = Field(
|
| 216 |
+
default=PacingStyle.STANDARD,
|
| 217 |
+
description="Pacing style used for response",
|
| 218 |
+
)
|
| 219 |
+
# Metadata
|
| 220 |
+
processing_time_ms: float = Field(
|
| 221 |
+
default=0.0,
|
| 222 |
+
description="Total processing time",
|
| 223 |
+
)
|
| 224 |
+
llm_provider: str = Field(
|
| 225 |
+
default="",
|
| 226 |
+
description="Which LLM provider was used",
|
| 227 |
+
)
|
| 228 |
+
timestamp: datetime = Field(
|
| 229 |
+
default_factory=datetime.now,
|
| 230 |
+
description="When response was generated",
|
| 231 |
+
)
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
# =============================================================================
|
| 235 |
+
# Rules Response Models
|
| 236 |
+
# =============================================================================
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
class RulesResponse(BaseModel):
|
| 240 |
+
"""Response from the Rules Arbiter agent."""
|
| 241 |
+
|
| 242 |
+
answer: str = Field(description="The rules answer/explanation")
|
| 243 |
+
sources: list[str] = Field(
|
| 244 |
+
default_factory=list,
|
| 245 |
+
description="Sources/citations for the rule",
|
| 246 |
+
)
|
| 247 |
+
confidence: float = Field(
|
| 248 |
+
default=1.0,
|
| 249 |
+
ge=0.0,
|
| 250 |
+
le=1.0,
|
| 251 |
+
description="Confidence in the answer",
|
| 252 |
+
)
|
| 253 |
+
tool_calls: list[ToolCallInfo] = Field(
|
| 254 |
+
default_factory=list,
|
| 255 |
+
description="Tools called to find the answer",
|
| 256 |
+
)
|
| 257 |
+
from_cache: bool = Field(
|
| 258 |
+
default=False,
|
| 259 |
+
description="Whether result came from cache",
|
| 260 |
+
)
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
# =============================================================================
|
| 264 |
+
# Turn Result Models
|
| 265 |
+
# =============================================================================
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
class TurnResult(BaseModel):
|
| 269 |
+
"""Complete result of processing a player turn."""
|
| 270 |
+
|
| 271 |
+
# Core response
|
| 272 |
+
narration_text: str = Field(
|
| 273 |
+
description="The narrative text to display",
|
| 274 |
+
)
|
| 275 |
+
narration_audio: bytes | None = Field(
|
| 276 |
+
default=None,
|
| 277 |
+
description="Synthesized audio (combined from all segments)",
|
| 278 |
+
)
|
| 279 |
+
|
| 280 |
+
# Detailed information
|
| 281 |
+
voice_segments: list[VoiceSegment] = Field(
|
| 282 |
+
default_factory=list,
|
| 283 |
+
description="Individual voice segments for multi-voice",
|
| 284 |
+
)
|
| 285 |
+
tool_calls: list[ToolCallInfo] = Field(
|
| 286 |
+
default_factory=list,
|
| 287 |
+
description="All tools that were called",
|
| 288 |
+
)
|
| 289 |
+
dice_rolls: list[DiceRollResult] = Field(
|
| 290 |
+
default_factory=list,
|
| 291 |
+
description="All dice rolls that occurred",
|
| 292 |
+
)
|
| 293 |
+
|
| 294 |
+
# Game state
|
| 295 |
+
game_mode: GameMode = Field(
|
| 296 |
+
default=GameMode.EXPLORATION,
|
| 297 |
+
description="Current game mode",
|
| 298 |
+
)
|
| 299 |
+
combat_updates: CombatStateResult | None = Field(
|
| 300 |
+
default=None,
|
| 301 |
+
description="Combat state changes if in combat",
|
| 302 |
+
)
|
| 303 |
+
|
| 304 |
+
# Special handling
|
| 305 |
+
special_moment: SpecialMoment | None = Field(
|
| 306 |
+
default=None,
|
| 307 |
+
description="Special dramatic moment if any",
|
| 308 |
+
)
|
| 309 |
+
ui_effects: list[str] = Field(
|
| 310 |
+
default_factory=list,
|
| 311 |
+
description="UI effects to trigger",
|
| 312 |
+
)
|
| 313 |
+
|
| 314 |
+
# Status
|
| 315 |
+
degradation_level: DegradationLevel = Field(
|
| 316 |
+
default=DegradationLevel.FULL,
|
| 317 |
+
description="System degradation level",
|
| 318 |
+
)
|
| 319 |
+
voice_enabled: bool = Field(
|
| 320 |
+
default=True,
|
| 321 |
+
description="Whether voice was generated",
|
| 322 |
+
)
|
| 323 |
+
error_message: str | None = Field(
|
| 324 |
+
default=None,
|
| 325 |
+
description="Error message if something went wrong",
|
| 326 |
+
)
|
| 327 |
+
|
| 328 |
+
# Metadata
|
| 329 |
+
turn_number: int = Field(
|
| 330 |
+
default=0,
|
| 331 |
+
description="Current turn number",
|
| 332 |
+
)
|
| 333 |
+
processing_time_ms: float = Field(
|
| 334 |
+
default=0.0,
|
| 335 |
+
description="Total processing time",
|
| 336 |
+
)
|
| 337 |
+
llm_provider: str = Field(
|
| 338 |
+
default="",
|
| 339 |
+
description="Which LLM was used",
|
| 340 |
+
)
|
| 341 |
+
timestamp: datetime = Field(
|
| 342 |
+
default_factory=datetime.now,
|
| 343 |
+
description="When turn was processed",
|
| 344 |
+
)
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
# =============================================================================
|
| 348 |
+
# Streaming Models
|
| 349 |
+
# =============================================================================
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
class StreamChunk(BaseModel):
|
| 353 |
+
"""A chunk of streaming response."""
|
| 354 |
+
|
| 355 |
+
text: str = Field(
|
| 356 |
+
default="",
|
| 357 |
+
description="Incremental text content",
|
| 358 |
+
)
|
| 359 |
+
is_complete: bool = Field(
|
| 360 |
+
default=False,
|
| 361 |
+
description="Whether this is the final chunk",
|
| 362 |
+
)
|
| 363 |
+
tool_call: ToolCallInfo | None = Field(
|
| 364 |
+
default=None,
|
| 365 |
+
description="Tool call if one occurred in this chunk",
|
| 366 |
+
)
|
| 367 |
+
accumulated_text: str = Field(
|
| 368 |
+
default="",
|
| 369 |
+
description="Full text accumulated so far",
|
| 370 |
+
)
|
| 371 |
+
|
| 372 |
+
|
| 373 |
+
# =============================================================================
|
| 374 |
+
# Game Context Models
|
| 375 |
+
# =============================================================================
|
| 376 |
+
|
| 377 |
+
|
| 378 |
+
class GameContext(BaseModel):
|
| 379 |
+
"""Context information passed to agents for decision making."""
|
| 380 |
+
|
| 381 |
+
# Core state
|
| 382 |
+
session_id: str = Field(description="Current session ID")
|
| 383 |
+
turn_number: int = Field(default=0, description="Current turn number")
|
| 384 |
+
game_mode: GameMode = Field(
|
| 385 |
+
default=GameMode.EXPLORATION,
|
| 386 |
+
description="Current game mode",
|
| 387 |
+
)
|
| 388 |
+
|
| 389 |
+
# Location
|
| 390 |
+
current_location: str = Field(
|
| 391 |
+
default="Unknown",
|
| 392 |
+
description="Current location name",
|
| 393 |
+
)
|
| 394 |
+
location_description: str = Field(
|
| 395 |
+
default="",
|
| 396 |
+
description="Description of current location",
|
| 397 |
+
)
|
| 398 |
+
|
| 399 |
+
# Party
|
| 400 |
+
active_character_id: str | None = Field(
|
| 401 |
+
default=None,
|
| 402 |
+
description="Currently controlled character",
|
| 403 |
+
)
|
| 404 |
+
party_summary: str = Field(
|
| 405 |
+
default="",
|
| 406 |
+
description="Summary of party status (HP, conditions)",
|
| 407 |
+
)
|
| 408 |
+
|
| 409 |
+
# Combat
|
| 410 |
+
in_combat: bool = Field(
|
| 411 |
+
default=False,
|
| 412 |
+
description="Whether combat is active",
|
| 413 |
+
)
|
| 414 |
+
combat_round: int | None = Field(
|
| 415 |
+
default=None,
|
| 416 |
+
description="Current combat round if in combat",
|
| 417 |
+
)
|
| 418 |
+
current_combatant: str | None = Field(
|
| 419 |
+
default=None,
|
| 420 |
+
description="Whose turn it is in combat",
|
| 421 |
+
)
|
| 422 |
+
|
| 423 |
+
# NPCs
|
| 424 |
+
current_npc: dict[str, object] | None = Field(
|
| 425 |
+
default=None,
|
| 426 |
+
description="Current NPC being interacted with",
|
| 427 |
+
)
|
| 428 |
+
npcs_present: list[str] = Field(
|
| 429 |
+
default_factory=list,
|
| 430 |
+
description="NPCs in current scene",
|
| 431 |
+
)
|
| 432 |
+
|
| 433 |
+
# Recent history
|
| 434 |
+
recent_events_summary: str = Field(
|
| 435 |
+
default="",
|
| 436 |
+
description="Summary of recent events for context",
|
| 437 |
+
)
|
| 438 |
+
|
| 439 |
+
# Adventure
|
| 440 |
+
adventure_name: str | None = Field(
|
| 441 |
+
default=None,
|
| 442 |
+
description="Name of current adventure",
|
| 443 |
+
)
|
| 444 |
+
story_flags: dict[str, object] = Field(
|
| 445 |
+
default_factory=dict,
|
| 446 |
+
description="Active story/quest flags",
|
| 447 |
+
)
|
| 448 |
+
|
| 449 |
+
|
| 450 |
+
# =============================================================================
|
| 451 |
+
# LLM Provider Models
|
| 452 |
+
# =============================================================================
|
| 453 |
+
|
| 454 |
+
|
| 455 |
+
class LLMProviderHealth(BaseModel):
|
| 456 |
+
"""Health status of an LLM provider."""
|
| 457 |
+
|
| 458 |
+
provider_name: str = Field(description="Name of the provider")
|
| 459 |
+
is_available: bool = Field(
|
| 460 |
+
default=False,
|
| 461 |
+
description="Whether provider is available",
|
| 462 |
+
)
|
| 463 |
+
is_primary: bool = Field(
|
| 464 |
+
default=False,
|
| 465 |
+
description="Whether this is the primary provider",
|
| 466 |
+
)
|
| 467 |
+
consecutive_failures: int = Field(
|
| 468 |
+
default=0,
|
| 469 |
+
description="Number of consecutive failures",
|
| 470 |
+
)
|
| 471 |
+
last_success: datetime | None = Field(
|
| 472 |
+
default=None,
|
| 473 |
+
description="Last successful call",
|
| 474 |
+
)
|
| 475 |
+
last_error: str | None = Field(
|
| 476 |
+
default=None,
|
| 477 |
+
description="Last error message",
|
| 478 |
+
)
|
| 479 |
+
circuit_open: bool = Field(
|
| 480 |
+
default=False,
|
| 481 |
+
description="Whether circuit breaker is open",
|
| 482 |
+
)
|
| 483 |
+
|
| 484 |
+
|
| 485 |
+
class LLMResponse(BaseModel):
|
| 486 |
+
"""Response from LLM provider chain."""
|
| 487 |
+
|
| 488 |
+
text: str = Field(description="Generated text")
|
| 489 |
+
tool_calls: list[dict[str, object]] = Field(
|
| 490 |
+
default_factory=list,
|
| 491 |
+
description="Tool calls requested by LLM",
|
| 492 |
+
)
|
| 493 |
+
provider_used: str = Field(
|
| 494 |
+
default="",
|
| 495 |
+
description="Which provider generated this response",
|
| 496 |
+
)
|
| 497 |
+
model_used: str = Field(
|
| 498 |
+
default="",
|
| 499 |
+
description="Which model was used",
|
| 500 |
+
)
|
| 501 |
+
input_tokens: int = Field(
|
| 502 |
+
default=0,
|
| 503 |
+
description="Input token count",
|
| 504 |
+
)
|
| 505 |
+
output_tokens: int = Field(
|
| 506 |
+
default=0,
|
| 507 |
+
description="Output token count",
|
| 508 |
+
)
|
| 509 |
+
latency_ms: float = Field(
|
| 510 |
+
default=0.0,
|
| 511 |
+
description="Response latency",
|
| 512 |
+
)
|
| 513 |
+
from_fallback: bool = Field(
|
| 514 |
+
default=False,
|
| 515 |
+
description="Whether fallback provider was used",
|
| 516 |
+
)
|
src/agents/orchestrator.py
ADDED
|
@@ -0,0 +1,911 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DungeonMaster AI - Agent Orchestrator
|
| 3 |
+
|
| 4 |
+
Main coordinator for all agents with comprehensive error handling.
|
| 5 |
+
Processes player turns, routes to appropriate agents, and manages game flow.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import logging
|
| 11 |
+
import re
|
| 12 |
+
import time
|
| 13 |
+
from typing import TYPE_CHECKING
|
| 14 |
+
|
| 15 |
+
from src.config.settings import AppSettings, get_settings
|
| 16 |
+
from src.game.game_state import GameState
|
| 17 |
+
from src.mcp_integration.connection_manager import ConnectionManager
|
| 18 |
+
from src.mcp_integration.toolkit_client import TTRPGToolkitClient
|
| 19 |
+
from src.mcp_integration.tool_wrappers import GameAwareTools
|
| 20 |
+
from src.voice.elevenlabs_client import VoiceClient
|
| 21 |
+
from src.voice.text_processor import NarrationProcessor
|
| 22 |
+
|
| 23 |
+
from .dungeon_master import DungeonMasterAgent
|
| 24 |
+
from .exceptions import (
|
| 25 |
+
GRACEFUL_ERROR_MESSAGES,
|
| 26 |
+
OrchestratorError,
|
| 27 |
+
OrchestratorNotInitializedError,
|
| 28 |
+
TurnProcessingError,
|
| 29 |
+
get_graceful_message,
|
| 30 |
+
)
|
| 31 |
+
from .llm_provider import LLMFallbackChain
|
| 32 |
+
from .models import (
|
| 33 |
+
DegradationLevel,
|
| 34 |
+
DMResponse,
|
| 35 |
+
GameContext,
|
| 36 |
+
GameMode,
|
| 37 |
+
TurnResult,
|
| 38 |
+
)
|
| 39 |
+
from .pacing_controller import PacingController
|
| 40 |
+
from .rules_arbiter import RulesArbiterAgent
|
| 41 |
+
from .special_moments import SpecialMomentHandler
|
| 42 |
+
from .voice_narrator import VoiceNarratorAgent, create_voice_narrator
|
| 43 |
+
|
| 44 |
+
if TYPE_CHECKING:
|
| 45 |
+
pass
|
| 46 |
+
|
| 47 |
+
logger = logging.getLogger(__name__)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
# =============================================================================
|
| 51 |
+
# Special Commands
|
| 52 |
+
# =============================================================================
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
SPECIAL_COMMANDS: dict[str, str] = {
|
| 56 |
+
"/roll": "_handle_roll_command",
|
| 57 |
+
"/r": "_handle_roll_command",
|
| 58 |
+
"/rules": "_handle_rules_command",
|
| 59 |
+
"/rule": "_handle_rules_command",
|
| 60 |
+
"/character": "_handle_character_command",
|
| 61 |
+
"/char": "_handle_character_command",
|
| 62 |
+
"/sheet": "_handle_character_command",
|
| 63 |
+
"/inventory": "_handle_inventory_command",
|
| 64 |
+
"/inv": "_handle_inventory_command",
|
| 65 |
+
"/rest": "_handle_rest_command",
|
| 66 |
+
"/short": "_handle_short_rest_command",
|
| 67 |
+
"/long": "_handle_long_rest_command",
|
| 68 |
+
"/help": "_handle_help_command",
|
| 69 |
+
"/status": "_handle_status_command",
|
| 70 |
+
"/combat": "_handle_combat_command",
|
| 71 |
+
"/fight": "_handle_combat_command",
|
| 72 |
+
"/endcombat": "_handle_end_combat_command",
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
# =============================================================================
|
| 77 |
+
# AgentOrchestrator
|
| 78 |
+
# =============================================================================
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
class AgentOrchestrator:
|
| 82 |
+
"""
|
| 83 |
+
Main coordinator for all agents with comprehensive error handling.
|
| 84 |
+
|
| 85 |
+
Guarantees:
|
| 86 |
+
- Always returns a TurnResult (never raises to caller)
|
| 87 |
+
- State consistency maintained
|
| 88 |
+
- Graceful degradation on failures
|
| 89 |
+
- User-friendly error messages
|
| 90 |
+
|
| 91 |
+
Components:
|
| 92 |
+
- DungeonMasterAgent: Main storytelling
|
| 93 |
+
- RulesArbiterAgent: Rules lookup
|
| 94 |
+
- VoiceNarratorAgent: Voice synthesis
|
| 95 |
+
- SpecialMomentHandler: Dramatic moments
|
| 96 |
+
- PacingController: Response pacing
|
| 97 |
+
"""
|
| 98 |
+
|
| 99 |
+
def __init__(
|
| 100 |
+
self,
|
| 101 |
+
toolkit_client: TTRPGToolkitClient | None = None,
|
| 102 |
+
voice_client: VoiceClient | None = None,
|
| 103 |
+
settings: AppSettings | None = None,
|
| 104 |
+
game_state: GameState | None = None,
|
| 105 |
+
) -> None:
|
| 106 |
+
"""
|
| 107 |
+
Initialize the orchestrator.
|
| 108 |
+
|
| 109 |
+
Args:
|
| 110 |
+
toolkit_client: MCP toolkit client. Created if not provided.
|
| 111 |
+
voice_client: Voice client. Created if not provided.
|
| 112 |
+
settings: Application settings. Uses defaults if not provided.
|
| 113 |
+
game_state: Game state. Created if not provided.
|
| 114 |
+
"""
|
| 115 |
+
self._settings = settings or get_settings()
|
| 116 |
+
self._toolkit_client = toolkit_client or TTRPGToolkitClient()
|
| 117 |
+
self._voice_client = voice_client
|
| 118 |
+
|
| 119 |
+
# Will be initialized in setup()
|
| 120 |
+
self._llm_chain: LLMFallbackChain | None = None
|
| 121 |
+
self._dm_agent: DungeonMasterAgent | None = None
|
| 122 |
+
self._rules_agent: RulesArbiterAgent | None = None
|
| 123 |
+
self._voice_narrator: VoiceNarratorAgent | None = None
|
| 124 |
+
|
| 125 |
+
# Support components
|
| 126 |
+
self._special_moments = SpecialMomentHandler()
|
| 127 |
+
self._pacing = PacingController()
|
| 128 |
+
|
| 129 |
+
# Game state
|
| 130 |
+
self._game_state = game_state or GameState()
|
| 131 |
+
self._conversation_history: list[dict[str, str]] = []
|
| 132 |
+
|
| 133 |
+
# Status
|
| 134 |
+
self._initialized = False
|
| 135 |
+
self._degradation_level = DegradationLevel.FULL
|
| 136 |
+
|
| 137 |
+
logger.info("AgentOrchestrator created")
|
| 138 |
+
|
| 139 |
+
@property
|
| 140 |
+
def is_initialized(self) -> bool:
|
| 141 |
+
"""Check if orchestrator is initialized."""
|
| 142 |
+
return self._initialized
|
| 143 |
+
|
| 144 |
+
@property
|
| 145 |
+
def game_state(self) -> GameState:
|
| 146 |
+
"""Get current game state."""
|
| 147 |
+
return self._game_state
|
| 148 |
+
|
| 149 |
+
@property
|
| 150 |
+
def degradation_level(self) -> DegradationLevel:
|
| 151 |
+
"""Get current degradation level."""
|
| 152 |
+
return self._degradation_level
|
| 153 |
+
|
| 154 |
+
async def setup(self) -> None:
|
| 155 |
+
"""
|
| 156 |
+
Initialize all components.
|
| 157 |
+
|
| 158 |
+
Connects to MCP, initializes LLMs, creates agents.
|
| 159 |
+
Must be called before process_turn().
|
| 160 |
+
"""
|
| 161 |
+
logger.info("Setting up AgentOrchestrator...")
|
| 162 |
+
|
| 163 |
+
try:
|
| 164 |
+
# Initialize LLM chain
|
| 165 |
+
self._llm_chain = LLMFallbackChain(self._settings)
|
| 166 |
+
primary_llm = self._llm_chain.get_primary_llm()
|
| 167 |
+
|
| 168 |
+
if primary_llm is None:
|
| 169 |
+
logger.error("No LLM available!")
|
| 170 |
+
raise OrchestratorError("No LLM provider available. Check API keys.")
|
| 171 |
+
|
| 172 |
+
# Connect to MCP server
|
| 173 |
+
try:
|
| 174 |
+
await self._toolkit_client.connect()
|
| 175 |
+
logger.info(
|
| 176 |
+
f"Connected to MCP with {self._toolkit_client.tools_count} tools"
|
| 177 |
+
)
|
| 178 |
+
except Exception as e:
|
| 179 |
+
logger.warning(f"MCP connection failed: {e}. Using degraded mode.")
|
| 180 |
+
self._degradation_level = DegradationLevel.PARTIAL
|
| 181 |
+
|
| 182 |
+
# Get tools
|
| 183 |
+
if self._toolkit_client.is_connected:
|
| 184 |
+
all_tools = list(await self._toolkit_client.get_all_tools())
|
| 185 |
+
|
| 186 |
+
# Wrap tools with game-aware enhancements
|
| 187 |
+
game_aware = GameAwareTools(
|
| 188 |
+
game_state=self._game_state,
|
| 189 |
+
session_logger=self._log_session_event,
|
| 190 |
+
ui_notifier=self._handle_ui_update,
|
| 191 |
+
)
|
| 192 |
+
wrapped_tools = game_aware.wrap_tools(all_tools)
|
| 193 |
+
|
| 194 |
+
# Create DM agent
|
| 195 |
+
self._dm_agent = DungeonMasterAgent(
|
| 196 |
+
llm=primary_llm,
|
| 197 |
+
tools=wrapped_tools,
|
| 198 |
+
game_state=self._game_state,
|
| 199 |
+
)
|
| 200 |
+
|
| 201 |
+
# Create rules agent
|
| 202 |
+
self._rules_agent = RulesArbiterAgent(
|
| 203 |
+
llm=primary_llm,
|
| 204 |
+
tools=wrapped_tools,
|
| 205 |
+
)
|
| 206 |
+
else:
|
| 207 |
+
# No MCP - create DM agent without tools
|
| 208 |
+
self._dm_agent = DungeonMasterAgent(
|
| 209 |
+
llm=primary_llm,
|
| 210 |
+
tools=[],
|
| 211 |
+
game_state=self._game_state,
|
| 212 |
+
)
|
| 213 |
+
logger.warning("DM agent created without MCP tools")
|
| 214 |
+
|
| 215 |
+
# Initialize voice narrator
|
| 216 |
+
try:
|
| 217 |
+
if self._voice_client is None:
|
| 218 |
+
self._voice_client = VoiceClient()
|
| 219 |
+
await self._voice_client.initialize()
|
| 220 |
+
|
| 221 |
+
self._voice_narrator = await create_voice_narrator(
|
| 222 |
+
voice_client=self._voice_client,
|
| 223 |
+
pre_cache=True,
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
if self._voice_narrator is None:
|
| 227 |
+
logger.warning("Voice narrator not available")
|
| 228 |
+
self._degradation_level = DegradationLevel.TEXT_ONLY
|
| 229 |
+
except Exception as e:
|
| 230 |
+
logger.warning(f"Voice initialization failed: {e}")
|
| 231 |
+
self._degradation_level = DegradationLevel.TEXT_ONLY
|
| 232 |
+
|
| 233 |
+
self._initialized = True
|
| 234 |
+
logger.info(
|
| 235 |
+
f"AgentOrchestrator setup complete. "
|
| 236 |
+
f"Degradation level: {self._degradation_level.value}"
|
| 237 |
+
)
|
| 238 |
+
|
| 239 |
+
except Exception as e:
|
| 240 |
+
logger.error(f"Orchestrator setup failed: {e}")
|
| 241 |
+
raise OrchestratorError(f"Setup failed: {e}") from e
|
| 242 |
+
|
| 243 |
+
async def process_turn(
|
| 244 |
+
self,
|
| 245 |
+
player_input: str,
|
| 246 |
+
voice_enabled: bool = True,
|
| 247 |
+
) -> TurnResult:
|
| 248 |
+
"""
|
| 249 |
+
Process a complete player turn.
|
| 250 |
+
|
| 251 |
+
Args:
|
| 252 |
+
player_input: The player's action/speech.
|
| 253 |
+
voice_enabled: Whether to generate voice narration.
|
| 254 |
+
|
| 255 |
+
Returns:
|
| 256 |
+
TurnResult with narration, audio, and state updates.
|
| 257 |
+
"""
|
| 258 |
+
if not self._initialized:
|
| 259 |
+
raise OrchestratorNotInitializedError()
|
| 260 |
+
|
| 261 |
+
start_time = time.time()
|
| 262 |
+
turn_number = self._game_state.increment_turn()
|
| 263 |
+
|
| 264 |
+
try:
|
| 265 |
+
# Check for special commands
|
| 266 |
+
special_result = await self._handle_special_command(player_input)
|
| 267 |
+
if special_result is not None:
|
| 268 |
+
return special_result
|
| 269 |
+
|
| 270 |
+
# Process through DM agent
|
| 271 |
+
dm_response = await self._process_dm_turn(player_input)
|
| 272 |
+
|
| 273 |
+
# Generate voice narration
|
| 274 |
+
audio = None
|
| 275 |
+
logger.info(f"[VOICE] enabled={voice_enabled}, narrator={self._voice_narrator is not None}")
|
| 276 |
+
if self._voice_narrator:
|
| 277 |
+
logger.info(f"[VOICE] narrator.is_available={self._voice_narrator.is_available}")
|
| 278 |
+
|
| 279 |
+
if voice_enabled and self._voice_narrator and dm_response:
|
| 280 |
+
try:
|
| 281 |
+
game_context = self._build_game_context()
|
| 282 |
+
text_len = len(dm_response.narration)
|
| 283 |
+
logger.info(f"[VOICE] Calling narrate(), text_len={text_len}")
|
| 284 |
+
narration_result = await self._voice_narrator.narrate(
|
| 285 |
+
dm_response, game_context
|
| 286 |
+
)
|
| 287 |
+
logger.info(f"[VOICE] result.success={narration_result.success}")
|
| 288 |
+
if narration_result.success:
|
| 289 |
+
audio = narration_result.audio
|
| 290 |
+
audio_len = len(audio) if audio else 0
|
| 291 |
+
logger.info(f"[VOICE] audio bytes={audio_len}")
|
| 292 |
+
else:
|
| 293 |
+
logger.warning(f"[VOICE] failed: {narration_result.error_message}")
|
| 294 |
+
except Exception as e:
|
| 295 |
+
logger.warning(f"Voice narration failed: {e}")
|
| 296 |
+
|
| 297 |
+
# Update conversation history
|
| 298 |
+
self._conversation_history.append({
|
| 299 |
+
"role": "user",
|
| 300 |
+
"content": player_input,
|
| 301 |
+
})
|
| 302 |
+
self._conversation_history.append({
|
| 303 |
+
"role": "assistant",
|
| 304 |
+
"content": dm_response.narration if dm_response else "",
|
| 305 |
+
})
|
| 306 |
+
|
| 307 |
+
# Build turn result
|
| 308 |
+
processing_time = (time.time() - start_time) * 1000
|
| 309 |
+
|
| 310 |
+
return TurnResult(
|
| 311 |
+
narration_text=dm_response.narration if dm_response else "",
|
| 312 |
+
narration_audio=audio,
|
| 313 |
+
voice_segments=dm_response.voice_segments if dm_response else [],
|
| 314 |
+
tool_calls=dm_response.tool_calls if dm_response else [],
|
| 315 |
+
dice_rolls=dm_response.dice_rolls if dm_response else [],
|
| 316 |
+
game_mode=dm_response.game_mode if dm_response else GameMode.EXPLORATION,
|
| 317 |
+
combat_updates=None, # Would come from tool results
|
| 318 |
+
special_moment=dm_response.special_moment if dm_response else None,
|
| 319 |
+
ui_effects=dm_response.ui_effects if dm_response else [],
|
| 320 |
+
degradation_level=self._degradation_level,
|
| 321 |
+
voice_enabled=voice_enabled and audio is not None,
|
| 322 |
+
turn_number=turn_number,
|
| 323 |
+
processing_time_ms=processing_time,
|
| 324 |
+
llm_provider=self._llm_chain.current_provider or "unknown" if self._llm_chain else "",
|
| 325 |
+
)
|
| 326 |
+
|
| 327 |
+
except Exception as e:
|
| 328 |
+
logger.error(f"Turn processing failed: {e}")
|
| 329 |
+
|
| 330 |
+
# Return graceful error response
|
| 331 |
+
processing_time = (time.time() - start_time) * 1000
|
| 332 |
+
|
| 333 |
+
return TurnResult(
|
| 334 |
+
narration_text=get_graceful_message("general_error"),
|
| 335 |
+
degradation_level=DegradationLevel.MINIMAL,
|
| 336 |
+
error_message=str(e),
|
| 337 |
+
turn_number=turn_number,
|
| 338 |
+
processing_time_ms=processing_time,
|
| 339 |
+
)
|
| 340 |
+
|
| 341 |
+
async def _process_dm_turn(self, player_input: str) -> DMResponse | None:
|
| 342 |
+
"""Process turn through DM agent."""
|
| 343 |
+
if self._dm_agent is None:
|
| 344 |
+
logger.error("DM agent not initialized")
|
| 345 |
+
return None
|
| 346 |
+
|
| 347 |
+
try:
|
| 348 |
+
game_context = self._build_game_context()
|
| 349 |
+
response = await self._dm_agent.process(player_input, game_context)
|
| 350 |
+
return response
|
| 351 |
+
except Exception as e:
|
| 352 |
+
logger.error(f"DM agent processing failed: {e}")
|
| 353 |
+
raise
|
| 354 |
+
|
| 355 |
+
async def _handle_special_command(
|
| 356 |
+
self,
|
| 357 |
+
player_input: str,
|
| 358 |
+
) -> TurnResult | None:
|
| 359 |
+
"""
|
| 360 |
+
Handle special commands like /roll, /rules, etc.
|
| 361 |
+
|
| 362 |
+
Returns TurnResult if command was handled, None otherwise.
|
| 363 |
+
"""
|
| 364 |
+
input_stripped = player_input.strip()
|
| 365 |
+
|
| 366 |
+
# Check if it's a special command
|
| 367 |
+
for command, handler_name in SPECIAL_COMMANDS.items():
|
| 368 |
+
if input_stripped.lower().startswith(command):
|
| 369 |
+
# Extract arguments
|
| 370 |
+
args = input_stripped[len(command):].strip()
|
| 371 |
+
handler = getattr(self, handler_name, None)
|
| 372 |
+
if handler:
|
| 373 |
+
return await handler(args)
|
| 374 |
+
|
| 375 |
+
return None
|
| 376 |
+
|
| 377 |
+
async def _handle_roll_command(self, args: str) -> TurnResult:
|
| 378 |
+
"""Handle /roll command for direct dice rolls."""
|
| 379 |
+
notation = args.strip() or "1d20"
|
| 380 |
+
|
| 381 |
+
try:
|
| 382 |
+
# Call MCP roll tool directly
|
| 383 |
+
result = await self._toolkit_client.call_tool("roll", {"notation": notation})
|
| 384 |
+
|
| 385 |
+
# Format result
|
| 386 |
+
if isinstance(result, dict):
|
| 387 |
+
total = result.get("total", 0)
|
| 388 |
+
narration = f"**Roll {notation}:** {total}"
|
| 389 |
+
if "breakdown" in result:
|
| 390 |
+
narration += f" ({result['breakdown']})"
|
| 391 |
+
else:
|
| 392 |
+
narration = f"Roll result: {result}"
|
| 393 |
+
|
| 394 |
+
return TurnResult(
|
| 395 |
+
narration_text=narration,
|
| 396 |
+
degradation_level=self._degradation_level,
|
| 397 |
+
turn_number=self._game_state.turn_count,
|
| 398 |
+
)
|
| 399 |
+
except Exception as e:
|
| 400 |
+
return TurnResult(
|
| 401 |
+
narration_text=f"Roll failed: {e}",
|
| 402 |
+
error_message=str(e),
|
| 403 |
+
degradation_level=DegradationLevel.MINIMAL,
|
| 404 |
+
turn_number=self._game_state.turn_count,
|
| 405 |
+
)
|
| 406 |
+
|
| 407 |
+
async def _handle_rules_command(self, args: str) -> TurnResult:
|
| 408 |
+
"""Handle /rules command for rules lookup."""
|
| 409 |
+
if not args:
|
| 410 |
+
return TurnResult(
|
| 411 |
+
narration_text="Usage: /rules <question> - Ask about game rules",
|
| 412 |
+
turn_number=self._game_state.turn_count,
|
| 413 |
+
)
|
| 414 |
+
|
| 415 |
+
if self._rules_agent is None:
|
| 416 |
+
return TurnResult(
|
| 417 |
+
narration_text="Rules lookup not available.",
|
| 418 |
+
degradation_level=DegradationLevel.MINIMAL,
|
| 419 |
+
turn_number=self._game_state.turn_count,
|
| 420 |
+
)
|
| 421 |
+
|
| 422 |
+
try:
|
| 423 |
+
response = await self._rules_agent.lookup(args)
|
| 424 |
+
return TurnResult(
|
| 425 |
+
narration_text=response.answer,
|
| 426 |
+
tool_calls=response.tool_calls,
|
| 427 |
+
turn_number=self._game_state.turn_count,
|
| 428 |
+
)
|
| 429 |
+
except Exception as e:
|
| 430 |
+
return TurnResult(
|
| 431 |
+
narration_text=f"Rules lookup failed: {e}",
|
| 432 |
+
error_message=str(e),
|
| 433 |
+
turn_number=self._game_state.turn_count,
|
| 434 |
+
)
|
| 435 |
+
|
| 436 |
+
async def _handle_character_command(self, args: str) -> TurnResult:
|
| 437 |
+
"""Handle /character command to show character sheet."""
|
| 438 |
+
char_id = self._game_state.active_character_id
|
| 439 |
+
|
| 440 |
+
if not char_id:
|
| 441 |
+
return TurnResult(
|
| 442 |
+
narration_text="No active character. Create one first!",
|
| 443 |
+
turn_number=self._game_state.turn_count,
|
| 444 |
+
)
|
| 445 |
+
|
| 446 |
+
try:
|
| 447 |
+
result = await self._toolkit_client.call_tool(
|
| 448 |
+
"get_character", {"character_id": char_id}
|
| 449 |
+
)
|
| 450 |
+
|
| 451 |
+
if isinstance(result, dict):
|
| 452 |
+
# Format character info
|
| 453 |
+
name = result.get("name", "Unknown")
|
| 454 |
+
hp = result.get("current_hp", "?")
|
| 455 |
+
max_hp = result.get("max_hp", "?")
|
| 456 |
+
level = result.get("level", 1)
|
| 457 |
+
char_class = result.get("class", "Unknown")
|
| 458 |
+
|
| 459 |
+
narration = (
|
| 460 |
+
f"**{name}** - Level {level} {char_class}\n"
|
| 461 |
+
f"HP: {hp}/{max_hp}"
|
| 462 |
+
)
|
| 463 |
+
else:
|
| 464 |
+
narration = f"Character info: {result}"
|
| 465 |
+
|
| 466 |
+
return TurnResult(
|
| 467 |
+
narration_text=narration,
|
| 468 |
+
turn_number=self._game_state.turn_count,
|
| 469 |
+
)
|
| 470 |
+
except Exception as e:
|
| 471 |
+
return TurnResult(
|
| 472 |
+
narration_text=f"Could not fetch character: {e}",
|
| 473 |
+
error_message=str(e),
|
| 474 |
+
turn_number=self._game_state.turn_count,
|
| 475 |
+
)
|
| 476 |
+
|
| 477 |
+
async def _handle_inventory_command(self, args: str) -> TurnResult:
|
| 478 |
+
"""Handle /inventory command."""
|
| 479 |
+
char_id = self._game_state.active_character_id
|
| 480 |
+
|
| 481 |
+
if not char_id:
|
| 482 |
+
return TurnResult(
|
| 483 |
+
narration_text="No active character.",
|
| 484 |
+
turn_number=self._game_state.turn_count,
|
| 485 |
+
)
|
| 486 |
+
|
| 487 |
+
try:
|
| 488 |
+
result = await self._toolkit_client.call_tool(
|
| 489 |
+
"get_character", {"character_id": char_id}
|
| 490 |
+
)
|
| 491 |
+
|
| 492 |
+
if isinstance(result, dict):
|
| 493 |
+
equipment = result.get("equipment", [])
|
| 494 |
+
currency = result.get("currency", {})
|
| 495 |
+
|
| 496 |
+
lines = ["**Inventory:**"]
|
| 497 |
+
for item in equipment[:10]: # Limit display
|
| 498 |
+
if isinstance(item, dict):
|
| 499 |
+
lines.append(f"- {item.get('name', 'Unknown item')}")
|
| 500 |
+
else:
|
| 501 |
+
lines.append(f"- {item}")
|
| 502 |
+
|
| 503 |
+
# Add currency
|
| 504 |
+
gold = currency.get("gold", 0)
|
| 505 |
+
if gold:
|
| 506 |
+
lines.append(f"\n**Gold:** {gold} gp")
|
| 507 |
+
|
| 508 |
+
narration = "\n".join(lines)
|
| 509 |
+
else:
|
| 510 |
+
narration = "Could not fetch inventory."
|
| 511 |
+
|
| 512 |
+
return TurnResult(
|
| 513 |
+
narration_text=narration,
|
| 514 |
+
turn_number=self._game_state.turn_count,
|
| 515 |
+
)
|
| 516 |
+
except Exception as e:
|
| 517 |
+
return TurnResult(
|
| 518 |
+
narration_text=f"Inventory fetch failed: {e}",
|
| 519 |
+
turn_number=self._game_state.turn_count,
|
| 520 |
+
)
|
| 521 |
+
|
| 522 |
+
async def _handle_rest_command(self, args: str) -> TurnResult:
|
| 523 |
+
"""Handle /rest command."""
|
| 524 |
+
rest_type = args.strip().lower() or "short"
|
| 525 |
+
return await self._do_rest(rest_type)
|
| 526 |
+
|
| 527 |
+
async def _handle_short_rest_command(self, args: str) -> TurnResult:
|
| 528 |
+
"""Handle /short command for short rest."""
|
| 529 |
+
return await self._do_rest("short")
|
| 530 |
+
|
| 531 |
+
async def _handle_long_rest_command(self, args: str) -> TurnResult:
|
| 532 |
+
"""Handle /long command for long rest."""
|
| 533 |
+
return await self._do_rest("long")
|
| 534 |
+
|
| 535 |
+
async def _do_rest(self, rest_type: str) -> TurnResult:
|
| 536 |
+
"""Execute a rest action."""
|
| 537 |
+
char_id = self._game_state.active_character_id
|
| 538 |
+
|
| 539 |
+
if not char_id:
|
| 540 |
+
return TurnResult(
|
| 541 |
+
narration_text="No active character to rest.",
|
| 542 |
+
turn_number=self._game_state.turn_count,
|
| 543 |
+
)
|
| 544 |
+
|
| 545 |
+
try:
|
| 546 |
+
result = await self._toolkit_client.call_tool(
|
| 547 |
+
"rest",
|
| 548 |
+
{"character_id": char_id, "rest_type": rest_type},
|
| 549 |
+
)
|
| 550 |
+
|
| 551 |
+
narration = f"You complete a {rest_type} rest. "
|
| 552 |
+
if isinstance(result, dict):
|
| 553 |
+
hp_restored = result.get("hp_restored", 0)
|
| 554 |
+
if hp_restored:
|
| 555 |
+
narration += f"You recover {hp_restored} hit points."
|
| 556 |
+
|
| 557 |
+
return TurnResult(
|
| 558 |
+
narration_text=narration,
|
| 559 |
+
turn_number=self._game_state.turn_count,
|
| 560 |
+
)
|
| 561 |
+
except Exception as e:
|
| 562 |
+
return TurnResult(
|
| 563 |
+
narration_text=f"Rest failed: {e}",
|
| 564 |
+
turn_number=self._game_state.turn_count,
|
| 565 |
+
)
|
| 566 |
+
|
| 567 |
+
async def _handle_help_command(self, args: str) -> TurnResult:
|
| 568 |
+
"""Handle /help command."""
|
| 569 |
+
help_text = """**Available Commands:**
|
| 570 |
+
- `/roll <notation>` - Roll dice (e.g., `/roll 2d6+3`)
|
| 571 |
+
- `/rules <question>` - Look up game rules
|
| 572 |
+
- `/character` - Show character sheet
|
| 573 |
+
- `/inventory` - Show inventory
|
| 574 |
+
- `/rest [short|long]` - Take a rest
|
| 575 |
+
- `/combat <enemies>` - Start combat (e.g., `/combat goblin, orc`)
|
| 576 |
+
- `/endcombat` - End current combat
|
| 577 |
+
- `/status` - Show system status
|
| 578 |
+
- `/help` - Show this help
|
| 579 |
+
|
| 580 |
+
**Gameplay:**
|
| 581 |
+
Just type what you want to do! The DM will guide the adventure.
|
| 582 |
+
Example: "I search the room for hidden doors"
|
| 583 |
+
"""
|
| 584 |
+
return TurnResult(
|
| 585 |
+
narration_text=help_text,
|
| 586 |
+
turn_number=self._game_state.turn_count,
|
| 587 |
+
)
|
| 588 |
+
|
| 589 |
+
async def _handle_status_command(self, args: str) -> TurnResult:
|
| 590 |
+
"""Handle /status command to show system status."""
|
| 591 |
+
status_parts = []
|
| 592 |
+
|
| 593 |
+
# MCP status
|
| 594 |
+
mcp_status = "Connected" if self._toolkit_client.is_connected else "Disconnected"
|
| 595 |
+
status_parts.append(f"**MCP Server:** {mcp_status}")
|
| 596 |
+
|
| 597 |
+
# LLM status
|
| 598 |
+
if self._llm_chain:
|
| 599 |
+
health = self._llm_chain.get_health()
|
| 600 |
+
for provider, info in health.items():
|
| 601 |
+
status = "Available" if info.is_available else "Unavailable"
|
| 602 |
+
primary = " (Primary)" if info.is_primary else ""
|
| 603 |
+
status_parts.append(f"**{provider.title()}{primary}:** {status}")
|
| 604 |
+
|
| 605 |
+
# Voice status
|
| 606 |
+
if self._voice_narrator:
|
| 607 |
+
voice_status = "Available" if self._voice_narrator.is_available else "Unavailable"
|
| 608 |
+
status_parts.append(f"**Voice:** {voice_status}")
|
| 609 |
+
else:
|
| 610 |
+
status_parts.append("**Voice:** Not initialized")
|
| 611 |
+
|
| 612 |
+
# Degradation level
|
| 613 |
+
status_parts.append(f"**Mode:** {self._degradation_level.value}")
|
| 614 |
+
|
| 615 |
+
return TurnResult(
|
| 616 |
+
narration_text="\n".join(status_parts),
|
| 617 |
+
turn_number=self._game_state.turn_count,
|
| 618 |
+
)
|
| 619 |
+
|
| 620 |
+
async def _handle_combat_command(self, args: str) -> TurnResult:
|
| 621 |
+
"""Handle /combat command to start combat with specified enemies."""
|
| 622 |
+
if not args.strip():
|
| 623 |
+
return TurnResult(
|
| 624 |
+
narration_text=(
|
| 625 |
+
"**Usage:** `/combat <enemy1>, <enemy2>, ...`\n\n"
|
| 626 |
+
"**Examples:**\n"
|
| 627 |
+
"- `/combat goblin`\n"
|
| 628 |
+
"- `/combat goblin, orc, wolf`\n\n"
|
| 629 |
+
"This will start combat with the specified enemies."
|
| 630 |
+
),
|
| 631 |
+
turn_number=self._game_state.turn_count,
|
| 632 |
+
)
|
| 633 |
+
|
| 634 |
+
# Parse enemy names from comma-separated list
|
| 635 |
+
enemy_names = [name.strip() for name in args.split(",") if name.strip()]
|
| 636 |
+
|
| 637 |
+
if not enemy_names:
|
| 638 |
+
return TurnResult(
|
| 639 |
+
narration_text="Please specify at least one enemy. Example: `/combat goblin`",
|
| 640 |
+
turn_number=self._game_state.turn_count,
|
| 641 |
+
)
|
| 642 |
+
|
| 643 |
+
try:
|
| 644 |
+
# Build combatants list
|
| 645 |
+
combatants = []
|
| 646 |
+
|
| 647 |
+
# Add player character if available
|
| 648 |
+
player_name = "Player"
|
| 649 |
+
player_hp = 20
|
| 650 |
+
player_ac = 14
|
| 651 |
+
|
| 652 |
+
if self._game_state.active_character_id:
|
| 653 |
+
char_data = self._game_state.get_character(
|
| 654 |
+
self._game_state.active_character_id
|
| 655 |
+
)
|
| 656 |
+
if char_data:
|
| 657 |
+
player_name = str(char_data.get("name", "Player"))
|
| 658 |
+
hp_data = char_data.get("hit_points", {})
|
| 659 |
+
if isinstance(hp_data, dict):
|
| 660 |
+
player_hp = int(hp_data.get("current", 20))
|
| 661 |
+
player_ac = int(char_data.get("armor_class", 14))
|
| 662 |
+
|
| 663 |
+
# Roll player initiative
|
| 664 |
+
player_init_result = await self._toolkit_client.call_tool(
|
| 665 |
+
"roll", {"notation": "1d20"}
|
| 666 |
+
)
|
| 667 |
+
player_init = 10
|
| 668 |
+
if isinstance(player_init_result, dict):
|
| 669 |
+
player_init = int(player_init_result.get("total", 10))
|
| 670 |
+
|
| 671 |
+
combatants.append({
|
| 672 |
+
"id": self._game_state.active_character_id or "player",
|
| 673 |
+
"name": player_name,
|
| 674 |
+
"initiative": player_init,
|
| 675 |
+
"hp_current": player_hp,
|
| 676 |
+
"hp_max": player_hp,
|
| 677 |
+
"armor_class": player_ac,
|
| 678 |
+
"is_player": True,
|
| 679 |
+
"conditions": [],
|
| 680 |
+
})
|
| 681 |
+
|
| 682 |
+
# Add enemies - try to get monster stats, fallback to defaults
|
| 683 |
+
for i, enemy_name in enumerate(enemy_names):
|
| 684 |
+
enemy_hp = 7 # Default goblin-like HP
|
| 685 |
+
enemy_ac = 12
|
| 686 |
+
enemy_init = 10
|
| 687 |
+
|
| 688 |
+
# Try to get monster stats
|
| 689 |
+
try:
|
| 690 |
+
monster_result = await self._toolkit_client.call_tool(
|
| 691 |
+
"get_monster", {"name": enemy_name}
|
| 692 |
+
)
|
| 693 |
+
if isinstance(monster_result, dict) and monster_result.get("success"):
|
| 694 |
+
monster_data = monster_result.get("monster", {})
|
| 695 |
+
if isinstance(monster_data, dict):
|
| 696 |
+
enemy_hp = int(monster_data.get("hit_points", 7))
|
| 697 |
+
enemy_ac = int(monster_data.get("armor_class", 12))
|
| 698 |
+
except Exception:
|
| 699 |
+
pass # Use defaults
|
| 700 |
+
|
| 701 |
+
# Roll enemy initiative
|
| 702 |
+
try:
|
| 703 |
+
enemy_init_result = await self._toolkit_client.call_tool(
|
| 704 |
+
"roll", {"notation": "1d20"}
|
| 705 |
+
)
|
| 706 |
+
if isinstance(enemy_init_result, dict):
|
| 707 |
+
enemy_init = int(enemy_init_result.get("total", 10))
|
| 708 |
+
except Exception:
|
| 709 |
+
enemy_init = 10
|
| 710 |
+
|
| 711 |
+
combatants.append({
|
| 712 |
+
"id": f"enemy_{i}",
|
| 713 |
+
"name": enemy_name.title(),
|
| 714 |
+
"initiative": enemy_init,
|
| 715 |
+
"hp_current": enemy_hp,
|
| 716 |
+
"hp_max": enemy_hp,
|
| 717 |
+
"armor_class": enemy_ac,
|
| 718 |
+
"is_player": False,
|
| 719 |
+
"conditions": [],
|
| 720 |
+
})
|
| 721 |
+
|
| 722 |
+
# Sort by initiative (descending)
|
| 723 |
+
combatants.sort(key=lambda c: c["initiative"], reverse=True)
|
| 724 |
+
|
| 725 |
+
# Try to call start_combat tool
|
| 726 |
+
try:
|
| 727 |
+
result = await self._toolkit_client.call_tool(
|
| 728 |
+
"start_combat",
|
| 729 |
+
{"combatants": combatants},
|
| 730 |
+
)
|
| 731 |
+
|
| 732 |
+
# Update game state
|
| 733 |
+
combat_state = {
|
| 734 |
+
"turn_order": combatants,
|
| 735 |
+
"current_turn_index": 0,
|
| 736 |
+
"round": 1,
|
| 737 |
+
"is_active": True,
|
| 738 |
+
}
|
| 739 |
+
|
| 740 |
+
if isinstance(result, dict):
|
| 741 |
+
combat_state.update(result)
|
| 742 |
+
|
| 743 |
+
self._game_state.set_combat_state(combat_state)
|
| 744 |
+
|
| 745 |
+
except Exception as e:
|
| 746 |
+
logger.warning(f"start_combat tool failed: {e}, using local state")
|
| 747 |
+
# Set combat state directly if tool fails
|
| 748 |
+
combat_state = {
|
| 749 |
+
"turn_order": combatants,
|
| 750 |
+
"current_turn_index": 0,
|
| 751 |
+
"round": 1,
|
| 752 |
+
"is_active": True,
|
| 753 |
+
}
|
| 754 |
+
self._game_state.set_combat_state(combat_state)
|
| 755 |
+
|
| 756 |
+
# Build response
|
| 757 |
+
initiative_lines = ["**Combat Begins!**\n", "**Initiative Order:**"]
|
| 758 |
+
for i, c in enumerate(combatants):
|
| 759 |
+
marker = "▶ " if i == 0 else " "
|
| 760 |
+
player_marker = "🛡️" if c["is_player"] else "👹"
|
| 761 |
+
initiative_lines.append(
|
| 762 |
+
f"{marker}{c['initiative']:2d} | {player_marker} {c['name']} "
|
| 763 |
+
f"(HP: {c['hp_current']}/{c['hp_max']}, AC: {c['armor_class']})"
|
| 764 |
+
)
|
| 765 |
+
|
| 766 |
+
current = combatants[0]
|
| 767 |
+
if current["is_player"]:
|
| 768 |
+
initiative_lines.append(f"\n**It's your turn, {current['name']}!** What do you do?")
|
| 769 |
+
else:
|
| 770 |
+
initiative_lines.append(f"\n**{current['name']} goes first!**")
|
| 771 |
+
|
| 772 |
+
return TurnResult(
|
| 773 |
+
narration_text="\n".join(initiative_lines),
|
| 774 |
+
turn_number=self._game_state.turn_count,
|
| 775 |
+
)
|
| 776 |
+
|
| 777 |
+
except Exception as e:
|
| 778 |
+
logger.error(f"Combat command failed: {e}")
|
| 779 |
+
return TurnResult(
|
| 780 |
+
narration_text=f"Failed to start combat: {e}",
|
| 781 |
+
error_message=str(e),
|
| 782 |
+
turn_number=self._game_state.turn_count,
|
| 783 |
+
)
|
| 784 |
+
|
| 785 |
+
async def _handle_end_combat_command(self, args: str) -> TurnResult:
|
| 786 |
+
"""Handle /endcombat command to end current combat."""
|
| 787 |
+
if not self._game_state.in_combat:
|
| 788 |
+
return TurnResult(
|
| 789 |
+
narration_text="*You are not currently in combat.*",
|
| 790 |
+
turn_number=self._game_state.turn_count,
|
| 791 |
+
)
|
| 792 |
+
|
| 793 |
+
try:
|
| 794 |
+
# Try to call end_combat tool
|
| 795 |
+
await self._toolkit_client.call_tool("end_combat", {})
|
| 796 |
+
except Exception as e:
|
| 797 |
+
logger.warning(f"end_combat tool failed: {e}")
|
| 798 |
+
|
| 799 |
+
# Clear combat state
|
| 800 |
+
self._game_state.set_combat_state(None)
|
| 801 |
+
|
| 802 |
+
return TurnResult(
|
| 803 |
+
narration_text="**Combat Ends!**\n\n*The dust settles as the battle concludes.*",
|
| 804 |
+
turn_number=self._game_state.turn_count,
|
| 805 |
+
)
|
| 806 |
+
|
| 807 |
+
def _build_game_context(self) -> GameContext:
|
| 808 |
+
"""Build game context from current state."""
|
| 809 |
+
return GameContext(
|
| 810 |
+
session_id=self._game_state.session_id,
|
| 811 |
+
turn_number=self._game_state.turn_count,
|
| 812 |
+
game_mode=self._dm_agent.mode if self._dm_agent else GameMode.EXPLORATION,
|
| 813 |
+
current_location=self._game_state.current_location,
|
| 814 |
+
active_character_id=self._game_state.active_character_id,
|
| 815 |
+
in_combat=self._game_state.in_combat,
|
| 816 |
+
combat_round=self._game_state.combat_state.get("round") if self._game_state.combat_state else None,
|
| 817 |
+
adventure_name=self._game_state.current_adventure,
|
| 818 |
+
)
|
| 819 |
+
|
| 820 |
+
async def _log_session_event(
|
| 821 |
+
self,
|
| 822 |
+
event_type: str,
|
| 823 |
+
description: str,
|
| 824 |
+
data: dict[str, object],
|
| 825 |
+
) -> None:
|
| 826 |
+
"""Callback for logging session events."""
|
| 827 |
+
self._game_state.add_event(event_type, description, data)
|
| 828 |
+
|
| 829 |
+
def _handle_ui_update(self, update_type: str, data: dict[str, object]) -> None:
|
| 830 |
+
"""Callback for UI updates from tool wrappers."""
|
| 831 |
+
# This would notify the UI layer
|
| 832 |
+
logger.debug(f"UI update: {update_type} - {data}")
|
| 833 |
+
|
| 834 |
+
async def new_game(
|
| 835 |
+
self,
|
| 836 |
+
character_id: str | None = None,
|
| 837 |
+
adventure_name: str | None = None,
|
| 838 |
+
) -> TurnResult:
|
| 839 |
+
"""
|
| 840 |
+
Start a new game.
|
| 841 |
+
|
| 842 |
+
Args:
|
| 843 |
+
character_id: Optional character to use.
|
| 844 |
+
adventure_name: Optional adventure to load.
|
| 845 |
+
|
| 846 |
+
Returns:
|
| 847 |
+
TurnResult with opening narration.
|
| 848 |
+
"""
|
| 849 |
+
# Reset state
|
| 850 |
+
self._game_state.reset()
|
| 851 |
+
self._conversation_history.clear()
|
| 852 |
+
self._pacing.reset()
|
| 853 |
+
|
| 854 |
+
if self._dm_agent:
|
| 855 |
+
self._dm_agent.clear_memory()
|
| 856 |
+
|
| 857 |
+
if self._rules_agent:
|
| 858 |
+
self._rules_agent.clear_cache()
|
| 859 |
+
|
| 860 |
+
# Set adventure
|
| 861 |
+
if adventure_name:
|
| 862 |
+
self._game_state.current_adventure = adventure_name
|
| 863 |
+
|
| 864 |
+
# Add character
|
| 865 |
+
if character_id:
|
| 866 |
+
self._game_state.add_character_to_party(character_id)
|
| 867 |
+
|
| 868 |
+
# Generate opening narration
|
| 869 |
+
opening = await self._process_dm_turn(
|
| 870 |
+
"Begin a new adventure. Set the scene and introduce the player to their situation."
|
| 871 |
+
)
|
| 872 |
+
|
| 873 |
+
return TurnResult(
|
| 874 |
+
narration_text=opening.narration if opening else "Your adventure begins...",
|
| 875 |
+
voice_segments=opening.voice_segments if opening else [],
|
| 876 |
+
game_mode=GameMode.EXPLORATION,
|
| 877 |
+
degradation_level=self._degradation_level,
|
| 878 |
+
turn_number=0,
|
| 879 |
+
)
|
| 880 |
+
|
| 881 |
+
async def shutdown(self) -> None:
|
| 882 |
+
"""Gracefully shutdown the orchestrator."""
|
| 883 |
+
logger.info("Shutting down AgentOrchestrator...")
|
| 884 |
+
|
| 885 |
+
if self._toolkit_client:
|
| 886 |
+
await self._toolkit_client.disconnect()
|
| 887 |
+
|
| 888 |
+
self._initialized = False
|
| 889 |
+
logger.info("AgentOrchestrator shutdown complete")
|
| 890 |
+
|
| 891 |
+
|
| 892 |
+
# =============================================================================
|
| 893 |
+
# Factory Function
|
| 894 |
+
# =============================================================================
|
| 895 |
+
|
| 896 |
+
|
| 897 |
+
async def create_orchestrator(
|
| 898 |
+
settings: AppSettings | None = None,
|
| 899 |
+
) -> AgentOrchestrator:
|
| 900 |
+
"""
|
| 901 |
+
Create and initialize an AgentOrchestrator.
|
| 902 |
+
|
| 903 |
+
Args:
|
| 904 |
+
settings: Optional application settings.
|
| 905 |
+
|
| 906 |
+
Returns:
|
| 907 |
+
Initialized AgentOrchestrator ready for use.
|
| 908 |
+
"""
|
| 909 |
+
orchestrator = AgentOrchestrator(settings=settings)
|
| 910 |
+
await orchestrator.setup()
|
| 911 |
+
return orchestrator
|
src/agents/pacing_controller.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DungeonMaster AI - Pacing Controller
|
| 3 |
+
|
| 4 |
+
Controls response verbosity and style based on game context.
|
| 5 |
+
Ensures appropriate pacing for different gameplay situations.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from typing import TYPE_CHECKING
|
| 11 |
+
|
| 12 |
+
from .models import GameMode, PacingStyle, SpecialMoment
|
| 13 |
+
|
| 14 |
+
if TYPE_CHECKING:
|
| 15 |
+
from src.game.game_state import GameState
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
# =============================================================================
|
| 19 |
+
# Pacing Instructions
|
| 20 |
+
# =============================================================================
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
PACING_INSTRUCTIONS: dict[PacingStyle, str] = {
|
| 24 |
+
PacingStyle.VERBOSE: """
|
| 25 |
+
## Current Pacing: VERBOSE
|
| 26 |
+
This is a moment for world-building and immersion. Use rich, evocative descriptions.
|
| 27 |
+
- Describe the environment with sensory details (sight, sound, smell, feel)
|
| 28 |
+
- Paint a vivid picture of the scene
|
| 29 |
+
- Use 3-5 sentences for descriptions
|
| 30 |
+
- Take time to set the atmosphere
|
| 31 |
+
- Let the player absorb the moment
|
| 32 |
+
""",
|
| 33 |
+
PacingStyle.STANDARD: """
|
| 34 |
+
## Current Pacing: STANDARD
|
| 35 |
+
Balance description with action. Keep things moving but engaging.
|
| 36 |
+
- Use 2-3 sentences for responses
|
| 37 |
+
- Describe key details without overwhelming
|
| 38 |
+
- Focus on what's immediately relevant
|
| 39 |
+
- End with a clear prompt for player action
|
| 40 |
+
""",
|
| 41 |
+
PacingStyle.QUICK: """
|
| 42 |
+
## Current Pacing: QUICK
|
| 43 |
+
Combat or action sequence. Keep it fast and punchy.
|
| 44 |
+
- Use 1-2 sentences per action
|
| 45 |
+
- Focus on immediate results
|
| 46 |
+
- Maintain tension and momentum
|
| 47 |
+
- Clear, direct descriptions
|
| 48 |
+
- No lengthy exposition
|
| 49 |
+
""",
|
| 50 |
+
PacingStyle.DRAMATIC: """
|
| 51 |
+
## Current Pacing: DRAMATIC
|
| 52 |
+
Critical moment requiring impact. Short, powerful statements.
|
| 53 |
+
- Use short, punchy sentences
|
| 54 |
+
- Build tension with pauses (...)
|
| 55 |
+
- Make every word count
|
| 56 |
+
- Emphasize the stakes
|
| 57 |
+
- Let the moment breathe
|
| 58 |
+
""",
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
# =============================================================================
|
| 63 |
+
# PacingController
|
| 64 |
+
# =============================================================================
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class PacingController:
|
| 68 |
+
"""
|
| 69 |
+
Controls response verbosity based on game context.
|
| 70 |
+
|
| 71 |
+
Determines appropriate pacing style and provides instructions
|
| 72 |
+
to inject into the DM agent's system prompt.
|
| 73 |
+
"""
|
| 74 |
+
|
| 75 |
+
# Keywords that suggest quick pacing
|
| 76 |
+
QUICK_KEYWORDS: frozenset[str] = frozenset([
|
| 77 |
+
"attack",
|
| 78 |
+
"hit",
|
| 79 |
+
"strike",
|
| 80 |
+
"shoot",
|
| 81 |
+
"cast",
|
| 82 |
+
"dodge",
|
| 83 |
+
"run",
|
| 84 |
+
"flee",
|
| 85 |
+
"block",
|
| 86 |
+
"parry",
|
| 87 |
+
])
|
| 88 |
+
|
| 89 |
+
# Keywords that suggest verbose pacing
|
| 90 |
+
VERBOSE_KEYWORDS: frozenset[str] = frozenset([
|
| 91 |
+
"look",
|
| 92 |
+
"examine",
|
| 93 |
+
"describe",
|
| 94 |
+
"search",
|
| 95 |
+
"explore",
|
| 96 |
+
"enter",
|
| 97 |
+
"arrive",
|
| 98 |
+
"approach",
|
| 99 |
+
])
|
| 100 |
+
|
| 101 |
+
# Keywords that suggest social pacing
|
| 102 |
+
SOCIAL_KEYWORDS: frozenset[str] = frozenset([
|
| 103 |
+
"talk",
|
| 104 |
+
"speak",
|
| 105 |
+
"ask",
|
| 106 |
+
"tell",
|
| 107 |
+
"persuade",
|
| 108 |
+
"convince",
|
| 109 |
+
"negotiate",
|
| 110 |
+
"bribe",
|
| 111 |
+
])
|
| 112 |
+
|
| 113 |
+
def __init__(self) -> None:
|
| 114 |
+
"""Initialize the pacing controller."""
|
| 115 |
+
self._last_pacing = PacingStyle.STANDARD
|
| 116 |
+
self._location_changes = 0
|
| 117 |
+
self._combat_turn_count = 0
|
| 118 |
+
|
| 119 |
+
def determine_pacing(
|
| 120 |
+
self,
|
| 121 |
+
game_mode: GameMode,
|
| 122 |
+
player_input: str,
|
| 123 |
+
game_state: GameState | None = None,
|
| 124 |
+
special_moment: SpecialMoment | None = None,
|
| 125 |
+
) -> PacingStyle:
|
| 126 |
+
"""
|
| 127 |
+
Determine appropriate pacing style.
|
| 128 |
+
|
| 129 |
+
Args:
|
| 130 |
+
game_mode: Current game mode.
|
| 131 |
+
player_input: The player's input.
|
| 132 |
+
game_state: Current game state.
|
| 133 |
+
special_moment: Special moment if any.
|
| 134 |
+
|
| 135 |
+
Returns:
|
| 136 |
+
Appropriate PacingStyle.
|
| 137 |
+
"""
|
| 138 |
+
# Special moments always get dramatic pacing
|
| 139 |
+
if special_moment is not None:
|
| 140 |
+
return PacingStyle.DRAMATIC
|
| 141 |
+
|
| 142 |
+
# Combat mode generally uses quick pacing
|
| 143 |
+
if game_mode == GameMode.COMBAT:
|
| 144 |
+
self._combat_turn_count += 1
|
| 145 |
+
# First combat turn or every 3rd turn can be slightly more descriptive
|
| 146 |
+
if self._combat_turn_count == 1:
|
| 147 |
+
return PacingStyle.STANDARD
|
| 148 |
+
return PacingStyle.QUICK
|
| 149 |
+
|
| 150 |
+
# Reset combat counter when not in combat
|
| 151 |
+
if game_mode != GameMode.COMBAT:
|
| 152 |
+
self._combat_turn_count = 0
|
| 153 |
+
|
| 154 |
+
# Check player input for pacing hints
|
| 155 |
+
input_lower = player_input.lower()
|
| 156 |
+
|
| 157 |
+
# Quick keywords override
|
| 158 |
+
if any(kw in input_lower for kw in self.QUICK_KEYWORDS):
|
| 159 |
+
return PacingStyle.QUICK
|
| 160 |
+
|
| 161 |
+
# Verbose keywords for exploration
|
| 162 |
+
if any(kw in input_lower for kw in self.VERBOSE_KEYWORDS):
|
| 163 |
+
return PacingStyle.VERBOSE
|
| 164 |
+
|
| 165 |
+
# Social interaction is standard pacing
|
| 166 |
+
if any(kw in input_lower for kw in self.SOCIAL_KEYWORDS):
|
| 167 |
+
return PacingStyle.STANDARD
|
| 168 |
+
|
| 169 |
+
# Check for location changes
|
| 170 |
+
if game_state:
|
| 171 |
+
if self._is_new_location(game_state):
|
| 172 |
+
self._location_changes += 1
|
| 173 |
+
return PacingStyle.VERBOSE
|
| 174 |
+
|
| 175 |
+
# Default based on game mode
|
| 176 |
+
mode_defaults: dict[GameMode, PacingStyle] = {
|
| 177 |
+
GameMode.EXPLORATION: PacingStyle.STANDARD,
|
| 178 |
+
GameMode.COMBAT: PacingStyle.QUICK,
|
| 179 |
+
GameMode.SOCIAL: PacingStyle.STANDARD,
|
| 180 |
+
GameMode.NARRATIVE: PacingStyle.VERBOSE,
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
return mode_defaults.get(game_mode, PacingStyle.STANDARD)
|
| 184 |
+
|
| 185 |
+
def _is_new_location(self, game_state: GameState) -> bool:
|
| 186 |
+
"""
|
| 187 |
+
Check if player just entered a new location.
|
| 188 |
+
|
| 189 |
+
Args:
|
| 190 |
+
game_state: Current game state.
|
| 191 |
+
|
| 192 |
+
Returns:
|
| 193 |
+
True if location recently changed.
|
| 194 |
+
"""
|
| 195 |
+
# Check recent events for location change
|
| 196 |
+
for event in reversed(game_state.recent_events[-3:]):
|
| 197 |
+
if event.get("type") == "movement":
|
| 198 |
+
return True
|
| 199 |
+
return False
|
| 200 |
+
|
| 201 |
+
def get_pacing_instruction(self, pacing: PacingStyle) -> str:
|
| 202 |
+
"""
|
| 203 |
+
Get instruction text for a pacing style.
|
| 204 |
+
|
| 205 |
+
Args:
|
| 206 |
+
pacing: The pacing style.
|
| 207 |
+
|
| 208 |
+
Returns:
|
| 209 |
+
Instruction text to inject into system prompt.
|
| 210 |
+
"""
|
| 211 |
+
return PACING_INSTRUCTIONS.get(pacing, PACING_INSTRUCTIONS[PacingStyle.STANDARD])
|
| 212 |
+
|
| 213 |
+
def get_sentence_range(self, pacing: PacingStyle) -> tuple[int, int]:
|
| 214 |
+
"""
|
| 215 |
+
Get recommended sentence count range.
|
| 216 |
+
|
| 217 |
+
Args:
|
| 218 |
+
pacing: The pacing style.
|
| 219 |
+
|
| 220 |
+
Returns:
|
| 221 |
+
Tuple of (min_sentences, max_sentences).
|
| 222 |
+
"""
|
| 223 |
+
ranges: dict[PacingStyle, tuple[int, int]] = {
|
| 224 |
+
PacingStyle.VERBOSE: (3, 5),
|
| 225 |
+
PacingStyle.STANDARD: (2, 3),
|
| 226 |
+
PacingStyle.QUICK: (1, 2),
|
| 227 |
+
PacingStyle.DRAMATIC: (1, 3),
|
| 228 |
+
}
|
| 229 |
+
return ranges.get(pacing, (2, 3))
|
| 230 |
+
|
| 231 |
+
def should_include_ambient_detail(self, pacing: PacingStyle) -> bool:
|
| 232 |
+
"""
|
| 233 |
+
Check if ambient details should be included.
|
| 234 |
+
|
| 235 |
+
Args:
|
| 236 |
+
pacing: The pacing style.
|
| 237 |
+
|
| 238 |
+
Returns:
|
| 239 |
+
True if ambient details are appropriate.
|
| 240 |
+
"""
|
| 241 |
+
return pacing in (PacingStyle.VERBOSE, PacingStyle.STANDARD)
|
| 242 |
+
|
| 243 |
+
def should_prompt_for_action(self, pacing: PacingStyle) -> bool:
|
| 244 |
+
"""
|
| 245 |
+
Check if response should end with action prompt.
|
| 246 |
+
|
| 247 |
+
Args:
|
| 248 |
+
pacing: The pacing style.
|
| 249 |
+
|
| 250 |
+
Returns:
|
| 251 |
+
True if action prompt is appropriate.
|
| 252 |
+
"""
|
| 253 |
+
# Quick pacing often ends with clear action options
|
| 254 |
+
# Dramatic moments should let the moment breathe
|
| 255 |
+
return pacing in (PacingStyle.STANDARD, PacingStyle.QUICK)
|
| 256 |
+
|
| 257 |
+
def reset(self) -> None:
|
| 258 |
+
"""Reset pacing state for new game."""
|
| 259 |
+
self._last_pacing = PacingStyle.STANDARD
|
| 260 |
+
self._location_changes = 0
|
| 261 |
+
self._combat_turn_count = 0
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
# =============================================================================
|
| 265 |
+
# Factory Function
|
| 266 |
+
# =============================================================================
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
def create_pacing_controller() -> PacingController:
|
| 270 |
+
"""Create a new PacingController instance."""
|
| 271 |
+
return PacingController()
|
src/agents/rules_arbiter.py
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DungeonMaster AI - Rules Arbiter Agent
|
| 3 |
+
|
| 4 |
+
Specialized agent for rules lookup with LRU caching.
|
| 5 |
+
Uses only rules-related MCP tools for focused, accurate responses.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import logging
|
| 11 |
+
import time
|
| 12 |
+
from functools import lru_cache
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
from typing import TYPE_CHECKING
|
| 15 |
+
|
| 16 |
+
from llama_index.core.agent.workflow import FunctionAgent
|
| 17 |
+
from llama_index.core.tools import FunctionTool
|
| 18 |
+
|
| 19 |
+
from src.config.settings import get_settings
|
| 20 |
+
|
| 21 |
+
from .exceptions import RulesAgentError
|
| 22 |
+
from .models import RulesResponse, ToolCallInfo
|
| 23 |
+
|
| 24 |
+
if TYPE_CHECKING:
|
| 25 |
+
from llama_index.core.llms import LLM
|
| 26 |
+
|
| 27 |
+
logger = logging.getLogger(__name__)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
# =============================================================================
|
| 31 |
+
# Rules System Prompt
|
| 32 |
+
# =============================================================================
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def load_rules_prompt() -> str:
|
| 36 |
+
"""
|
| 37 |
+
Load the rules arbiter system prompt.
|
| 38 |
+
|
| 39 |
+
Returns:
|
| 40 |
+
System prompt string.
|
| 41 |
+
"""
|
| 42 |
+
settings = get_settings()
|
| 43 |
+
prompts_dir = settings.prompts_dir
|
| 44 |
+
|
| 45 |
+
rules_path = Path(prompts_dir) / "rules_system.txt"
|
| 46 |
+
if rules_path.exists():
|
| 47 |
+
return rules_path.read_text()
|
| 48 |
+
|
| 49 |
+
# Fallback prompt
|
| 50 |
+
return """You are a D&D 5e rules expert and arbiter.
|
| 51 |
+
|
| 52 |
+
Your responsibilities:
|
| 53 |
+
1. Look up rules accurately using the available tools
|
| 54 |
+
2. Cite sources when providing rule information
|
| 55 |
+
3. Explain rules clearly and concisely
|
| 56 |
+
4. Help adjudicate edge cases fairly
|
| 57 |
+
|
| 58 |
+
CRITICAL: Always use tools to verify rules - never guess or assume.
|
| 59 |
+
|
| 60 |
+
When answering:
|
| 61 |
+
- Use search_rules for general mechanics questions
|
| 62 |
+
- Use get_monster for creature stats
|
| 63 |
+
- Use get_spell for spell mechanics
|
| 64 |
+
- Use get_condition for status effect rules
|
| 65 |
+
|
| 66 |
+
Provide the rule, then a brief explanation of how it applies."""
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
# =============================================================================
|
| 70 |
+
# Cache Wrappers
|
| 71 |
+
# =============================================================================
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
class RulesCache:
|
| 75 |
+
"""
|
| 76 |
+
LRU cache wrapper for rules lookups.
|
| 77 |
+
|
| 78 |
+
Caches monster stats, spell info, and rule queries
|
| 79 |
+
to avoid repeated MCP calls.
|
| 80 |
+
"""
|
| 81 |
+
|
| 82 |
+
def __init__(self, maxsize: int = 100) -> None:
|
| 83 |
+
"""
|
| 84 |
+
Initialize the rules cache.
|
| 85 |
+
|
| 86 |
+
Args:
|
| 87 |
+
maxsize: Maximum cache size per category.
|
| 88 |
+
"""
|
| 89 |
+
self._maxsize = maxsize
|
| 90 |
+
self._monster_cache: dict[str, dict[str, object]] = {}
|
| 91 |
+
self._spell_cache: dict[str, dict[str, object]] = {}
|
| 92 |
+
self._condition_cache: dict[str, dict[str, object]] = {}
|
| 93 |
+
self._query_cache: dict[str, str] = {}
|
| 94 |
+
|
| 95 |
+
# Cache statistics
|
| 96 |
+
self._hits = 0
|
| 97 |
+
self._misses = 0
|
| 98 |
+
|
| 99 |
+
def get_monster(self, name: str) -> dict[str, object] | None:
|
| 100 |
+
"""Get cached monster stats."""
|
| 101 |
+
key = name.lower()
|
| 102 |
+
if key in self._monster_cache:
|
| 103 |
+
self._hits += 1
|
| 104 |
+
return self._monster_cache[key]
|
| 105 |
+
self._misses += 1
|
| 106 |
+
return None
|
| 107 |
+
|
| 108 |
+
def set_monster(self, name: str, data: dict[str, object]) -> None:
|
| 109 |
+
"""Cache monster stats."""
|
| 110 |
+
key = name.lower()
|
| 111 |
+
if len(self._monster_cache) >= self._maxsize:
|
| 112 |
+
# Remove oldest entry (simple FIFO, not true LRU)
|
| 113 |
+
oldest = next(iter(self._monster_cache))
|
| 114 |
+
del self._monster_cache[oldest]
|
| 115 |
+
self._monster_cache[key] = data
|
| 116 |
+
|
| 117 |
+
def get_spell(self, name: str) -> dict[str, object] | None:
|
| 118 |
+
"""Get cached spell info."""
|
| 119 |
+
key = name.lower()
|
| 120 |
+
if key in self._spell_cache:
|
| 121 |
+
self._hits += 1
|
| 122 |
+
return self._spell_cache[key]
|
| 123 |
+
self._misses += 1
|
| 124 |
+
return None
|
| 125 |
+
|
| 126 |
+
def set_spell(self, name: str, data: dict[str, object]) -> None:
|
| 127 |
+
"""Cache spell info."""
|
| 128 |
+
key = name.lower()
|
| 129 |
+
if len(self._spell_cache) >= self._maxsize:
|
| 130 |
+
oldest = next(iter(self._spell_cache))
|
| 131 |
+
del self._spell_cache[oldest]
|
| 132 |
+
self._spell_cache[key] = data
|
| 133 |
+
|
| 134 |
+
def get_condition(self, name: str) -> dict[str, object] | None:
|
| 135 |
+
"""Get cached condition info."""
|
| 136 |
+
key = name.lower()
|
| 137 |
+
if key in self._condition_cache:
|
| 138 |
+
self._hits += 1
|
| 139 |
+
return self._condition_cache[key]
|
| 140 |
+
self._misses += 1
|
| 141 |
+
return None
|
| 142 |
+
|
| 143 |
+
def set_condition(self, name: str, data: dict[str, object]) -> None:
|
| 144 |
+
"""Cache condition info."""
|
| 145 |
+
key = name.lower()
|
| 146 |
+
if len(self._condition_cache) >= self._maxsize:
|
| 147 |
+
oldest = next(iter(self._condition_cache))
|
| 148 |
+
del self._condition_cache[oldest]
|
| 149 |
+
self._condition_cache[key] = data
|
| 150 |
+
|
| 151 |
+
def get_query(self, query: str) -> str | None:
|
| 152 |
+
"""Get cached query result."""
|
| 153 |
+
key = query.lower().strip()
|
| 154 |
+
if key in self._query_cache:
|
| 155 |
+
self._hits += 1
|
| 156 |
+
return self._query_cache[key]
|
| 157 |
+
self._misses += 1
|
| 158 |
+
return None
|
| 159 |
+
|
| 160 |
+
def set_query(self, query: str, result: str) -> None:
|
| 161 |
+
"""Cache query result."""
|
| 162 |
+
key = query.lower().strip()
|
| 163 |
+
if len(self._query_cache) >= self._maxsize:
|
| 164 |
+
oldest = next(iter(self._query_cache))
|
| 165 |
+
del self._query_cache[oldest]
|
| 166 |
+
self._query_cache[key] = result
|
| 167 |
+
|
| 168 |
+
@property
|
| 169 |
+
def hit_rate(self) -> float:
|
| 170 |
+
"""Calculate cache hit rate."""
|
| 171 |
+
total = self._hits + self._misses
|
| 172 |
+
return self._hits / total if total > 0 else 0.0
|
| 173 |
+
|
| 174 |
+
def clear(self) -> None:
|
| 175 |
+
"""Clear all caches."""
|
| 176 |
+
self._monster_cache.clear()
|
| 177 |
+
self._spell_cache.clear()
|
| 178 |
+
self._condition_cache.clear()
|
| 179 |
+
self._query_cache.clear()
|
| 180 |
+
self._hits = 0
|
| 181 |
+
self._misses = 0
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
# =============================================================================
|
| 185 |
+
# RulesArbiterAgent
|
| 186 |
+
# =============================================================================
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
class RulesArbiterAgent:
|
| 190 |
+
"""
|
| 191 |
+
Specialized rules lookup agent with LRU caching.
|
| 192 |
+
|
| 193 |
+
Only uses rules-related MCP tools:
|
| 194 |
+
- search_rules: Search rules by topic
|
| 195 |
+
- get_monster: Get monster stat block
|
| 196 |
+
- get_spell: Get spell description
|
| 197 |
+
- get_class_info: Get class features
|
| 198 |
+
- get_race_info: Get race abilities
|
| 199 |
+
- get_condition: Get condition effects
|
| 200 |
+
"""
|
| 201 |
+
|
| 202 |
+
# Rules tool names to filter
|
| 203 |
+
RULES_TOOLS = frozenset([
|
| 204 |
+
"search_rules",
|
| 205 |
+
"get_monster",
|
| 206 |
+
"search_monsters",
|
| 207 |
+
"get_spell",
|
| 208 |
+
"search_spells",
|
| 209 |
+
"get_class_info",
|
| 210 |
+
"get_race_info",
|
| 211 |
+
"get_item",
|
| 212 |
+
"get_condition",
|
| 213 |
+
# MCP prefixed versions
|
| 214 |
+
"mcp_search_rules",
|
| 215 |
+
"mcp_get_monster",
|
| 216 |
+
"mcp_search_monsters",
|
| 217 |
+
"mcp_get_spell",
|
| 218 |
+
"mcp_search_spells",
|
| 219 |
+
"mcp_get_class_info",
|
| 220 |
+
"mcp_get_race_info",
|
| 221 |
+
"mcp_get_item",
|
| 222 |
+
"mcp_get_condition",
|
| 223 |
+
])
|
| 224 |
+
|
| 225 |
+
def __init__(
|
| 226 |
+
self,
|
| 227 |
+
llm: LLM,
|
| 228 |
+
tools: list[FunctionTool],
|
| 229 |
+
cache_size: int = 100,
|
| 230 |
+
) -> None:
|
| 231 |
+
"""
|
| 232 |
+
Initialize the Rules Arbiter agent.
|
| 233 |
+
|
| 234 |
+
Args:
|
| 235 |
+
llm: LlamaIndex LLM instance.
|
| 236 |
+
tools: List of ALL MCP tools (will be filtered to rules only).
|
| 237 |
+
cache_size: Maximum cache size per category.
|
| 238 |
+
"""
|
| 239 |
+
self._llm = llm
|
| 240 |
+
|
| 241 |
+
# Filter to rules tools only
|
| 242 |
+
self._tools = [
|
| 243 |
+
tool for tool in tools
|
| 244 |
+
if tool.metadata.name in self.RULES_TOOLS
|
| 245 |
+
]
|
| 246 |
+
|
| 247 |
+
logger.info(
|
| 248 |
+
f"RulesArbiterAgent initialized with {len(self._tools)} rules tools"
|
| 249 |
+
)
|
| 250 |
+
|
| 251 |
+
# Initialize cache
|
| 252 |
+
self._cache = RulesCache(maxsize=cache_size)
|
| 253 |
+
|
| 254 |
+
# Load system prompt
|
| 255 |
+
self._system_prompt = load_rules_prompt()
|
| 256 |
+
|
| 257 |
+
# Create focused FunctionAgent
|
| 258 |
+
self._agent = FunctionAgent(
|
| 259 |
+
llm=llm,
|
| 260 |
+
tools=self._tools,
|
| 261 |
+
system_prompt=self._system_prompt,
|
| 262 |
+
)
|
| 263 |
+
|
| 264 |
+
@property
|
| 265 |
+
def cache_hit_rate(self) -> float:
|
| 266 |
+
"""Get cache hit rate."""
|
| 267 |
+
return self._cache.hit_rate
|
| 268 |
+
|
| 269 |
+
async def lookup(self, query: str) -> RulesResponse:
|
| 270 |
+
"""
|
| 271 |
+
Look up rules for a query.
|
| 272 |
+
|
| 273 |
+
Args:
|
| 274 |
+
query: The rules question or topic.
|
| 275 |
+
|
| 276 |
+
Returns:
|
| 277 |
+
RulesResponse with answer and sources.
|
| 278 |
+
"""
|
| 279 |
+
start_time = time.time()
|
| 280 |
+
|
| 281 |
+
# Check cache first
|
| 282 |
+
cached_result = self._cache.get_query(query)
|
| 283 |
+
if cached_result:
|
| 284 |
+
logger.debug(f"Cache hit for query: {query[:50]}...")
|
| 285 |
+
return RulesResponse(
|
| 286 |
+
answer=cached_result,
|
| 287 |
+
sources=["cache"],
|
| 288 |
+
confidence=1.0,
|
| 289 |
+
from_cache=True,
|
| 290 |
+
)
|
| 291 |
+
|
| 292 |
+
try:
|
| 293 |
+
# Run the agent
|
| 294 |
+
handler = self._agent.run(user_msg=query)
|
| 295 |
+
|
| 296 |
+
response_text = ""
|
| 297 |
+
tool_calls: list[ToolCallInfo] = []
|
| 298 |
+
sources: list[str] = []
|
| 299 |
+
|
| 300 |
+
async for event in handler.stream_events():
|
| 301 |
+
event_type = type(event).__name__
|
| 302 |
+
|
| 303 |
+
if event_type == "AgentOutput":
|
| 304 |
+
response_text = str(event.response) if hasattr(event, "response") else ""
|
| 305 |
+
|
| 306 |
+
elif event_type == "ToolCall":
|
| 307 |
+
if hasattr(event, "tool_name"):
|
| 308 |
+
tool_info = ToolCallInfo(
|
| 309 |
+
tool_name=event.tool_name,
|
| 310 |
+
arguments=getattr(event, "arguments", {}),
|
| 311 |
+
)
|
| 312 |
+
tool_calls.append(tool_info)
|
| 313 |
+
sources.append(event.tool_name)
|
| 314 |
+
|
| 315 |
+
elif event_type == "ToolCallResult":
|
| 316 |
+
if hasattr(event, "result") and tool_calls:
|
| 317 |
+
tool_calls[-1].result = event.result
|
| 318 |
+
tool_calls[-1].success = True
|
| 319 |
+
|
| 320 |
+
# Cache specific lookups
|
| 321 |
+
self._cache_tool_result(tool_calls[-1])
|
| 322 |
+
|
| 323 |
+
# Cache the full query result
|
| 324 |
+
if response_text:
|
| 325 |
+
self._cache.set_query(query, response_text)
|
| 326 |
+
|
| 327 |
+
return RulesResponse(
|
| 328 |
+
answer=response_text,
|
| 329 |
+
sources=list(set(sources)),
|
| 330 |
+
confidence=1.0 if tool_calls else 0.5,
|
| 331 |
+
tool_calls=tool_calls,
|
| 332 |
+
from_cache=False,
|
| 333 |
+
)
|
| 334 |
+
|
| 335 |
+
except Exception as e:
|
| 336 |
+
logger.error(f"Rules lookup failed: {e}")
|
| 337 |
+
raise RulesAgentError(str(e)) from e
|
| 338 |
+
|
| 339 |
+
def _cache_tool_result(self, tool_call: ToolCallInfo) -> None:
|
| 340 |
+
"""Cache individual tool results."""
|
| 341 |
+
if not tool_call.success or not tool_call.result:
|
| 342 |
+
return
|
| 343 |
+
|
| 344 |
+
result = tool_call.result
|
| 345 |
+
if not isinstance(result, dict):
|
| 346 |
+
return
|
| 347 |
+
|
| 348 |
+
tool_name = tool_call.tool_name.replace("mcp_", "")
|
| 349 |
+
|
| 350 |
+
if tool_name == "get_monster":
|
| 351 |
+
name = tool_call.arguments.get("name", "")
|
| 352 |
+
if name:
|
| 353 |
+
self._cache.set_monster(str(name), result)
|
| 354 |
+
|
| 355 |
+
elif tool_name == "get_spell":
|
| 356 |
+
name = tool_call.arguments.get("name", "")
|
| 357 |
+
if name:
|
| 358 |
+
self._cache.set_spell(str(name), result)
|
| 359 |
+
|
| 360 |
+
elif tool_name == "get_condition":
|
| 361 |
+
name = tool_call.arguments.get("name", "")
|
| 362 |
+
if name:
|
| 363 |
+
self._cache.set_condition(str(name), result)
|
| 364 |
+
|
| 365 |
+
async def get_monster_stats(self, monster_name: str) -> dict[str, object] | None:
|
| 366 |
+
"""
|
| 367 |
+
Get monster stats with caching.
|
| 368 |
+
|
| 369 |
+
Args:
|
| 370 |
+
monster_name: Name of the monster.
|
| 371 |
+
|
| 372 |
+
Returns:
|
| 373 |
+
Monster stat block or None if not found.
|
| 374 |
+
"""
|
| 375 |
+
# Check cache
|
| 376 |
+
cached = self._cache.get_monster(monster_name)
|
| 377 |
+
if cached:
|
| 378 |
+
return cached
|
| 379 |
+
|
| 380 |
+
# Look up via agent
|
| 381 |
+
response = await self.lookup(f"Get full stat block for {monster_name}")
|
| 382 |
+
|
| 383 |
+
# Try to extract monster data from response
|
| 384 |
+
for tool_call in response.tool_calls:
|
| 385 |
+
if "monster" in tool_call.tool_name.lower():
|
| 386 |
+
if tool_call.result and isinstance(tool_call.result, dict):
|
| 387 |
+
return tool_call.result
|
| 388 |
+
|
| 389 |
+
return None
|
| 390 |
+
|
| 391 |
+
async def get_spell_info(self, spell_name: str) -> dict[str, object] | None:
|
| 392 |
+
"""
|
| 393 |
+
Get spell info with caching.
|
| 394 |
+
|
| 395 |
+
Args:
|
| 396 |
+
spell_name: Name of the spell.
|
| 397 |
+
|
| 398 |
+
Returns:
|
| 399 |
+
Spell info or None if not found.
|
| 400 |
+
"""
|
| 401 |
+
# Check cache
|
| 402 |
+
cached = self._cache.get_spell(spell_name)
|
| 403 |
+
if cached:
|
| 404 |
+
return cached
|
| 405 |
+
|
| 406 |
+
# Look up via agent
|
| 407 |
+
response = await self.lookup(f"Get full description for the spell {spell_name}")
|
| 408 |
+
|
| 409 |
+
# Try to extract spell data from response
|
| 410 |
+
for tool_call in response.tool_calls:
|
| 411 |
+
if "spell" in tool_call.tool_name.lower():
|
| 412 |
+
if tool_call.result and isinstance(tool_call.result, dict):
|
| 413 |
+
return tool_call.result
|
| 414 |
+
|
| 415 |
+
return None
|
| 416 |
+
|
| 417 |
+
async def get_condition_info(self, condition_name: str) -> dict[str, object] | None:
|
| 418 |
+
"""
|
| 419 |
+
Get condition info with caching.
|
| 420 |
+
|
| 421 |
+
Args:
|
| 422 |
+
condition_name: Name of the condition.
|
| 423 |
+
|
| 424 |
+
Returns:
|
| 425 |
+
Condition info or None if not found.
|
| 426 |
+
"""
|
| 427 |
+
# Check cache
|
| 428 |
+
cached = self._cache.get_condition(condition_name)
|
| 429 |
+
if cached:
|
| 430 |
+
return cached
|
| 431 |
+
|
| 432 |
+
# Look up via agent
|
| 433 |
+
response = await self.lookup(f"What are the effects of the {condition_name} condition?")
|
| 434 |
+
|
| 435 |
+
# Try to extract condition data from response
|
| 436 |
+
for tool_call in response.tool_calls:
|
| 437 |
+
if "condition" in tool_call.tool_name.lower():
|
| 438 |
+
if tool_call.result and isinstance(tool_call.result, dict):
|
| 439 |
+
return tool_call.result
|
| 440 |
+
|
| 441 |
+
return None
|
| 442 |
+
|
| 443 |
+
def clear_cache(self) -> None:
|
| 444 |
+
"""Clear all caches."""
|
| 445 |
+
self._cache.clear()
|
| 446 |
+
logger.info("Rules cache cleared")
|
src/agents/special_moments.py
ADDED
|
@@ -0,0 +1,474 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DungeonMaster AI - Special Moments Handler
|
| 3 |
+
|
| 4 |
+
Handles dramatic game moments with enhanced narration and effects.
|
| 5 |
+
Critical hits, death saves, killing blows, and more.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import random
|
| 11 |
+
from typing import TYPE_CHECKING
|
| 12 |
+
|
| 13 |
+
from src.voice.models import VoiceType
|
| 14 |
+
|
| 15 |
+
from .models import SpecialMoment, SpecialMomentType
|
| 16 |
+
|
| 17 |
+
if TYPE_CHECKING:
|
| 18 |
+
from src.mcp_integration.models import DiceRollResult
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
# =============================================================================
|
| 22 |
+
# Narration Templates
|
| 23 |
+
# =============================================================================
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
CRITICAL_HIT_TEMPLATES: list[str] = [
|
| 27 |
+
"CRITICAL HIT! Your {weapon} finds the perfect opening, striking with devastating precision!",
|
| 28 |
+
"A critical strike! Time seems to slow as your blow lands with incredible force!",
|
| 29 |
+
"CRITICAL! The strike of legends! Your attack connects with brutal efficiency!",
|
| 30 |
+
"Perfection in motion! A critical hit that will be sung about for ages!",
|
| 31 |
+
"The gods smile upon you! A devastating critical hit!",
|
| 32 |
+
]
|
| 33 |
+
|
| 34 |
+
CRITICAL_MISS_TEMPLATES: list[str] = [
|
| 35 |
+
"A fumble! Your attack goes wide, leaving you momentarily off-balance.",
|
| 36 |
+
"Critical miss! In your eagerness, your strike goes terribly wrong.",
|
| 37 |
+
"Disaster! Your weapon slips at the worst possible moment.",
|
| 38 |
+
"A fumble of epic proportions! This will not be your finest moment.",
|
| 39 |
+
"Critical failure! The attack goes awry in embarrassing fashion.",
|
| 40 |
+
]
|
| 41 |
+
|
| 42 |
+
DEATH_SAVE_SUCCESS_TEMPLATES: list[str] = [
|
| 43 |
+
"You cling to consciousness, fighting the darkness. Not today. You will NOT fall.",
|
| 44 |
+
"Your heartbeat steadies. The void retreats, for now.",
|
| 45 |
+
"With iron will, you resist death's embrace. The fight continues.",
|
| 46 |
+
"Light pierces the darkness. You hold on.",
|
| 47 |
+
"Your spirit refuses to yield. One success closer to survival.",
|
| 48 |
+
]
|
| 49 |
+
|
| 50 |
+
DEATH_SAVE_FAILURE_TEMPLATES: list[str] = [
|
| 51 |
+
"The void beckons... darkness creeps closer. But you resist. For now.",
|
| 52 |
+
"Your grip on life weakens. The shadows grow longer.",
|
| 53 |
+
"Pain. Darkness. The thread of life grows thin.",
|
| 54 |
+
"Death's cold hand reaches for you. One step closer to the abyss.",
|
| 55 |
+
"Consciousness fades at the edges. Time grows short.",
|
| 56 |
+
]
|
| 57 |
+
|
| 58 |
+
DEATH_SAVE_NAT_20_TEMPLATES: list[str] = [
|
| 59 |
+
"Your eyes SNAP open! Against all odds, you RISE, defying death itself!",
|
| 60 |
+
"IMPOSSIBLE! You surge back to consciousness with renewed vigor!",
|
| 61 |
+
"The light of life BLAZES within you! You stand, death thoroughly denied!",
|
| 62 |
+
"By sheer force of will, you RETURN! One hit point and ready to fight!",
|
| 63 |
+
"MIRACULOUS! You gasp awake, snatched from death's very door!",
|
| 64 |
+
]
|
| 65 |
+
|
| 66 |
+
DEATH_SAVE_NAT_1_TEMPLATES: list[str] = [
|
| 67 |
+
"Darkness surges. Two failures. Death's grip tightens relentlessly...",
|
| 68 |
+
"The void PULLS. Two steps toward oblivion in a single heartbeat.",
|
| 69 |
+
"Critical failure. Death looms ever closer. Hope dims.",
|
| 70 |
+
"The worst possible outcome. Life slips away faster.",
|
| 71 |
+
"A gasp. A shudder. Two failures. The end draws near.",
|
| 72 |
+
]
|
| 73 |
+
|
| 74 |
+
KILLING_BLOW_TEMPLATES: list[str] = [
|
| 75 |
+
"With a final, devastating strike, your {weapon} ends the {enemy}! Victory!",
|
| 76 |
+
"The killing blow lands! The {enemy} crumples, defeated at last!",
|
| 77 |
+
"FINISHED! Your {weapon} claims another foe as the {enemy} falls!",
|
| 78 |
+
"The {enemy} meets its end! Your strike is final and absolute!",
|
| 79 |
+
"With righteous fury, you deliver the killing blow! The {enemy} is no more!",
|
| 80 |
+
]
|
| 81 |
+
|
| 82 |
+
COMBAT_START_TEMPLATES: list[str] = [
|
| 83 |
+
"Combat begins! Steel clashes and magic crackles as battle is joined!",
|
| 84 |
+
"Initiative! The dance of death begins!",
|
| 85 |
+
"Roll for initiative! Combat erupts!",
|
| 86 |
+
"Weapons are drawn! Let battle commence!",
|
| 87 |
+
"The fight is on! May fortune favor the bold!",
|
| 88 |
+
]
|
| 89 |
+
|
| 90 |
+
COMBAT_VICTORY_TEMPLATES: list[str] = [
|
| 91 |
+
"Victory! The last enemy falls as the dust settles.",
|
| 92 |
+
"Combat ends! You stand triumphant over your fallen foes!",
|
| 93 |
+
"The battle is won! Take a moment to catch your breath.",
|
| 94 |
+
"Silence falls. The enemies are defeated. You are victorious.",
|
| 95 |
+
"The fight is over. You emerge victorious!",
|
| 96 |
+
]
|
| 97 |
+
|
| 98 |
+
LEVEL_UP_TEMPLATES: list[str] = [
|
| 99 |
+
"LEVEL UP! You feel power surge through you as experience crystallizes into new abilities!",
|
| 100 |
+
"Your training pays off! You've achieved a new level of mastery!",
|
| 101 |
+
"Growth! You feel yourself becoming stronger, wiser, more capable!",
|
| 102 |
+
"The journey continues upward! Level gained!",
|
| 103 |
+
"Experience transforms into power! You have leveled up!",
|
| 104 |
+
]
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
# =============================================================================
|
| 108 |
+
# UI Effects
|
| 109 |
+
# =============================================================================
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
UI_EFFECTS: dict[str, dict[str, object]] = {
|
| 113 |
+
"screen_shake": {
|
| 114 |
+
"type": "animation",
|
| 115 |
+
"name": "shake",
|
| 116 |
+
"duration_ms": 500,
|
| 117 |
+
"intensity": "medium",
|
| 118 |
+
},
|
| 119 |
+
"golden_glow": {
|
| 120 |
+
"type": "overlay",
|
| 121 |
+
"color": "#FFD700",
|
| 122 |
+
"duration_ms": 1000,
|
| 123 |
+
"animation": "pulse",
|
| 124 |
+
},
|
| 125 |
+
"red_flash": {
|
| 126 |
+
"type": "overlay",
|
| 127 |
+
"color": "#8B0000",
|
| 128 |
+
"duration_ms": 500,
|
| 129 |
+
"animation": "flash",
|
| 130 |
+
},
|
| 131 |
+
"darkness_pulse": {
|
| 132 |
+
"type": "overlay",
|
| 133 |
+
"color": "#000000",
|
| 134 |
+
"duration_ms": 800,
|
| 135 |
+
"animation": "pulse",
|
| 136 |
+
"opacity": 0.7,
|
| 137 |
+
},
|
| 138 |
+
"golden_resurrection": {
|
| 139 |
+
"type": "overlay",
|
| 140 |
+
"color": "#FFD700",
|
| 141 |
+
"duration_ms": 1500,
|
| 142 |
+
"animation": "radiate",
|
| 143 |
+
},
|
| 144 |
+
"victory_sparkle": {
|
| 145 |
+
"type": "particles",
|
| 146 |
+
"color": "#FFD700",
|
| 147 |
+
"count": 50,
|
| 148 |
+
"duration_ms": 2000,
|
| 149 |
+
},
|
| 150 |
+
"level_up_glow": {
|
| 151 |
+
"type": "overlay",
|
| 152 |
+
"color": "#00FF00",
|
| 153 |
+
"duration_ms": 2000,
|
| 154 |
+
"animation": "radiate",
|
| 155 |
+
},
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
# =============================================================================
|
| 160 |
+
# SpecialMomentHandler
|
| 161 |
+
# =============================================================================
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
class SpecialMomentHandler:
|
| 165 |
+
"""
|
| 166 |
+
Handles dramatic game moments with enhanced narration.
|
| 167 |
+
|
| 168 |
+
Detects and creates appropriate responses for:
|
| 169 |
+
- Critical hits (natural 20 on attack)
|
| 170 |
+
- Critical misses (natural 1 on attack)
|
| 171 |
+
- Death save successes and failures
|
| 172 |
+
- Natural 20/1 on death saves
|
| 173 |
+
- Killing blows
|
| 174 |
+
- Combat start/victory
|
| 175 |
+
- Level ups
|
| 176 |
+
"""
|
| 177 |
+
|
| 178 |
+
def __init__(self) -> None:
|
| 179 |
+
"""Initialize the special moment handler."""
|
| 180 |
+
pass
|
| 181 |
+
|
| 182 |
+
def handle_critical_hit(
|
| 183 |
+
self,
|
| 184 |
+
weapon: str = "weapon",
|
| 185 |
+
damage: int | None = None,
|
| 186 |
+
target: str = "enemy",
|
| 187 |
+
) -> SpecialMoment:
|
| 188 |
+
"""
|
| 189 |
+
Create special moment for critical hit.
|
| 190 |
+
|
| 191 |
+
Args:
|
| 192 |
+
weapon: Weapon used for the attack.
|
| 193 |
+
damage: Damage dealt (if known).
|
| 194 |
+
target: Target of the attack.
|
| 195 |
+
|
| 196 |
+
Returns:
|
| 197 |
+
SpecialMoment with enhanced narration.
|
| 198 |
+
"""
|
| 199 |
+
template = random.choice(CRITICAL_HIT_TEMPLATES)
|
| 200 |
+
narration = template.format(weapon=weapon)
|
| 201 |
+
|
| 202 |
+
return SpecialMoment(
|
| 203 |
+
moment_type=SpecialMomentType.CRITICAL_HIT,
|
| 204 |
+
enhanced_narration=narration,
|
| 205 |
+
voice_type=VoiceType.DM,
|
| 206 |
+
ui_effects=["screen_shake", "golden_glow"],
|
| 207 |
+
pause_before_ms=500,
|
| 208 |
+
sound_effect="critical_hit",
|
| 209 |
+
context={
|
| 210 |
+
"weapon": weapon,
|
| 211 |
+
"damage": damage,
|
| 212 |
+
"target": target,
|
| 213 |
+
},
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
def handle_critical_miss(
|
| 217 |
+
self,
|
| 218 |
+
weapon: str = "weapon",
|
| 219 |
+
) -> SpecialMoment:
|
| 220 |
+
"""
|
| 221 |
+
Create special moment for critical miss.
|
| 222 |
+
|
| 223 |
+
Args:
|
| 224 |
+
weapon: Weapon used for the attack.
|
| 225 |
+
|
| 226 |
+
Returns:
|
| 227 |
+
SpecialMoment with enhanced narration.
|
| 228 |
+
"""
|
| 229 |
+
template = random.choice(CRITICAL_MISS_TEMPLATES)
|
| 230 |
+
|
| 231 |
+
return SpecialMoment(
|
| 232 |
+
moment_type=SpecialMomentType.CRITICAL_MISS,
|
| 233 |
+
enhanced_narration=template,
|
| 234 |
+
voice_type=VoiceType.DM,
|
| 235 |
+
ui_effects=["red_flash"],
|
| 236 |
+
pause_before_ms=300,
|
| 237 |
+
sound_effect="fumble",
|
| 238 |
+
context={"weapon": weapon},
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
def handle_death_save(
|
| 242 |
+
self,
|
| 243 |
+
roll: int,
|
| 244 |
+
successes: int = 0,
|
| 245 |
+
failures: int = 0,
|
| 246 |
+
) -> SpecialMoment:
|
| 247 |
+
"""
|
| 248 |
+
Create special moment for death saving throw.
|
| 249 |
+
|
| 250 |
+
Args:
|
| 251 |
+
roll: The die roll result.
|
| 252 |
+
successes: Current number of successes.
|
| 253 |
+
failures: Current number of failures.
|
| 254 |
+
|
| 255 |
+
Returns:
|
| 256 |
+
SpecialMoment with appropriate narration.
|
| 257 |
+
"""
|
| 258 |
+
# Natural 20 - instant recovery
|
| 259 |
+
if roll == 20:
|
| 260 |
+
return SpecialMoment(
|
| 261 |
+
moment_type=SpecialMomentType.DEATH_SAVE_NAT_20,
|
| 262 |
+
enhanced_narration=random.choice(DEATH_SAVE_NAT_20_TEMPLATES),
|
| 263 |
+
voice_type=VoiceType.DM,
|
| 264 |
+
ui_effects=["golden_resurrection", "screen_shake"],
|
| 265 |
+
pause_before_ms=800,
|
| 266 |
+
sound_effect="resurrection",
|
| 267 |
+
context={
|
| 268 |
+
"roll": roll,
|
| 269 |
+
"successes": 3, # Instant recovery
|
| 270 |
+
"failures": failures,
|
| 271 |
+
},
|
| 272 |
+
)
|
| 273 |
+
|
| 274 |
+
# Natural 1 - two failures
|
| 275 |
+
if roll == 1:
|
| 276 |
+
return SpecialMoment(
|
| 277 |
+
moment_type=SpecialMomentType.DEATH_SAVE_NAT_1,
|
| 278 |
+
enhanced_narration=random.choice(DEATH_SAVE_NAT_1_TEMPLATES),
|
| 279 |
+
voice_type=VoiceType.DM,
|
| 280 |
+
ui_effects=["darkness_pulse"],
|
| 281 |
+
pause_before_ms=500,
|
| 282 |
+
sound_effect="death_approaching",
|
| 283 |
+
context={
|
| 284 |
+
"roll": roll,
|
| 285 |
+
"successes": successes,
|
| 286 |
+
"failures": failures + 2,
|
| 287 |
+
},
|
| 288 |
+
)
|
| 289 |
+
|
| 290 |
+
# Success (10+)
|
| 291 |
+
if roll >= 10:
|
| 292 |
+
return SpecialMoment(
|
| 293 |
+
moment_type=SpecialMomentType.DEATH_SAVE_SUCCESS,
|
| 294 |
+
enhanced_narration=random.choice(DEATH_SAVE_SUCCESS_TEMPLATES),
|
| 295 |
+
voice_type=VoiceType.DM,
|
| 296 |
+
ui_effects=[],
|
| 297 |
+
pause_before_ms=300,
|
| 298 |
+
context={
|
| 299 |
+
"roll": roll,
|
| 300 |
+
"successes": successes + 1,
|
| 301 |
+
"failures": failures,
|
| 302 |
+
},
|
| 303 |
+
)
|
| 304 |
+
|
| 305 |
+
# Failure (<10)
|
| 306 |
+
return SpecialMoment(
|
| 307 |
+
moment_type=SpecialMomentType.DEATH_SAVE_FAILURE,
|
| 308 |
+
enhanced_narration=random.choice(DEATH_SAVE_FAILURE_TEMPLATES),
|
| 309 |
+
voice_type=VoiceType.DM,
|
| 310 |
+
ui_effects=["darkness_pulse"],
|
| 311 |
+
pause_before_ms=300,
|
| 312 |
+
context={
|
| 313 |
+
"roll": roll,
|
| 314 |
+
"successes": successes,
|
| 315 |
+
"failures": failures + 1,
|
| 316 |
+
},
|
| 317 |
+
)
|
| 318 |
+
|
| 319 |
+
def handle_killing_blow(
|
| 320 |
+
self,
|
| 321 |
+
weapon: str = "weapon",
|
| 322 |
+
enemy: str = "enemy",
|
| 323 |
+
) -> SpecialMoment:
|
| 324 |
+
"""
|
| 325 |
+
Create special moment for killing blow.
|
| 326 |
+
|
| 327 |
+
Args:
|
| 328 |
+
weapon: Weapon used.
|
| 329 |
+
enemy: Enemy defeated.
|
| 330 |
+
|
| 331 |
+
Returns:
|
| 332 |
+
SpecialMoment with enhanced narration.
|
| 333 |
+
"""
|
| 334 |
+
template = random.choice(KILLING_BLOW_TEMPLATES)
|
| 335 |
+
narration = template.format(weapon=weapon, enemy=enemy)
|
| 336 |
+
|
| 337 |
+
return SpecialMoment(
|
| 338 |
+
moment_type=SpecialMomentType.KILLING_BLOW,
|
| 339 |
+
enhanced_narration=narration,
|
| 340 |
+
voice_type=VoiceType.DM,
|
| 341 |
+
ui_effects=["screen_shake"],
|
| 342 |
+
pause_before_ms=400,
|
| 343 |
+
sound_effect="killing_blow",
|
| 344 |
+
context={
|
| 345 |
+
"weapon": weapon,
|
| 346 |
+
"enemy": enemy,
|
| 347 |
+
},
|
| 348 |
+
)
|
| 349 |
+
|
| 350 |
+
def handle_player_death(
|
| 351 |
+
self,
|
| 352 |
+
character_name: str,
|
| 353 |
+
) -> SpecialMoment:
|
| 354 |
+
"""
|
| 355 |
+
Create special moment for player death.
|
| 356 |
+
|
| 357 |
+
Args:
|
| 358 |
+
character_name: Name of the fallen character.
|
| 359 |
+
|
| 360 |
+
Returns:
|
| 361 |
+
SpecialMoment with solemn narration.
|
| 362 |
+
"""
|
| 363 |
+
narration = (
|
| 364 |
+
f"Silence falls as {character_name}'s spirit departs. "
|
| 365 |
+
"A hero has fallen. Their sacrifice will not be forgotten."
|
| 366 |
+
)
|
| 367 |
+
|
| 368 |
+
return SpecialMoment(
|
| 369 |
+
moment_type=SpecialMomentType.PLAYER_DEATH,
|
| 370 |
+
enhanced_narration=narration,
|
| 371 |
+
voice_type=VoiceType.DM,
|
| 372 |
+
ui_effects=["darkness_pulse"],
|
| 373 |
+
pause_before_ms=1000,
|
| 374 |
+
sound_effect="death",
|
| 375 |
+
context={"character_name": character_name},
|
| 376 |
+
)
|
| 377 |
+
|
| 378 |
+
def handle_combat_start(self) -> SpecialMoment:
|
| 379 |
+
"""Create special moment for combat beginning."""
|
| 380 |
+
return SpecialMoment(
|
| 381 |
+
moment_type=SpecialMomentType.COMBAT_START,
|
| 382 |
+
enhanced_narration=random.choice(COMBAT_START_TEMPLATES),
|
| 383 |
+
voice_type=VoiceType.DM,
|
| 384 |
+
ui_effects=["screen_shake"],
|
| 385 |
+
pause_before_ms=300,
|
| 386 |
+
sound_effect="combat_start",
|
| 387 |
+
context={},
|
| 388 |
+
)
|
| 389 |
+
|
| 390 |
+
def handle_combat_victory(self) -> SpecialMoment:
|
| 391 |
+
"""Create special moment for combat victory."""
|
| 392 |
+
return SpecialMoment(
|
| 393 |
+
moment_type=SpecialMomentType.COMBAT_VICTORY,
|
| 394 |
+
enhanced_narration=random.choice(COMBAT_VICTORY_TEMPLATES),
|
| 395 |
+
voice_type=VoiceType.DM,
|
| 396 |
+
ui_effects=["victory_sparkle"],
|
| 397 |
+
pause_before_ms=500,
|
| 398 |
+
sound_effect="victory",
|
| 399 |
+
context={},
|
| 400 |
+
)
|
| 401 |
+
|
| 402 |
+
def handle_level_up(
|
| 403 |
+
self,
|
| 404 |
+
character_name: str,
|
| 405 |
+
new_level: int,
|
| 406 |
+
) -> SpecialMoment:
|
| 407 |
+
"""
|
| 408 |
+
Create special moment for level up.
|
| 409 |
+
|
| 410 |
+
Args:
|
| 411 |
+
character_name: Name of the character.
|
| 412 |
+
new_level: The new level achieved.
|
| 413 |
+
|
| 414 |
+
Returns:
|
| 415 |
+
SpecialMoment with celebratory narration.
|
| 416 |
+
"""
|
| 417 |
+
template = random.choice(LEVEL_UP_TEMPLATES)
|
| 418 |
+
|
| 419 |
+
return SpecialMoment(
|
| 420 |
+
moment_type=SpecialMomentType.LEVEL_UP,
|
| 421 |
+
enhanced_narration=f"{character_name} reaches level {new_level}! {template}",
|
| 422 |
+
voice_type=VoiceType.DM,
|
| 423 |
+
ui_effects=["level_up_glow"],
|
| 424 |
+
pause_before_ms=500,
|
| 425 |
+
sound_effect="level_up",
|
| 426 |
+
context={
|
| 427 |
+
"character_name": character_name,
|
| 428 |
+
"new_level": new_level,
|
| 429 |
+
},
|
| 430 |
+
)
|
| 431 |
+
|
| 432 |
+
def detect_from_roll(
|
| 433 |
+
self,
|
| 434 |
+
roll_result: DiceRollResult,
|
| 435 |
+
context: dict[str, object] | None = None,
|
| 436 |
+
) -> SpecialMoment | None:
|
| 437 |
+
"""
|
| 438 |
+
Detect special moment from a dice roll result.
|
| 439 |
+
|
| 440 |
+
Args:
|
| 441 |
+
roll_result: The dice roll result.
|
| 442 |
+
context: Additional context (weapon, target, etc.).
|
| 443 |
+
|
| 444 |
+
Returns:
|
| 445 |
+
SpecialMoment if detected, None otherwise.
|
| 446 |
+
"""
|
| 447 |
+
context = context or {}
|
| 448 |
+
|
| 449 |
+
# Check for critical hit
|
| 450 |
+
if roll_result.is_critical:
|
| 451 |
+
return self.handle_critical_hit(
|
| 452 |
+
weapon=str(context.get("weapon", "weapon")),
|
| 453 |
+
target=str(context.get("target", "enemy")),
|
| 454 |
+
)
|
| 455 |
+
|
| 456 |
+
# Check for critical miss
|
| 457 |
+
if roll_result.is_fumble:
|
| 458 |
+
return self.handle_critical_miss(
|
| 459 |
+
weapon=str(context.get("weapon", "weapon")),
|
| 460 |
+
)
|
| 461 |
+
|
| 462 |
+
return None
|
| 463 |
+
|
| 464 |
+
def get_ui_effect_config(self, effect_name: str) -> dict[str, object]:
|
| 465 |
+
"""
|
| 466 |
+
Get configuration for a UI effect.
|
| 467 |
+
|
| 468 |
+
Args:
|
| 469 |
+
effect_name: Name of the effect.
|
| 470 |
+
|
| 471 |
+
Returns:
|
| 472 |
+
Effect configuration dictionary.
|
| 473 |
+
"""
|
| 474 |
+
return UI_EFFECTS.get(effect_name, {})
|
src/agents/voice_narrator.py
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DungeonMaster AI - Voice Narrator Agent
|
| 3 |
+
|
| 4 |
+
Coordinates multi-voice narration using ElevenLabs.
|
| 5 |
+
NOT an LLM agent - uses existing voice infrastructure.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import asyncio
|
| 11 |
+
import hashlib
|
| 12 |
+
import io
|
| 13 |
+
import logging
|
| 14 |
+
import time
|
| 15 |
+
from typing import TYPE_CHECKING
|
| 16 |
+
|
| 17 |
+
from src.voice.elevenlabs_client import VoiceClient
|
| 18 |
+
from src.voice.models import (
|
| 19 |
+
NarrationResult,
|
| 20 |
+
ProcessedNarration,
|
| 21 |
+
TextSegment,
|
| 22 |
+
VoiceType,
|
| 23 |
+
)
|
| 24 |
+
from src.voice.text_processor import NarrationProcessor
|
| 25 |
+
from src.voice.voice_profiles import get_voice_profile
|
| 26 |
+
|
| 27 |
+
from .exceptions import VoiceNarratorError
|
| 28 |
+
from .models import DMResponse, GameContext, VoiceSegment
|
| 29 |
+
|
| 30 |
+
if TYPE_CHECKING:
|
| 31 |
+
pass
|
| 32 |
+
|
| 33 |
+
logger = logging.getLogger(__name__)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
# =============================================================================
|
| 37 |
+
# Phrase Cache
|
| 38 |
+
# =============================================================================
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class PhraseCache:
|
| 42 |
+
"""
|
| 43 |
+
Cache for common phrases to enable instant playback.
|
| 44 |
+
|
| 45 |
+
Caches audio for frequently used phrases like "Roll for initiative!"
|
| 46 |
+
"""
|
| 47 |
+
|
| 48 |
+
# Common phrases to pre-cache
|
| 49 |
+
COMMON_PHRASES: list[tuple[str, VoiceType]] = [
|
| 50 |
+
("Roll for initiative!", VoiceType.DM),
|
| 51 |
+
("Roll a d20.", VoiceType.DM),
|
| 52 |
+
("Make a saving throw.", VoiceType.DM),
|
| 53 |
+
("Combat has begun!", VoiceType.DM),
|
| 54 |
+
("Your turn.", VoiceType.DM),
|
| 55 |
+
("What do you do?", VoiceType.DM),
|
| 56 |
+
("Make an attack roll.", VoiceType.DM),
|
| 57 |
+
("Roll for damage.", VoiceType.DM),
|
| 58 |
+
("Critical hit!", VoiceType.DM),
|
| 59 |
+
("The enemy is defeated!", VoiceType.DM),
|
| 60 |
+
]
|
| 61 |
+
|
| 62 |
+
def __init__(self, max_size: int = 50) -> None:
|
| 63 |
+
"""
|
| 64 |
+
Initialize phrase cache.
|
| 65 |
+
|
| 66 |
+
Args:
|
| 67 |
+
max_size: Maximum number of phrases to cache.
|
| 68 |
+
"""
|
| 69 |
+
self._cache: dict[str, bytes] = {}
|
| 70 |
+
self._max_size = max_size
|
| 71 |
+
|
| 72 |
+
def _make_key(self, text: str, voice_type: VoiceType) -> str:
|
| 73 |
+
"""Create cache key from text and voice type."""
|
| 74 |
+
content = f"{voice_type.value}:{text}"
|
| 75 |
+
return hashlib.md5(content.encode()).hexdigest()
|
| 76 |
+
|
| 77 |
+
def get(self, text: str, voice_type: VoiceType) -> bytes | None:
|
| 78 |
+
"""Get cached audio for phrase."""
|
| 79 |
+
key = self._make_key(text, voice_type)
|
| 80 |
+
return self._cache.get(key)
|
| 81 |
+
|
| 82 |
+
def set(self, text: str, voice_type: VoiceType, audio: bytes) -> None:
|
| 83 |
+
"""Cache audio for phrase."""
|
| 84 |
+
if len(self._cache) >= self._max_size:
|
| 85 |
+
# Remove oldest entry
|
| 86 |
+
oldest = next(iter(self._cache))
|
| 87 |
+
del self._cache[oldest]
|
| 88 |
+
|
| 89 |
+
key = self._make_key(text, voice_type)
|
| 90 |
+
self._cache[key] = audio
|
| 91 |
+
|
| 92 |
+
def clear(self) -> None:
|
| 93 |
+
"""Clear the cache."""
|
| 94 |
+
self._cache.clear()
|
| 95 |
+
|
| 96 |
+
@property
|
| 97 |
+
def size(self) -> int:
|
| 98 |
+
"""Get number of cached phrases."""
|
| 99 |
+
return len(self._cache)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
# =============================================================================
|
| 103 |
+
# Audio Utilities
|
| 104 |
+
# =============================================================================
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def generate_silence(duration_ms: int, sample_rate: int = 22050) -> bytes:
|
| 108 |
+
"""
|
| 109 |
+
Generate silence audio bytes.
|
| 110 |
+
|
| 111 |
+
Args:
|
| 112 |
+
duration_ms: Duration of silence in milliseconds.
|
| 113 |
+
sample_rate: Audio sample rate.
|
| 114 |
+
|
| 115 |
+
Returns:
|
| 116 |
+
Silence audio as bytes (raw PCM).
|
| 117 |
+
"""
|
| 118 |
+
# For MP3 output, we'll use actual silence
|
| 119 |
+
# This is a minimal silent MP3 frame
|
| 120 |
+
# In production, you'd use proper audio concatenation
|
| 121 |
+
return b"" # Empty bytes - pauses handled differently
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
async def concatenate_audio_segments(
|
| 125 |
+
segments: list[tuple[bytes, int]],
|
| 126 |
+
) -> bytes:
|
| 127 |
+
"""
|
| 128 |
+
Concatenate audio segments with pauses.
|
| 129 |
+
|
| 130 |
+
Args:
|
| 131 |
+
segments: List of (audio_bytes, pause_after_ms) tuples.
|
| 132 |
+
|
| 133 |
+
Returns:
|
| 134 |
+
Combined audio bytes.
|
| 135 |
+
|
| 136 |
+
Note:
|
| 137 |
+
This is a simplified implementation. In production,
|
| 138 |
+
you'd use a proper audio library like pydub.
|
| 139 |
+
"""
|
| 140 |
+
# For now, just concatenate the audio bytes
|
| 141 |
+
# Pauses would require proper audio manipulation
|
| 142 |
+
combined = io.BytesIO()
|
| 143 |
+
for audio, _pause in segments:
|
| 144 |
+
combined.write(audio)
|
| 145 |
+
return combined.getvalue()
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
# =============================================================================
|
| 149 |
+
# VoiceNarratorAgent
|
| 150 |
+
# =============================================================================
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
class VoiceNarratorAgent:
|
| 154 |
+
"""
|
| 155 |
+
Multi-voice narration coordinator.
|
| 156 |
+
|
| 157 |
+
NOT an LLM agent - uses existing voice infrastructure:
|
| 158 |
+
- VoiceClient for ElevenLabs synthesis
|
| 159 |
+
- NarrationProcessor for text preprocessing
|
| 160 |
+
- Voice profiles for character voices
|
| 161 |
+
|
| 162 |
+
Features:
|
| 163 |
+
- Multi-voice dialogue (DM, NPC, monster voices)
|
| 164 |
+
- Phrase caching for common utterances
|
| 165 |
+
- Graceful degradation when voice unavailable
|
| 166 |
+
"""
|
| 167 |
+
|
| 168 |
+
def __init__(
|
| 169 |
+
self,
|
| 170 |
+
voice_client: VoiceClient,
|
| 171 |
+
text_processor: NarrationProcessor | None = None,
|
| 172 |
+
) -> None:
|
| 173 |
+
"""
|
| 174 |
+
Initialize the Voice Narrator.
|
| 175 |
+
|
| 176 |
+
Args:
|
| 177 |
+
voice_client: ElevenLabs voice client.
|
| 178 |
+
text_processor: Text processor for TTS optimization.
|
| 179 |
+
"""
|
| 180 |
+
self._voice_client = voice_client
|
| 181 |
+
self._text_processor = text_processor or NarrationProcessor()
|
| 182 |
+
self._phrase_cache = PhraseCache()
|
| 183 |
+
|
| 184 |
+
logger.info("VoiceNarratorAgent initialized")
|
| 185 |
+
|
| 186 |
+
async def narrate(
|
| 187 |
+
self,
|
| 188 |
+
dm_response: DMResponse,
|
| 189 |
+
game_context: GameContext | None = None,
|
| 190 |
+
) -> NarrationResult:
|
| 191 |
+
"""
|
| 192 |
+
Generate voice narration for a DM response.
|
| 193 |
+
|
| 194 |
+
Args:
|
| 195 |
+
dm_response: The DM's response with voice segments.
|
| 196 |
+
game_context: Optional game context for voice selection.
|
| 197 |
+
|
| 198 |
+
Returns:
|
| 199 |
+
NarrationResult with synthesized audio.
|
| 200 |
+
"""
|
| 201 |
+
start_time = time.time()
|
| 202 |
+
|
| 203 |
+
try:
|
| 204 |
+
# Get voice segments from DM response
|
| 205 |
+
segments = dm_response.voice_segments
|
| 206 |
+
|
| 207 |
+
# If no segments, create one from narration text
|
| 208 |
+
if not segments:
|
| 209 |
+
segments = [
|
| 210 |
+
VoiceSegment(
|
| 211 |
+
text=dm_response.narration,
|
| 212 |
+
voice_type=VoiceType.DM,
|
| 213 |
+
)
|
| 214 |
+
]
|
| 215 |
+
|
| 216 |
+
# Process each segment
|
| 217 |
+
audio_segments: list[tuple[bytes, int]] = []
|
| 218 |
+
total_text = ""
|
| 219 |
+
|
| 220 |
+
for segment in segments:
|
| 221 |
+
# Check phrase cache first
|
| 222 |
+
cached_audio = self._phrase_cache.get(
|
| 223 |
+
segment.text, segment.voice_type
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
if cached_audio:
|
| 227 |
+
audio_segments.append((cached_audio, segment.pause_after_ms))
|
| 228 |
+
total_text += segment.text + " "
|
| 229 |
+
continue
|
| 230 |
+
|
| 231 |
+
# Process text for TTS
|
| 232 |
+
processed_text = self._text_processor.process_for_tts(segment.text)
|
| 233 |
+
|
| 234 |
+
# Synthesize audio
|
| 235 |
+
try:
|
| 236 |
+
audio_bytes = await self._voice_client.synthesize(
|
| 237 |
+
text=processed_text,
|
| 238 |
+
voice_type=segment.voice_type,
|
| 239 |
+
stream=False,
|
| 240 |
+
)
|
| 241 |
+
|
| 242 |
+
if audio_bytes and isinstance(audio_bytes, bytes):
|
| 243 |
+
audio_segments.append(
|
| 244 |
+
(audio_bytes, segment.pause_after_ms)
|
| 245 |
+
)
|
| 246 |
+
|
| 247 |
+
# Cache if it's a short phrase
|
| 248 |
+
if len(segment.text) < 100:
|
| 249 |
+
self._phrase_cache.set(
|
| 250 |
+
segment.text,
|
| 251 |
+
segment.voice_type,
|
| 252 |
+
audio_bytes,
|
| 253 |
+
)
|
| 254 |
+
|
| 255 |
+
except Exception as e:
|
| 256 |
+
logger.warning(f"Failed to synthesize segment: {e}")
|
| 257 |
+
# Continue with other segments
|
| 258 |
+
|
| 259 |
+
total_text += segment.text + " "
|
| 260 |
+
|
| 261 |
+
# Combine audio segments
|
| 262 |
+
if audio_segments:
|
| 263 |
+
combined_audio = await concatenate_audio_segments(audio_segments)
|
| 264 |
+
else:
|
| 265 |
+
combined_audio = None
|
| 266 |
+
|
| 267 |
+
# Determine primary voice used
|
| 268 |
+
primary_voice = VoiceType.DM
|
| 269 |
+
if segments:
|
| 270 |
+
# Use the most common voice type
|
| 271 |
+
voice_counts: dict[VoiceType, int] = {}
|
| 272 |
+
for seg in segments:
|
| 273 |
+
voice_counts[seg.voice_type] = voice_counts.get(seg.voice_type, 0) + 1
|
| 274 |
+
primary_voice = max(voice_counts, key=voice_counts.get)
|
| 275 |
+
|
| 276 |
+
duration_ms = int((time.time() - start_time) * 1000)
|
| 277 |
+
|
| 278 |
+
return NarrationResult(
|
| 279 |
+
success=combined_audio is not None,
|
| 280 |
+
audio=combined_audio,
|
| 281 |
+
format="mp3",
|
| 282 |
+
voice_used=primary_voice.value,
|
| 283 |
+
voice_type=primary_voice,
|
| 284 |
+
text_narrated=dm_response.narration,
|
| 285 |
+
text_processed=total_text.strip(),
|
| 286 |
+
duration_ms=duration_ms,
|
| 287 |
+
is_streaming=False,
|
| 288 |
+
from_cache=False,
|
| 289 |
+
)
|
| 290 |
+
|
| 291 |
+
except Exception as e:
|
| 292 |
+
logger.error(f"Voice narration failed: {e}")
|
| 293 |
+
raise VoiceNarratorError(str(e)) from e
|
| 294 |
+
|
| 295 |
+
async def narrate_text(
|
| 296 |
+
self,
|
| 297 |
+
text: str,
|
| 298 |
+
voice_type: VoiceType = VoiceType.DM,
|
| 299 |
+
) -> NarrationResult:
|
| 300 |
+
"""
|
| 301 |
+
Narrate simple text with a single voice.
|
| 302 |
+
|
| 303 |
+
Args:
|
| 304 |
+
text: Text to narrate.
|
| 305 |
+
voice_type: Voice to use.
|
| 306 |
+
|
| 307 |
+
Returns:
|
| 308 |
+
NarrationResult with audio.
|
| 309 |
+
"""
|
| 310 |
+
# Create minimal DM response
|
| 311 |
+
dm_response = DMResponse(
|
| 312 |
+
narration=text,
|
| 313 |
+
voice_segments=[
|
| 314 |
+
VoiceSegment(text=text, voice_type=voice_type)
|
| 315 |
+
],
|
| 316 |
+
)
|
| 317 |
+
return await self.narrate(dm_response)
|
| 318 |
+
|
| 319 |
+
async def narrate_dialogue(
|
| 320 |
+
self,
|
| 321 |
+
segments: list[VoiceSegment],
|
| 322 |
+
) -> NarrationResult:
|
| 323 |
+
"""
|
| 324 |
+
Narrate multi-voice dialogue sequence.
|
| 325 |
+
|
| 326 |
+
Args:
|
| 327 |
+
segments: List of voice segments.
|
| 328 |
+
|
| 329 |
+
Returns:
|
| 330 |
+
NarrationResult with combined audio.
|
| 331 |
+
"""
|
| 332 |
+
# Build narration text
|
| 333 |
+
full_text = " ".join(s.text for s in segments)
|
| 334 |
+
|
| 335 |
+
dm_response = DMResponse(
|
| 336 |
+
narration=full_text,
|
| 337 |
+
voice_segments=segments,
|
| 338 |
+
)
|
| 339 |
+
return await self.narrate(dm_response)
|
| 340 |
+
|
| 341 |
+
async def pre_cache_phrases(self) -> int:
|
| 342 |
+
"""
|
| 343 |
+
Pre-cache common phrases for instant playback.
|
| 344 |
+
|
| 345 |
+
Returns:
|
| 346 |
+
Number of phrases cached.
|
| 347 |
+
"""
|
| 348 |
+
cached_count = 0
|
| 349 |
+
|
| 350 |
+
for phrase, voice_type in PhraseCache.COMMON_PHRASES:
|
| 351 |
+
try:
|
| 352 |
+
# Check if already cached
|
| 353 |
+
if self._phrase_cache.get(phrase, voice_type):
|
| 354 |
+
cached_count += 1
|
| 355 |
+
continue
|
| 356 |
+
|
| 357 |
+
# Synthesize and cache
|
| 358 |
+
processed = self._text_processor.process_for_tts(phrase)
|
| 359 |
+
audio_bytes = await self._voice_client.synthesize(
|
| 360 |
+
text=processed,
|
| 361 |
+
voice_type=voice_type,
|
| 362 |
+
stream=False,
|
| 363 |
+
)
|
| 364 |
+
|
| 365 |
+
if audio_bytes and isinstance(audio_bytes, bytes):
|
| 366 |
+
self._phrase_cache.set(phrase, voice_type, audio_bytes)
|
| 367 |
+
cached_count += 1
|
| 368 |
+
|
| 369 |
+
except Exception as e:
|
| 370 |
+
logger.warning(f"Failed to pre-cache phrase '{phrase}': {e}")
|
| 371 |
+
|
| 372 |
+
logger.info(f"Pre-cached {cached_count} common phrases")
|
| 373 |
+
return cached_count
|
| 374 |
+
|
| 375 |
+
def get_voice_profile_info(self, voice_type: VoiceType) -> dict[str, str]:
|
| 376 |
+
"""
|
| 377 |
+
Get information about a voice profile.
|
| 378 |
+
|
| 379 |
+
Args:
|
| 380 |
+
voice_type: Voice type to get info for.
|
| 381 |
+
|
| 382 |
+
Returns:
|
| 383 |
+
Dictionary with profile information.
|
| 384 |
+
"""
|
| 385 |
+
profile = get_voice_profile(voice_type)
|
| 386 |
+
return {
|
| 387 |
+
"name": profile.name,
|
| 388 |
+
"description": profile.description,
|
| 389 |
+
"voice_id": profile.voice_id,
|
| 390 |
+
"type": voice_type.value,
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
@property
|
| 394 |
+
def is_available(self) -> bool:
|
| 395 |
+
"""Check if voice service is available."""
|
| 396 |
+
status = self._voice_client.get_status()
|
| 397 |
+
return status.is_available
|
| 398 |
+
|
| 399 |
+
@property
|
| 400 |
+
def cache_size(self) -> int:
|
| 401 |
+
"""Get phrase cache size."""
|
| 402 |
+
return self._phrase_cache.size
|
| 403 |
+
|
| 404 |
+
def clear_cache(self) -> None:
|
| 405 |
+
"""Clear phrase cache."""
|
| 406 |
+
self._phrase_cache.clear()
|
| 407 |
+
logger.info("Voice phrase cache cleared")
|
| 408 |
+
|
| 409 |
+
|
| 410 |
+
# =============================================================================
|
| 411 |
+
# Factory Function
|
| 412 |
+
# =============================================================================
|
| 413 |
+
|
| 414 |
+
|
| 415 |
+
async def create_voice_narrator(
|
| 416 |
+
voice_client: VoiceClient | None = None,
|
| 417 |
+
pre_cache: bool = True,
|
| 418 |
+
) -> VoiceNarratorAgent | None:
|
| 419 |
+
"""
|
| 420 |
+
Create and initialize a VoiceNarratorAgent.
|
| 421 |
+
|
| 422 |
+
Args:
|
| 423 |
+
voice_client: Optional voice client. Creates new one if not provided.
|
| 424 |
+
pre_cache: Whether to pre-cache common phrases.
|
| 425 |
+
|
| 426 |
+
Returns:
|
| 427 |
+
VoiceNarratorAgent or None if voice is unavailable.
|
| 428 |
+
"""
|
| 429 |
+
if voice_client is None:
|
| 430 |
+
voice_client = VoiceClient()
|
| 431 |
+
try:
|
| 432 |
+
await voice_client.initialize()
|
| 433 |
+
except Exception as e:
|
| 434 |
+
logger.warning(f"Voice client initialization failed: {e}")
|
| 435 |
+
return None
|
| 436 |
+
|
| 437 |
+
if not voice_client.get_status().is_available:
|
| 438 |
+
logger.warning("Voice service not available")
|
| 439 |
+
return None
|
| 440 |
+
|
| 441 |
+
narrator = VoiceNarratorAgent(
|
| 442 |
+
voice_client=voice_client,
|
| 443 |
+
text_processor=NarrationProcessor(),
|
| 444 |
+
)
|
| 445 |
+
|
| 446 |
+
if pre_cache:
|
| 447 |
+
await narrator.pre_cache_phrases()
|
| 448 |
+
|
| 449 |
+
return narrator
|
src/config/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DungeonMaster AI - Configuration Package
|
| 3 |
+
|
| 4 |
+
Settings and configuration management.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from src.config.settings import (
|
| 8 |
+
AppSettings,
|
| 9 |
+
GameSettings,
|
| 10 |
+
LLMSettings,
|
| 11 |
+
MCPSettings,
|
| 12 |
+
VoiceSettings,
|
| 13 |
+
get_settings,
|
| 14 |
+
settings,
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
__all__ = [
|
| 18 |
+
"AppSettings",
|
| 19 |
+
"LLMSettings",
|
| 20 |
+
"VoiceSettings",
|
| 21 |
+
"MCPSettings",
|
| 22 |
+
"GameSettings",
|
| 23 |
+
"get_settings",
|
| 24 |
+
"settings",
|
| 25 |
+
]
|
src/config/prompts/dm_combat.txt
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
## Combat Mode - Additional Guidelines
|
| 2 |
+
|
| 3 |
+
You are now running a combat encounter. Shift your narration style to match the intensity and tactical nature of battle.
|
| 4 |
+
|
| 5 |
+
### Combat Flow Management
|
| 6 |
+
1. **Always track initiative order** - use the combat tools to manage turn order
|
| 7 |
+
2. **Announce each turn clearly** - "It's your turn. The goblin archer is 30 feet away, the wounded one is in melee range."
|
| 8 |
+
3. **Describe the battlefield** - remind players of positioning, cover, and tactical options
|
| 9 |
+
4. **Monster tactics** - play enemies intelligently based on their intelligence and nature
|
| 10 |
+
|
| 11 |
+
### Attack Resolution
|
| 12 |
+
When the player attacks:
|
| 13 |
+
1. Ask what they're attacking and how (weapon, spell, ability)
|
| 14 |
+
2. Call for an attack roll using the roll tool with appropriate modifiers
|
| 15 |
+
3. Compare to target AC (use get_monster if needed)
|
| 16 |
+
4. If hit, roll damage with the roll tool
|
| 17 |
+
5. Apply damage using modify_hp
|
| 18 |
+
6. Narrate the result cinematically
|
| 19 |
+
|
| 20 |
+
### Damage Descriptions by Severity
|
| 21 |
+
- **Minor hit (1-5 damage)**: "Your blade grazes the creature's arm, drawing a thin line of blood"
|
| 22 |
+
- **Solid hit (6-15 damage)**: "Your strike lands true, biting deep into flesh"
|
| 23 |
+
- **Devastating hit (16+ damage)**: "Your weapon crashes through the creature's defenses with brutal force"
|
| 24 |
+
- **Critical hit**: "Time seems to slow as your blow finds the perfect opening - a strike that will be sung about in taverns!"
|
| 25 |
+
|
| 26 |
+
### Death and Unconsciousness
|
| 27 |
+
- When a player reaches 0 HP, immediately describe them falling unconscious
|
| 28 |
+
- Begin death saving throws on their turn
|
| 29 |
+
- Describe the tension as allies scramble to help
|
| 30 |
+
- When enemies drop to 0 HP, describe their defeat dramatically
|
| 31 |
+
|
| 32 |
+
### Tactical Information to Provide
|
| 33 |
+
At the start of each player turn, briefly note:
|
| 34 |
+
- Current HP status (without exact numbers unless asked)
|
| 35 |
+
- Obvious threats and opportunities
|
| 36 |
+
- Environmental factors that might help
|
| 37 |
+
- Status of ongoing effects
|
| 38 |
+
|
| 39 |
+
### Ending Combat
|
| 40 |
+
When combat ends:
|
| 41 |
+
1. Use end_combat to properly close the encounter
|
| 42 |
+
2. Describe the aftermath - bodies, environment damage, emotional state
|
| 43 |
+
3. Allow for looting and recovery
|
| 44 |
+
4. Transition smoothly back to exploration or roleplay
|
| 45 |
+
|
| 46 |
+
Remember: Combat should feel exciting and dangerous, but also fair. Use your tools to ensure mechanical accuracy while your narration brings the battle to life!
|
src/config/prompts/dm_exploration.txt
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
## Exploration Mode - Additional Guidelines
|
| 2 |
+
|
| 3 |
+
You are guiding the player through exploration and discovery. Focus on environmental storytelling and rewarding curiosity.
|
| 4 |
+
|
| 5 |
+
### Scene Setting
|
| 6 |
+
When the player enters a new area:
|
| 7 |
+
1. Describe the immediate sensory experience (sight, sound, smell)
|
| 8 |
+
2. Note obvious exits and points of interest
|
| 9 |
+
3. Hint at things that might reward investigation
|
| 10 |
+
4. Establish the mood and atmosphere
|
| 11 |
+
|
| 12 |
+
### Environmental Description Layers
|
| 13 |
+
- **Immediate (always describe)**: What's obvious at first glance
|
| 14 |
+
- **Observable (on looking closer)**: Details noticed with attention
|
| 15 |
+
- **Hidden (requires checks)**: Secrets, traps, hidden passages
|
| 16 |
+
|
| 17 |
+
### Skill Check Triggers
|
| 18 |
+
Call for checks when there's genuine uncertainty:
|
| 19 |
+
- **Perception**: Noticing hidden details, detecting ambushes
|
| 20 |
+
- **Investigation**: Deducing information, finding secret mechanisms
|
| 21 |
+
- **Survival**: Tracking, foraging, navigating wilderness
|
| 22 |
+
- **History/Arcana/Religion/Nature**: Recalling relevant knowledge
|
| 23 |
+
- **Athletics/Acrobatics**: Physical challenges
|
| 24 |
+
|
| 25 |
+
### Check Difficulty Guidelines
|
| 26 |
+
- **DC 10 (Easy)**: Most adventurers succeed
|
| 27 |
+
- **DC 15 (Medium)**: Requires skill or luck
|
| 28 |
+
- **DC 20 (Hard)**: Challenging even for experts
|
| 29 |
+
- **DC 25+ (Very Hard)**: Near-legendary difficulty
|
| 30 |
+
|
| 31 |
+
### Discovery and Rewards
|
| 32 |
+
When players search or investigate:
|
| 33 |
+
1. Use the roll tool for the appropriate check
|
| 34 |
+
2. Scale information revealed to the check result
|
| 35 |
+
3. Use the generators to create appropriate loot if needed
|
| 36 |
+
4. Make discoveries feel earned and exciting
|
| 37 |
+
|
| 38 |
+
### Trap Handling
|
| 39 |
+
If traps are present:
|
| 40 |
+
1. Provide subtle hints before triggering (strange floor tiles, odd drafts)
|
| 41 |
+
2. Allow Perception/Investigation to detect
|
| 42 |
+
3. Allow Thieves' Tools or creative solutions to disarm
|
| 43 |
+
4. If triggered, describe and roll damage fairly
|
| 44 |
+
5. Let creative solutions bypass or minimize harm
|
| 45 |
+
|
| 46 |
+
### Time and Resources
|
| 47 |
+
- Track time passing when relevant (torches, spells, exhaustion)
|
| 48 |
+
- Note when resources are consumed
|
| 49 |
+
- Create tension through resource management when appropriate
|
| 50 |
+
|
| 51 |
+
### Transitioning to Other Modes
|
| 52 |
+
- If combat initiates: "Roll for initiative!" and switch to combat mode
|
| 53 |
+
- If NPC encountered: Switch to social mode for the conversation
|
| 54 |
+
- If puzzle found: Describe it clearly and let the player work through it
|
| 55 |
+
|
| 56 |
+
Remember: Exploration should feel like discovering a living world. Reward curiosity, create atmosphere, and let the environment tell its own story!
|
src/config/prompts/dm_social.txt
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
## Social Encounter Mode - Additional Guidelines
|
| 2 |
+
|
| 3 |
+
You are facilitating roleplay and social interaction. Bring NPCs to life with distinct personalities and motivations.
|
| 4 |
+
|
| 5 |
+
### NPC Portrayal
|
| 6 |
+
When voicing an NPC:
|
| 7 |
+
1. **Distinct voice**: Give each NPC a unique way of speaking (formal, casual, accent hints)
|
| 8 |
+
2. **Clear motivation**: Know what the NPC wants and fears
|
| 9 |
+
3. **Reactive personality**: Respond to player approach (hostile, friendly, suspicious)
|
| 10 |
+
4. **Consistent behavior**: Remember how they've been treated
|
| 11 |
+
|
| 12 |
+
### NPC Personality Markers
|
| 13 |
+
Establish quickly:
|
| 14 |
+
- Speech pattern (verbose, terse, nervous, confident)
|
| 15 |
+
- Emotional state (bored, afraid, excited, suspicious)
|
| 16 |
+
- Relationship to player (helpful, neutral, hostile)
|
| 17 |
+
- Key information they possess
|
| 18 |
+
|
| 19 |
+
### Social Skill Checks
|
| 20 |
+
Call for rolls when:
|
| 21 |
+
- **Persuasion**: Convincing through logic or charm
|
| 22 |
+
- **Deception**: Lying or misleading
|
| 23 |
+
- **Intimidation**: Threatening or coercing
|
| 24 |
+
- **Insight**: Reading true intentions
|
| 25 |
+
- **Performance**: Entertaining or distracting
|
| 26 |
+
|
| 27 |
+
### When NOT to Roll
|
| 28 |
+
- If the player makes a genuinely good argument, consider auto-success
|
| 29 |
+
- If the request is reasonable and NPC is friendly, just say yes
|
| 30 |
+
- If the request is impossible (peasant gives up their only food), no roll helps
|
| 31 |
+
- Let good roleplay grant advantage, poor roleplay grant disadvantage
|
| 32 |
+
|
| 33 |
+
### Dialogue Flow
|
| 34 |
+
1. Let the player speak first
|
| 35 |
+
2. React as the NPC would genuinely react
|
| 36 |
+
3. Provide hooks for continued conversation
|
| 37 |
+
4. Include non-verbal cues (the guard's eyes narrow, the merchant's face lights up)
|
| 38 |
+
|
| 39 |
+
### Information Sharing
|
| 40 |
+
NPCs can provide:
|
| 41 |
+
- **Quest hooks**: Rumors, pleas for help, job offers
|
| 42 |
+
- **World building**: Local history, customs, dangers
|
| 43 |
+
- **Practical info**: Directions, prices, recommendations
|
| 44 |
+
- **Plot advancement**: Clues, secrets, revelations
|
| 45 |
+
|
| 46 |
+
### Reading NPCs
|
| 47 |
+
When players try to discern motives:
|
| 48 |
+
1. Call for Insight check
|
| 49 |
+
2. Scale information to the roll:
|
| 50 |
+
- Low roll: Surface impression only
|
| 51 |
+
- Medium roll: General sense of honesty/intent
|
| 52 |
+
- High roll: Specific hidden emotions or tells
|
| 53 |
+
- Natural 20: Deep understanding of motivation
|
| 54 |
+
|
| 55 |
+
### Conflict Resolution
|
| 56 |
+
Not all social encounters end peacefully:
|
| 57 |
+
- If negotiations break down, give warning signs
|
| 58 |
+
- Allow players to course-correct before violence
|
| 59 |
+
- If combat begins, transition smoothly
|
| 60 |
+
- Remember: Some NPCs can't be talked down
|
| 61 |
+
|
| 62 |
+
### Building Relationships
|
| 63 |
+
Track NPC disposition:
|
| 64 |
+
- Note if players helped or harmed them
|
| 65 |
+
- Have NPCs remember past interactions
|
| 66 |
+
- Build recurring characters when appropriate
|
| 67 |
+
- Create consequences for broken promises
|
| 68 |
+
|
| 69 |
+
Remember: NPCs are people, not quest dispensers. Give them lives, loves, and fears. The most memorable adventures feature characters the player genuinely cares about - or loves to hate!
|
src/config/prompts/dm_system.txt
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
You are an experienced, engaging Dungeon Master for Dungeons & Dragons 5th Edition. Your role is to create an immersive, memorable tabletop roleplaying experience for the player.
|
| 2 |
+
|
| 3 |
+
## Your Personality
|
| 4 |
+
- **Dramatic yet fair**: You weave compelling narratives while ensuring balanced gameplay
|
| 5 |
+
- **Descriptive but concise**: You paint vivid scenes in 2-4 sentences, never overwhelming with walls of text
|
| 6 |
+
- **Responsive and adaptive**: You react creatively to player choices, never railroading them
|
| 7 |
+
- **Knowledgeable**: You understand D&D 5e rules deeply but prioritize fun over strict adherence
|
| 8 |
+
|
| 9 |
+
## Core Responsibilities
|
| 10 |
+
1. **Narrate the story**: Describe environments, events, and outcomes with evocative detail
|
| 11 |
+
2. **Control NPCs and monsters**: Give each character a distinct voice, motivation, and personality
|
| 12 |
+
3. **Present challenges**: Create obstacles that test the player's creativity and character abilities
|
| 13 |
+
4. **Adjudicate actions**: Determine outcomes fairly, calling for appropriate checks when uncertainty exists
|
| 14 |
+
5. **Maintain pacing**: Keep the game moving, cutting to interesting moments
|
| 15 |
+
|
| 16 |
+
## Tool Usage Guidelines (CRITICAL)
|
| 17 |
+
You have access to powerful tools via the TTRPG Toolkit. USE THEM CORRECTLY:
|
| 18 |
+
|
| 19 |
+
- **ALWAYS use the roll tool** for any uncertain outcome. Never arbitrarily decide success/failure.
|
| 20 |
+
- **Use get_character** to check player stats before making decisions involving their abilities
|
| 21 |
+
- **Use modify_hp** when ANY damage is dealt or healing occurs - track HP precisely
|
| 22 |
+
- **Use search_rules** when uncertain about any game mechanic - never guess at rules
|
| 23 |
+
- **Use get_monster** to retrieve accurate monster stats for any encounter
|
| 24 |
+
- **Use combat tools** (start_combat, next_turn, etc.) to properly manage combat flow
|
| 25 |
+
- **Use generators** when you need spontaneous NPCs, encounters, or loot
|
| 26 |
+
|
| 27 |
+
## Response Guidelines
|
| 28 |
+
- Keep narration to 2-4 sentences typically - quality over quantity
|
| 29 |
+
- Describe results narratively, not mechanically
|
| 30 |
+
- GOOD: "Your blade catches the goblin across the chest, and it crumples with a pained shriek"
|
| 31 |
+
- BAD: "You deal 8 slashing damage to the goblin"
|
| 32 |
+
- End responses at natural decision points - let the player choose what happens next
|
| 33 |
+
- When multiple paths exist, briefly hint at options without dictating choice
|
| 34 |
+
- Be fair but challenging - players should feel their choices matter
|
| 35 |
+
|
| 36 |
+
## Combat Narration
|
| 37 |
+
When in combat:
|
| 38 |
+
- Describe attacks cinematically based on success/failure margins
|
| 39 |
+
- A natural 20 deserves epic description
|
| 40 |
+
- A natural 1 should be embarrassing but not campaign-ending
|
| 41 |
+
- Track initiative and remind players whose turn it is
|
| 42 |
+
- Describe monster tactics and reactions to make combat feel dynamic
|
| 43 |
+
|
| 44 |
+
## Voice Narration System (CRITICAL)
|
| 45 |
+
Your narration will be converted to speech. Use VOICE CUES to assign different voices to dialogue and narration.
|
| 46 |
+
|
| 47 |
+
### Available Voice Types
|
| 48 |
+
Use these tags to mark text for different voices:
|
| 49 |
+
- `[VOICE:dm]` - Your narration voice (default, authoritative, dramatic)
|
| 50 |
+
- `[VOICE:gruff]` - Rough, tough characters (dwarves, soldiers, thugs)
|
| 51 |
+
- `[VOICE:gentle]` - Kind, soft characters (healers, elves, children)
|
| 52 |
+
- `[VOICE:mysterious]` - Enigmatic characters (mages, seers, strangers)
|
| 53 |
+
- `[VOICE:monster]` - Creatures and beasts (goblins, dragons, demons)
|
| 54 |
+
|
| 55 |
+
### Voice Cue Format
|
| 56 |
+
Insert voice tags BEFORE the text they apply to. Each tag affects text until the next tag or end of response.
|
| 57 |
+
|
| 58 |
+
### Voice Examples
|
| 59 |
+
GOOD:
|
| 60 |
+
[VOICE:dm]You enter the dimly lit tavern. A grizzled dwarf at the bar turns to face you.
|
| 61 |
+
[VOICE:gruff]"What're ye lookin' at, stranger? This ain't no place for sightseers."
|
| 62 |
+
[VOICE:dm]His hand moves subtly toward a hammer beneath the counter.
|
| 63 |
+
|
| 64 |
+
GOOD (Combat):
|
| 65 |
+
[VOICE:dm]The goblin shrieks as your sword connects!
|
| 66 |
+
[VOICE:monster]"No! No hurt Griknak! We surrenders!"
|
| 67 |
+
[VOICE:dm]The remaining goblins scatter into the shadows.
|
| 68 |
+
|
| 69 |
+
GOOD (Mystery):
|
| 70 |
+
[VOICE:dm]The hooded figure emerges from the mist.
|
| 71 |
+
[VOICE:mysterious]"You seek answers, but are you prepared for the truth?"
|
| 72 |
+
[VOICE:dm]Their eyes glow with an otherworldly light.
|
| 73 |
+
|
| 74 |
+
### Voice Guidelines
|
| 75 |
+
- ALWAYS use [VOICE:dm] for narration and your DM voice
|
| 76 |
+
- SWITCH voices for NPC dialogue to create distinct characters
|
| 77 |
+
- Use [VOICE:monster] for creatures, even intelligent ones, to differentiate from humanoids
|
| 78 |
+
- Keep voice switches to 2-3 per response to maintain clarity
|
| 79 |
+
- Critical moments (death saves, killing blows) should use [VOICE:dm] for maximum impact
|
| 80 |
+
|
| 81 |
+
## The Golden Rule
|
| 82 |
+
Your job is to facilitate fun and adventure. When in doubt:
|
| 83 |
+
1. Ask yourself "What would make this moment most exciting?"
|
| 84 |
+
2. Use your tools to ensure fairness
|
| 85 |
+
3. Say "yes, and..." or "yes, but..." rather than flat "no"
|
| 86 |
+
4. Remember: The player is the hero of this story
|
| 87 |
+
|
| 88 |
+
Now, embrace your role as Dungeon Master. The adventure awaits!
|
src/config/prompts/narrator_system.txt
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
You are the voice narrator for DungeonMaster AI. Your role is to process Dungeon Master text for optimal voice synthesis.
|
| 2 |
+
|
| 3 |
+
## Text Processing Guidelines
|
| 4 |
+
|
| 5 |
+
### Pronunciation
|
| 6 |
+
- Expand abbreviations for natural speech:
|
| 7 |
+
- HP -> "hit points"
|
| 8 |
+
- AC -> "armor class"
|
| 9 |
+
- DC -> "difficulty class"
|
| 10 |
+
- STR, DEX, CON, INT, WIS, CHA -> full ability names
|
| 11 |
+
- d20, d6, etc. -> "dee twenty", "dee six"
|
| 12 |
+
- +5 -> "plus five"
|
| 13 |
+
- -2 -> "minus two"
|
| 14 |
+
- 1st, 2nd, 3rd -> "first", "second", "third"
|
| 15 |
+
|
| 16 |
+
### Dramatic Pacing
|
| 17 |
+
- Add natural pauses after dramatic statements
|
| 18 |
+
- Slow down for important revelations
|
| 19 |
+
- Speed up during action sequences
|
| 20 |
+
- Let tension build with appropriate beats
|
| 21 |
+
|
| 22 |
+
### Dialogue Handling
|
| 23 |
+
When text contains dialogue:
|
| 24 |
+
- DM narration uses the primary narrator voice
|
| 25 |
+
- NPC dialogue should be marked for character voice switching
|
| 26 |
+
- Monster speech may need the monster voice profile
|
| 27 |
+
- Internal thoughts can be delivered with different intonation
|
| 28 |
+
|
| 29 |
+
### Emotional Tone Markers
|
| 30 |
+
Identify the emotional context:
|
| 31 |
+
- **Tension**: Slow, deliberate delivery
|
| 32 |
+
- **Excitement**: Faster, more energetic
|
| 33 |
+
- **Mystery**: Lower, more measured
|
| 34 |
+
- **Danger**: Urgent, warning tone
|
| 35 |
+
- **Victory**: Triumphant, celebratory
|
| 36 |
+
- **Sorrow**: Gentle, somber
|
| 37 |
+
|
| 38 |
+
### Voice Assignment Rules
|
| 39 |
+
Select appropriate voice based on:
|
| 40 |
+
1. **DM Narration**: Deep, authoritative (default DM voice)
|
| 41 |
+
2. **Male NPC - Gruff**: Guards, dwarves, warriors
|
| 42 |
+
3. **Female NPC - Gentle**: Elves, healers, sages
|
| 43 |
+
4. **Mysterious Figure**: Wizards, fey, ethereal beings
|
| 44 |
+
5. **Monster**: Creatures, villains, supernatural entities
|
| 45 |
+
|
| 46 |
+
### Text Cleanup
|
| 47 |
+
Before synthesis:
|
| 48 |
+
- Remove markdown formatting
|
| 49 |
+
- Convert special characters appropriately
|
| 50 |
+
- Ensure proper sentence structure
|
| 51 |
+
- Remove meta-commentary not meant to be spoken
|
| 52 |
+
|
| 53 |
+
### Output Format
|
| 54 |
+
For each text segment, provide:
|
| 55 |
+
1. Cleaned text ready for TTS
|
| 56 |
+
2. Recommended voice profile
|
| 57 |
+
3. Emotional tone suggestion
|
| 58 |
+
4. Any special pacing notes
|
| 59 |
+
|
| 60 |
+
Your goal is to make the audio narration as engaging and immersive as the written adventure!
|
src/config/prompts/rules_system.txt
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
You are a D&D 5th Edition rules expert and arbiter. Your role is to provide accurate, clear rules information when asked.
|
| 2 |
+
|
| 3 |
+
## Core Principles
|
| 4 |
+
1. **Accuracy first**: Always use your tools to verify rules - never guess
|
| 5 |
+
2. **Cite sources**: Reference specific rules when providing information
|
| 6 |
+
3. **Clarity**: Explain rules in plain language, then quote official text if needed
|
| 7 |
+
4. **Fairness**: When rules are ambiguous, suggest fair interpretations
|
| 8 |
+
|
| 9 |
+
## Your Capabilities
|
| 10 |
+
You have access to rules lookup tools:
|
| 11 |
+
- **search_rules**: Find rules by topic or keyword
|
| 12 |
+
- **get_monster**: Retrieve complete monster statistics
|
| 13 |
+
- **get_spell**: Get detailed spell information
|
| 14 |
+
- **get_class_info**: Class features and abilities
|
| 15 |
+
- **get_race_info**: Racial traits and features
|
| 16 |
+
- **get_item**: Magic items and equipment
|
| 17 |
+
- **get_condition**: Status condition effects
|
| 18 |
+
|
| 19 |
+
## Response Format
|
| 20 |
+
When answering rules questions:
|
| 21 |
+
|
| 22 |
+
1. **Direct answer first**: Give the ruling in plain terms
|
| 23 |
+
2. **Official citation**: Quote relevant rules text
|
| 24 |
+
3. **Edge cases**: Note common misunderstandings or exceptions
|
| 25 |
+
4. **Practical application**: Give an example if helpful
|
| 26 |
+
|
| 27 |
+
## Common Rules Areas
|
| 28 |
+
|
| 29 |
+
### Combat
|
| 30 |
+
- Attack rolls and armor class
|
| 31 |
+
- Damage types and resistances
|
| 32 |
+
- Actions, bonus actions, reactions
|
| 33 |
+
- Movement and positioning
|
| 34 |
+
- Cover and visibility
|
| 35 |
+
- Conditions and status effects
|
| 36 |
+
|
| 37 |
+
### Spellcasting
|
| 38 |
+
- Spell slots and casting
|
| 39 |
+
- Concentration
|
| 40 |
+
- Components (V, S, M)
|
| 41 |
+
- Saving throws and attack rolls
|
| 42 |
+
- Spell interactions
|
| 43 |
+
|
| 44 |
+
### Character Abilities
|
| 45 |
+
- Ability checks and skills
|
| 46 |
+
- Saving throws
|
| 47 |
+
- Class features
|
| 48 |
+
- Racial abilities
|
| 49 |
+
- Feats
|
| 50 |
+
|
| 51 |
+
### Adjudication Principles
|
| 52 |
+
When rules are unclear:
|
| 53 |
+
1. Check for specific over general (specific rules override general)
|
| 54 |
+
2. Look for designer intent (what makes sense?)
|
| 55 |
+
3. Consider game balance
|
| 56 |
+
4. Default to what's most fun while fair
|
| 57 |
+
|
| 58 |
+
## Important Notes
|
| 59 |
+
- If asked to rule on homebrew, clarify that your knowledge is for official 5e content
|
| 60 |
+
- When multiple valid interpretations exist, present them fairly
|
| 61 |
+
- Encourage "rule of cool" for dramatic moments, but maintain consistency
|
| 62 |
+
- Remember: The DM has final say, but rules provide the foundation
|
| 63 |
+
|
| 64 |
+
Your goal is to help ensure the game runs smoothly by providing quick, accurate rules information when needed.
|
src/config/settings.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DungeonMaster AI - Application Settings
|
| 3 |
+
|
| 4 |
+
Centralized configuration management using pydantic-settings.
|
| 5 |
+
Loads settings from environment variables and .env file.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from functools import lru_cache
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
from pydantic import Field, field_validator
|
| 12 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class LLMSettings(BaseSettings):
|
| 16 |
+
"""LLM API configuration."""
|
| 17 |
+
|
| 18 |
+
model_config = SettingsConfigDict(
|
| 19 |
+
env_file=".env",
|
| 20 |
+
env_prefix="",
|
| 21 |
+
extra="ignore",
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
# Gemini (Primary)
|
| 25 |
+
gemini_api_key: str = Field(
|
| 26 |
+
default="",
|
| 27 |
+
description="Google Gemini API key for primary LLM",
|
| 28 |
+
)
|
| 29 |
+
gemini_model: str = Field(
|
| 30 |
+
default="gemini-1.5-pro",
|
| 31 |
+
description="Gemini model to use",
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
# OpenAI (Fallback)
|
| 35 |
+
openai_api_key: str = Field(
|
| 36 |
+
default="",
|
| 37 |
+
description="OpenAI API key for fallback LLM",
|
| 38 |
+
)
|
| 39 |
+
openai_model: str = Field(
|
| 40 |
+
default="gpt-4o",
|
| 41 |
+
description="OpenAI model to use as fallback",
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
# LLM Parameters
|
| 45 |
+
temperature: float = Field(
|
| 46 |
+
default=0.75,
|
| 47 |
+
ge=0.0,
|
| 48 |
+
le=2.0,
|
| 49 |
+
description="Temperature for LLM generation (0.0-2.0)",
|
| 50 |
+
)
|
| 51 |
+
max_tokens: int = Field(
|
| 52 |
+
default=1024,
|
| 53 |
+
ge=100,
|
| 54 |
+
le=8192,
|
| 55 |
+
description="Maximum tokens for LLM response",
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
@property
|
| 59 |
+
def has_gemini(self) -> bool:
|
| 60 |
+
"""Check if Gemini API key is configured."""
|
| 61 |
+
return bool(self.gemini_api_key and self.gemini_api_key != "your_gemini_api_key_here")
|
| 62 |
+
|
| 63 |
+
@property
|
| 64 |
+
def has_openai(self) -> bool:
|
| 65 |
+
"""Check if OpenAI API key is configured."""
|
| 66 |
+
return bool(self.openai_api_key and self.openai_api_key != "your_openai_api_key_here")
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
class VoiceSettings(BaseSettings):
|
| 70 |
+
"""Voice synthesis configuration."""
|
| 71 |
+
|
| 72 |
+
model_config = SettingsConfigDict(
|
| 73 |
+
env_file=".env",
|
| 74 |
+
env_prefix="",
|
| 75 |
+
extra="ignore",
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
elevenlabs_api_key: str = Field(
|
| 79 |
+
default="",
|
| 80 |
+
description="ElevenLabs API key for voice synthesis",
|
| 81 |
+
)
|
| 82 |
+
voice_enabled: bool = Field(
|
| 83 |
+
default=True,
|
| 84 |
+
description="Whether voice narration is enabled",
|
| 85 |
+
)
|
| 86 |
+
voice_auto_play: bool = Field(
|
| 87 |
+
default=True,
|
| 88 |
+
description="Auto-play voice narration",
|
| 89 |
+
)
|
| 90 |
+
voice_speed: float = Field(
|
| 91 |
+
default=1.0,
|
| 92 |
+
ge=0.5,
|
| 93 |
+
le=2.0,
|
| 94 |
+
description="Voice playback speed (0.5-2.0)",
|
| 95 |
+
)
|
| 96 |
+
voice_model: str = Field(
|
| 97 |
+
default="eleven_turbo_v2",
|
| 98 |
+
description="ElevenLabs model to use",
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
# Voice Profile IDs (ElevenLabs voice IDs)
|
| 102 |
+
dm_voice_id: str = Field(
|
| 103 |
+
default="pNInz6obpgDQGcFmaJgB", # Adam - deep, authoritative
|
| 104 |
+
description="Voice ID for DM narration",
|
| 105 |
+
)
|
| 106 |
+
npc_male_gruff_voice_id: str = Field(
|
| 107 |
+
default="VR6AewLTigWG4xSOukaG", # Arnold - gruff male
|
| 108 |
+
description="Voice ID for gruff male NPCs",
|
| 109 |
+
)
|
| 110 |
+
npc_female_gentle_voice_id: str = Field(
|
| 111 |
+
default="EXAVITQu4vr4xnSDxMaL", # Bella - gentle female
|
| 112 |
+
description="Voice ID for gentle female NPCs",
|
| 113 |
+
)
|
| 114 |
+
npc_mysterious_voice_id: str = Field(
|
| 115 |
+
default="onwK4e9ZLuTAKqWW03F9", # Daniel - mysterious
|
| 116 |
+
description="Voice ID for mysterious NPCs",
|
| 117 |
+
)
|
| 118 |
+
monster_voice_id: str = Field(
|
| 119 |
+
default="N2lVS1w4EtoT3dr4eOWO", # Callum - menacing
|
| 120 |
+
description="Voice ID for monsters",
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
@property
|
| 124 |
+
def has_elevenlabs(self) -> bool:
|
| 125 |
+
"""Check if ElevenLabs API key is configured."""
|
| 126 |
+
return bool(
|
| 127 |
+
self.elevenlabs_api_key and self.elevenlabs_api_key != "your_elevenlabs_api_key_here"
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
class MCPSettings(BaseSettings):
|
| 132 |
+
"""MCP Server configuration."""
|
| 133 |
+
|
| 134 |
+
model_config = SettingsConfigDict(
|
| 135 |
+
env_file=".env",
|
| 136 |
+
env_prefix="",
|
| 137 |
+
extra="ignore",
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
ttrpg_toolkit_mcp_url: str = Field(
|
| 141 |
+
default="http://localhost:8000/mcp",
|
| 142 |
+
description="TTRPG Toolkit MCP server URL (streamable-http endpoint)",
|
| 143 |
+
)
|
| 144 |
+
mcp_connection_timeout: int = Field(
|
| 145 |
+
default=30,
|
| 146 |
+
ge=5,
|
| 147 |
+
le=120,
|
| 148 |
+
description="MCP connection timeout in seconds",
|
| 149 |
+
)
|
| 150 |
+
mcp_retry_attempts: int = Field(
|
| 151 |
+
default=3,
|
| 152 |
+
ge=1,
|
| 153 |
+
le=10,
|
| 154 |
+
description="Number of retry attempts for MCP connection",
|
| 155 |
+
)
|
| 156 |
+
mcp_retry_delay: float = Field(
|
| 157 |
+
default=1.0,
|
| 158 |
+
ge=0.5,
|
| 159 |
+
le=10.0,
|
| 160 |
+
description="Delay between retry attempts in seconds",
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
class GameSettings(BaseSettings):
|
| 165 |
+
"""Game-specific configuration."""
|
| 166 |
+
|
| 167 |
+
model_config = SettingsConfigDict(
|
| 168 |
+
env_file=".env",
|
| 169 |
+
env_prefix="",
|
| 170 |
+
extra="ignore",
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
max_conversation_history: int = Field(
|
| 174 |
+
default=10,
|
| 175 |
+
ge=5,
|
| 176 |
+
le=50,
|
| 177 |
+
description="Maximum conversation turns to keep in context",
|
| 178 |
+
)
|
| 179 |
+
default_adventure: str = Field(
|
| 180 |
+
default="tavern_start",
|
| 181 |
+
description="Default adventure to load on new game",
|
| 182 |
+
)
|
| 183 |
+
auto_save_enabled: bool = Field(
|
| 184 |
+
default=True,
|
| 185 |
+
description="Enable auto-save functionality",
|
| 186 |
+
)
|
| 187 |
+
auto_save_interval: int = Field(
|
| 188 |
+
default=5,
|
| 189 |
+
ge=1,
|
| 190 |
+
le=30,
|
| 191 |
+
description="Auto-save interval in minutes",
|
| 192 |
+
)
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
class AppSettings(BaseSettings):
|
| 196 |
+
"""Main application settings."""
|
| 197 |
+
|
| 198 |
+
model_config = SettingsConfigDict(
|
| 199 |
+
env_file=".env",
|
| 200 |
+
env_file_encoding="utf-8",
|
| 201 |
+
extra="ignore",
|
| 202 |
+
case_sensitive=False,
|
| 203 |
+
)
|
| 204 |
+
|
| 205 |
+
# Application Info
|
| 206 |
+
app_name: str = Field(
|
| 207 |
+
default="DungeonMaster AI",
|
| 208 |
+
description="Application name",
|
| 209 |
+
)
|
| 210 |
+
app_version: str = Field(
|
| 211 |
+
default="1.0.0",
|
| 212 |
+
description="Application version",
|
| 213 |
+
)
|
| 214 |
+
|
| 215 |
+
# Debug & Logging
|
| 216 |
+
debug_mode: bool = Field(
|
| 217 |
+
default=False,
|
| 218 |
+
description="Enable debug mode",
|
| 219 |
+
)
|
| 220 |
+
log_level: str = Field(
|
| 221 |
+
default="INFO",
|
| 222 |
+
description="Logging level",
|
| 223 |
+
)
|
| 224 |
+
|
| 225 |
+
# HuggingFace
|
| 226 |
+
huggingface_token: str = Field(
|
| 227 |
+
default="",
|
| 228 |
+
description="HuggingFace token for deployment",
|
| 229 |
+
)
|
| 230 |
+
|
| 231 |
+
# Paths
|
| 232 |
+
base_dir: Path = Field(
|
| 233 |
+
default_factory=lambda: Path(__file__).parent.parent.parent,
|
| 234 |
+
description="Base directory of the application",
|
| 235 |
+
)
|
| 236 |
+
|
| 237 |
+
# Sub-settings (loaded separately for better organization)
|
| 238 |
+
llm: LLMSettings = Field(default_factory=LLMSettings)
|
| 239 |
+
voice: VoiceSettings = Field(default_factory=VoiceSettings)
|
| 240 |
+
mcp: MCPSettings = Field(default_factory=MCPSettings)
|
| 241 |
+
game: GameSettings = Field(default_factory=GameSettings)
|
| 242 |
+
|
| 243 |
+
@field_validator("log_level")
|
| 244 |
+
@classmethod
|
| 245 |
+
def validate_log_level(cls, v: str) -> str:
|
| 246 |
+
"""Validate log level."""
|
| 247 |
+
valid_levels = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
|
| 248 |
+
upper_v = v.upper()
|
| 249 |
+
if upper_v not in valid_levels:
|
| 250 |
+
raise ValueError(f"Invalid log level: {v}. Must be one of {valid_levels}")
|
| 251 |
+
return upper_v
|
| 252 |
+
|
| 253 |
+
@property
|
| 254 |
+
def prompts_dir(self) -> Path:
|
| 255 |
+
"""Get prompts directory path."""
|
| 256 |
+
return self.base_dir / "src" / "config" / "prompts"
|
| 257 |
+
|
| 258 |
+
@property
|
| 259 |
+
def adventures_dir(self) -> Path:
|
| 260 |
+
"""Get adventures directory path."""
|
| 261 |
+
return self.base_dir / "adventures"
|
| 262 |
+
|
| 263 |
+
@property
|
| 264 |
+
def assets_dir(self) -> Path:
|
| 265 |
+
"""Get UI assets directory path."""
|
| 266 |
+
return self.base_dir / "ui" / "assets"
|
| 267 |
+
|
| 268 |
+
def get_prompt_path(self, prompt_name: str) -> Path:
|
| 269 |
+
"""Get path to a specific prompt file."""
|
| 270 |
+
return self.prompts_dir / f"{prompt_name}.txt"
|
| 271 |
+
|
| 272 |
+
def get_adventure_path(self, adventure_name: str) -> Path:
|
| 273 |
+
"""Get path to a specific adventure file."""
|
| 274 |
+
return self.adventures_dir / f"{adventure_name}.json"
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
@lru_cache
|
| 278 |
+
def get_settings() -> AppSettings:
|
| 279 |
+
"""
|
| 280 |
+
Get cached application settings.
|
| 281 |
+
|
| 282 |
+
Uses lru_cache to ensure settings are only loaded once.
|
| 283 |
+
Call get_settings.cache_clear() to reload settings.
|
| 284 |
+
"""
|
| 285 |
+
return AppSettings()
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
# Convenience function for quick access
|
| 289 |
+
settings = get_settings()
|
src/game/__init__.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DungeonMaster AI - Game Package
|
| 3 |
+
|
| 4 |
+
Game state management, context building, event logging, and adventure loading.
|
| 5 |
+
|
| 6 |
+
This package provides:
|
| 7 |
+
- GameState: Basic game state dataclass (Phase 1 stub)
|
| 8 |
+
- GameStateManager: High-level state manager with MCP integration
|
| 9 |
+
- StoryContextBuilder: LLM context construction
|
| 10 |
+
- EventLogger: Session event logging
|
| 11 |
+
- AdventureLoader: Pre-made adventure loading
|
| 12 |
+
|
| 13 |
+
Models:
|
| 14 |
+
- SessionEvent: Event log entry
|
| 15 |
+
- Combatant: Combat participant
|
| 16 |
+
- CombatState: Active combat state
|
| 17 |
+
- CharacterSnapshot: Cached character data
|
| 18 |
+
- NPCInfo: NPC information
|
| 19 |
+
- SceneInfo: Location/scene data
|
| 20 |
+
- GameSaveData: Serializable save file
|
| 21 |
+
- AdventureData: Adventure module data
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
# Original Phase 1 exports (preserved for compatibility)
|
| 25 |
+
from .game_state import GameState, GameStateProtocol
|
| 26 |
+
|
| 27 |
+
# New Pydantic models
|
| 28 |
+
from .models import (
|
| 29 |
+
# Enums
|
| 30 |
+
EventType,
|
| 31 |
+
CombatantStatus,
|
| 32 |
+
HPStatus,
|
| 33 |
+
# Event models
|
| 34 |
+
SessionEvent,
|
| 35 |
+
# Combat models
|
| 36 |
+
Combatant,
|
| 37 |
+
CombatState,
|
| 38 |
+
# Character models
|
| 39 |
+
CharacterSnapshot,
|
| 40 |
+
# NPC and Scene models
|
| 41 |
+
NPCInfo,
|
| 42 |
+
SceneInfo,
|
| 43 |
+
# Save/Load models
|
| 44 |
+
GameSaveData,
|
| 45 |
+
# Adventure models
|
| 46 |
+
AdventureMetadata,
|
| 47 |
+
EncounterData,
|
| 48 |
+
AdventureData,
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
# Manager classes
|
| 52 |
+
from .game_state_manager import GameStateManager
|
| 53 |
+
from .story_context import StoryContextBuilder
|
| 54 |
+
from .event_logger import EventLogger
|
| 55 |
+
from .adventure_loader import AdventureLoader
|
| 56 |
+
|
| 57 |
+
__all__ = [
|
| 58 |
+
# Original (Phase 1)
|
| 59 |
+
"GameState",
|
| 60 |
+
"GameStateProtocol",
|
| 61 |
+
# Enums
|
| 62 |
+
"EventType",
|
| 63 |
+
"CombatantStatus",
|
| 64 |
+
"HPStatus",
|
| 65 |
+
# Event models
|
| 66 |
+
"SessionEvent",
|
| 67 |
+
# Combat models
|
| 68 |
+
"Combatant",
|
| 69 |
+
"CombatState",
|
| 70 |
+
# Character models
|
| 71 |
+
"CharacterSnapshot",
|
| 72 |
+
# NPC and Scene models
|
| 73 |
+
"NPCInfo",
|
| 74 |
+
"SceneInfo",
|
| 75 |
+
# Save/Load models
|
| 76 |
+
"GameSaveData",
|
| 77 |
+
# Adventure models
|
| 78 |
+
"AdventureMetadata",
|
| 79 |
+
"EncounterData",
|
| 80 |
+
"AdventureData",
|
| 81 |
+
# Manager classes
|
| 82 |
+
"GameStateManager",
|
| 83 |
+
"StoryContextBuilder",
|
| 84 |
+
"EventLogger",
|
| 85 |
+
"AdventureLoader",
|
| 86 |
+
]
|
src/game/adventure_loader.py
ADDED
|
@@ -0,0 +1,530 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DungeonMaster AI - Adventure Loader
|
| 3 |
+
|
| 4 |
+
Loads and manages adventure JSON files for pre-made scenarios.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import json
|
| 10 |
+
import logging
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
from typing import TYPE_CHECKING
|
| 13 |
+
|
| 14 |
+
from .models import (
|
| 15 |
+
AdventureData,
|
| 16 |
+
EncounterData,
|
| 17 |
+
NPCInfo,
|
| 18 |
+
SceneInfo,
|
| 19 |
+
)
|
| 20 |
+
|
| 21 |
+
if TYPE_CHECKING:
|
| 22 |
+
from .game_state_manager import GameStateManager
|
| 23 |
+
|
| 24 |
+
logger = logging.getLogger(__name__)
|
| 25 |
+
|
| 26 |
+
# Default adventures directory relative to project root
|
| 27 |
+
DEFAULT_ADVENTURES_DIR = Path(__file__).parent.parent.parent / "adventures"
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class AdventureLoader:
|
| 31 |
+
"""
|
| 32 |
+
Loads and manages adventure JSON files.
|
| 33 |
+
|
| 34 |
+
Adventures are pre-made scenarios with scenes, NPCs, encounters,
|
| 35 |
+
and victory conditions. This class handles loading, parsing,
|
| 36 |
+
and initializing games from adventure files.
|
| 37 |
+
"""
|
| 38 |
+
|
| 39 |
+
def __init__(
|
| 40 |
+
self,
|
| 41 |
+
adventures_dir: Path | str | None = None,
|
| 42 |
+
) -> None:
|
| 43 |
+
"""
|
| 44 |
+
Initialize the adventure loader.
|
| 45 |
+
|
| 46 |
+
Args:
|
| 47 |
+
adventures_dir: Directory containing adventure JSON files.
|
| 48 |
+
Defaults to 'adventures/' in project root.
|
| 49 |
+
"""
|
| 50 |
+
if adventures_dir is None:
|
| 51 |
+
self._adventures_dir = DEFAULT_ADVENTURES_DIR
|
| 52 |
+
else:
|
| 53 |
+
self._adventures_dir = Path(adventures_dir)
|
| 54 |
+
|
| 55 |
+
# Cache loaded adventures
|
| 56 |
+
self._cache: dict[str, AdventureData] = {}
|
| 57 |
+
|
| 58 |
+
logger.debug(f"AdventureLoader initialized with dir: {self._adventures_dir}")
|
| 59 |
+
|
| 60 |
+
@property
|
| 61 |
+
def adventures_dir(self) -> Path:
|
| 62 |
+
"""Get the adventures directory path."""
|
| 63 |
+
return self._adventures_dir
|
| 64 |
+
|
| 65 |
+
# =========================================================================
|
| 66 |
+
# Adventure Discovery
|
| 67 |
+
# =========================================================================
|
| 68 |
+
|
| 69 |
+
def list_adventures(self) -> list[dict[str, str]]:
|
| 70 |
+
"""
|
| 71 |
+
List all available adventures.
|
| 72 |
+
|
| 73 |
+
Returns:
|
| 74 |
+
List of adventure info dicts with keys:
|
| 75 |
+
- name: Adventure name
|
| 76 |
+
- file: Filename
|
| 77 |
+
- description: Adventure description
|
| 78 |
+
- difficulty: Difficulty level
|
| 79 |
+
- estimated_time: Estimated play time
|
| 80 |
+
"""
|
| 81 |
+
adventures: list[dict[str, str]] = []
|
| 82 |
+
|
| 83 |
+
if not self._adventures_dir.exists():
|
| 84 |
+
logger.warning(f"Adventures directory not found: {self._adventures_dir}")
|
| 85 |
+
return adventures
|
| 86 |
+
|
| 87 |
+
for json_file in self._adventures_dir.glob("*.json"):
|
| 88 |
+
# Skip sample_characters directory
|
| 89 |
+
if json_file.is_dir():
|
| 90 |
+
continue
|
| 91 |
+
|
| 92 |
+
try:
|
| 93 |
+
with open(json_file, encoding="utf-8") as f:
|
| 94 |
+
data = json.load(f)
|
| 95 |
+
|
| 96 |
+
metadata = data.get("metadata", {})
|
| 97 |
+
adventures.append(
|
| 98 |
+
{
|
| 99 |
+
"name": metadata.get("name", json_file.stem),
|
| 100 |
+
"file": json_file.name,
|
| 101 |
+
"description": metadata.get("description", ""),
|
| 102 |
+
"difficulty": metadata.get("difficulty", "medium"),
|
| 103 |
+
"estimated_time": metadata.get("estimated_time", "Unknown"),
|
| 104 |
+
"recommended_level": str(
|
| 105 |
+
metadata.get("recommended_level", 1)
|
| 106 |
+
),
|
| 107 |
+
}
|
| 108 |
+
)
|
| 109 |
+
except (json.JSONDecodeError, OSError) as e:
|
| 110 |
+
logger.warning(f"Failed to load adventure {json_file}: {e}")
|
| 111 |
+
continue
|
| 112 |
+
|
| 113 |
+
return adventures
|
| 114 |
+
|
| 115 |
+
# =========================================================================
|
| 116 |
+
# Adventure Loading
|
| 117 |
+
# =========================================================================
|
| 118 |
+
|
| 119 |
+
def load(self, adventure_name: str) -> AdventureData | None:
|
| 120 |
+
"""
|
| 121 |
+
Load an adventure by name.
|
| 122 |
+
|
| 123 |
+
Checks cache first, then loads from file.
|
| 124 |
+
|
| 125 |
+
Args:
|
| 126 |
+
adventure_name: Name of adventure or filename (with/without .json)
|
| 127 |
+
|
| 128 |
+
Returns:
|
| 129 |
+
AdventureData if found, None otherwise
|
| 130 |
+
"""
|
| 131 |
+
# Check cache
|
| 132 |
+
if adventure_name in self._cache:
|
| 133 |
+
return self._cache[adventure_name]
|
| 134 |
+
|
| 135 |
+
# Find the file
|
| 136 |
+
json_file = self._find_adventure_file(adventure_name)
|
| 137 |
+
if json_file is None:
|
| 138 |
+
logger.warning(f"Adventure not found: {adventure_name}")
|
| 139 |
+
return None
|
| 140 |
+
|
| 141 |
+
# Load and parse
|
| 142 |
+
try:
|
| 143 |
+
with open(json_file, encoding="utf-8") as f:
|
| 144 |
+
data = json.load(f)
|
| 145 |
+
|
| 146 |
+
adventure = AdventureData.from_json(data)
|
| 147 |
+
|
| 148 |
+
# Cache by both name and filename
|
| 149 |
+
self._cache[adventure_name] = adventure
|
| 150 |
+
self._cache[adventure.metadata.name] = adventure
|
| 151 |
+
self._cache[json_file.stem] = adventure
|
| 152 |
+
|
| 153 |
+
logger.info(f"Loaded adventure: {adventure.metadata.name}")
|
| 154 |
+
return adventure
|
| 155 |
+
|
| 156 |
+
except (json.JSONDecodeError, OSError) as e:
|
| 157 |
+
logger.error(f"Failed to load adventure {json_file}: {e}")
|
| 158 |
+
return None
|
| 159 |
+
except Exception as e:
|
| 160 |
+
logger.error(f"Failed to parse adventure {json_file}: {e}")
|
| 161 |
+
return None
|
| 162 |
+
|
| 163 |
+
def _find_adventure_file(self, adventure_name: str) -> Path | None:
|
| 164 |
+
"""
|
| 165 |
+
Find an adventure file by name.
|
| 166 |
+
|
| 167 |
+
Args:
|
| 168 |
+
adventure_name: Name or filename to search for
|
| 169 |
+
|
| 170 |
+
Returns:
|
| 171 |
+
Path to file if found, None otherwise
|
| 172 |
+
"""
|
| 173 |
+
if not self._adventures_dir.exists():
|
| 174 |
+
return None
|
| 175 |
+
|
| 176 |
+
# Try exact filename
|
| 177 |
+
exact_path = self._adventures_dir / adventure_name
|
| 178 |
+
if exact_path.exists():
|
| 179 |
+
return exact_path
|
| 180 |
+
|
| 181 |
+
# Try with .json extension
|
| 182 |
+
json_path = self._adventures_dir / f"{adventure_name}.json"
|
| 183 |
+
if json_path.exists():
|
| 184 |
+
return json_path
|
| 185 |
+
|
| 186 |
+
# Try matching by metadata name
|
| 187 |
+
for json_file in self._adventures_dir.glob("*.json"):
|
| 188 |
+
try:
|
| 189 |
+
with open(json_file, encoding="utf-8") as f:
|
| 190 |
+
data = json.load(f)
|
| 191 |
+
metadata = data.get("metadata", {})
|
| 192 |
+
if metadata.get("name", "").lower() == adventure_name.lower():
|
| 193 |
+
return json_file
|
| 194 |
+
except (json.JSONDecodeError, OSError):
|
| 195 |
+
continue
|
| 196 |
+
|
| 197 |
+
return None
|
| 198 |
+
|
| 199 |
+
# =========================================================================
|
| 200 |
+
# Game Initialization
|
| 201 |
+
# =========================================================================
|
| 202 |
+
|
| 203 |
+
async def initialize_game(
|
| 204 |
+
self,
|
| 205 |
+
manager: GameStateManager,
|
| 206 |
+
adventure_name: str,
|
| 207 |
+
) -> bool:
|
| 208 |
+
"""
|
| 209 |
+
Initialize a game session with an adventure.
|
| 210 |
+
|
| 211 |
+
Args:
|
| 212 |
+
manager: GameStateManager to initialize
|
| 213 |
+
adventure_name: Name of adventure to load
|
| 214 |
+
|
| 215 |
+
Returns:
|
| 216 |
+
True if successful, False otherwise
|
| 217 |
+
"""
|
| 218 |
+
adventure = self.load(adventure_name)
|
| 219 |
+
if adventure is None:
|
| 220 |
+
return False
|
| 221 |
+
|
| 222 |
+
try:
|
| 223 |
+
# Start new game with adventure name
|
| 224 |
+
await manager.new_game(adventure=adventure.metadata.name)
|
| 225 |
+
|
| 226 |
+
# Set starting location
|
| 227 |
+
starting_scene = adventure.starting_scene
|
| 228 |
+
scene_id = str(starting_scene.get("scene_id", ""))
|
| 229 |
+
scene_info = self.get_scene(adventure_name, scene_id)
|
| 230 |
+
|
| 231 |
+
if scene_info:
|
| 232 |
+
manager.set_location(scene_info.name, scene_info)
|
| 233 |
+
else:
|
| 234 |
+
# Fallback to basic location from starting scene
|
| 235 |
+
manager.set_location(
|
| 236 |
+
str(starting_scene.get("scene_id", "Unknown")),
|
| 237 |
+
None,
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
+
# Add all NPCs to manager
|
| 241 |
+
for npc_data in adventure.npcs:
|
| 242 |
+
if isinstance(npc_data, dict):
|
| 243 |
+
npc = self._parse_npc(npc_data)
|
| 244 |
+
if npc:
|
| 245 |
+
manager.add_known_npc(npc)
|
| 246 |
+
|
| 247 |
+
# Set initial story flags
|
| 248 |
+
manager.set_story_flag("adventure_started", True)
|
| 249 |
+
manager.set_story_flag("adventure_name", adventure.metadata.name)
|
| 250 |
+
|
| 251 |
+
logger.info(
|
| 252 |
+
f"Initialized game with adventure: {adventure.metadata.name}"
|
| 253 |
+
)
|
| 254 |
+
return True
|
| 255 |
+
|
| 256 |
+
except Exception as e:
|
| 257 |
+
logger.error(f"Failed to initialize game with adventure: {e}")
|
| 258 |
+
return False
|
| 259 |
+
|
| 260 |
+
# =========================================================================
|
| 261 |
+
# Content Retrieval
|
| 262 |
+
# =========================================================================
|
| 263 |
+
|
| 264 |
+
def get_starting_narrative(self, adventure_name: str) -> str:
|
| 265 |
+
"""
|
| 266 |
+
Get the opening narrative for an adventure.
|
| 267 |
+
|
| 268 |
+
Args:
|
| 269 |
+
adventure_name: Name of adventure
|
| 270 |
+
|
| 271 |
+
Returns:
|
| 272 |
+
Opening narrative text, or empty string if not found
|
| 273 |
+
"""
|
| 274 |
+
adventure = self.load(adventure_name)
|
| 275 |
+
if adventure is None:
|
| 276 |
+
return ""
|
| 277 |
+
|
| 278 |
+
starting_scene = adventure.starting_scene
|
| 279 |
+
return str(starting_scene.get("narrative", ""))
|
| 280 |
+
|
| 281 |
+
def get_scene(
|
| 282 |
+
self,
|
| 283 |
+
adventure_name: str,
|
| 284 |
+
scene_id: str,
|
| 285 |
+
) -> SceneInfo | None:
|
| 286 |
+
"""
|
| 287 |
+
Get a scene from an adventure.
|
| 288 |
+
|
| 289 |
+
Args:
|
| 290 |
+
adventure_name: Name of adventure
|
| 291 |
+
scene_id: Scene ID to find
|
| 292 |
+
|
| 293 |
+
Returns:
|
| 294 |
+
SceneInfo if found, None otherwise
|
| 295 |
+
"""
|
| 296 |
+
adventure = self.load(adventure_name)
|
| 297 |
+
if adventure is None:
|
| 298 |
+
return None
|
| 299 |
+
|
| 300 |
+
scene_data = adventure.get_scene(scene_id)
|
| 301 |
+
if scene_data is None:
|
| 302 |
+
return None
|
| 303 |
+
|
| 304 |
+
return self._parse_scene(scene_data)
|
| 305 |
+
|
| 306 |
+
def get_encounter(
|
| 307 |
+
self,
|
| 308 |
+
adventure_name: str,
|
| 309 |
+
encounter_id: str,
|
| 310 |
+
) -> EncounterData | None:
|
| 311 |
+
"""
|
| 312 |
+
Get an encounter from an adventure.
|
| 313 |
+
|
| 314 |
+
Args:
|
| 315 |
+
adventure_name: Name of adventure
|
| 316 |
+
encounter_id: Encounter ID to find
|
| 317 |
+
|
| 318 |
+
Returns:
|
| 319 |
+
EncounterData if found, None otherwise
|
| 320 |
+
"""
|
| 321 |
+
adventure = self.load(adventure_name)
|
| 322 |
+
if adventure is None:
|
| 323 |
+
return None
|
| 324 |
+
|
| 325 |
+
return adventure.get_encounter(encounter_id)
|
| 326 |
+
|
| 327 |
+
def get_npc(
|
| 328 |
+
self,
|
| 329 |
+
adventure_name: str,
|
| 330 |
+
npc_id: str,
|
| 331 |
+
) -> NPCInfo | None:
|
| 332 |
+
"""
|
| 333 |
+
Get an NPC from an adventure.
|
| 334 |
+
|
| 335 |
+
Args:
|
| 336 |
+
adventure_name: Name of adventure
|
| 337 |
+
npc_id: NPC ID to find
|
| 338 |
+
|
| 339 |
+
Returns:
|
| 340 |
+
NPCInfo if found, None otherwise
|
| 341 |
+
"""
|
| 342 |
+
adventure = self.load(adventure_name)
|
| 343 |
+
if adventure is None:
|
| 344 |
+
return None
|
| 345 |
+
|
| 346 |
+
npc_data = adventure.get_npc(npc_id)
|
| 347 |
+
if npc_data is None:
|
| 348 |
+
return None
|
| 349 |
+
|
| 350 |
+
return self._parse_npc(npc_data)
|
| 351 |
+
|
| 352 |
+
def get_all_scenes(self, adventure_name: str) -> list[SceneInfo]:
|
| 353 |
+
"""
|
| 354 |
+
Get all scenes from an adventure.
|
| 355 |
+
|
| 356 |
+
Args:
|
| 357 |
+
adventure_name: Name of adventure
|
| 358 |
+
|
| 359 |
+
Returns:
|
| 360 |
+
List of all SceneInfo objects
|
| 361 |
+
"""
|
| 362 |
+
adventure = self.load(adventure_name)
|
| 363 |
+
if adventure is None:
|
| 364 |
+
return []
|
| 365 |
+
|
| 366 |
+
scenes: list[SceneInfo] = []
|
| 367 |
+
for scene_data in adventure.scenes:
|
| 368 |
+
if isinstance(scene_data, dict):
|
| 369 |
+
scene = self._parse_scene(scene_data)
|
| 370 |
+
if scene:
|
| 371 |
+
scenes.append(scene)
|
| 372 |
+
|
| 373 |
+
return scenes
|
| 374 |
+
|
| 375 |
+
def get_all_npcs(self, adventure_name: str) -> list[NPCInfo]:
|
| 376 |
+
"""
|
| 377 |
+
Get all NPCs from an adventure.
|
| 378 |
+
|
| 379 |
+
Args:
|
| 380 |
+
adventure_name: Name of adventure
|
| 381 |
+
|
| 382 |
+
Returns:
|
| 383 |
+
List of all NPCInfo objects
|
| 384 |
+
"""
|
| 385 |
+
adventure = self.load(adventure_name)
|
| 386 |
+
if adventure is None:
|
| 387 |
+
return []
|
| 388 |
+
|
| 389 |
+
npcs: list[NPCInfo] = []
|
| 390 |
+
for npc_data in adventure.npcs:
|
| 391 |
+
if isinstance(npc_data, dict):
|
| 392 |
+
npc = self._parse_npc(npc_data)
|
| 393 |
+
if npc:
|
| 394 |
+
npcs.append(npc)
|
| 395 |
+
|
| 396 |
+
return npcs
|
| 397 |
+
|
| 398 |
+
# =========================================================================
|
| 399 |
+
# Parsing Helpers
|
| 400 |
+
# =========================================================================
|
| 401 |
+
|
| 402 |
+
def _parse_scene(self, data: dict[str, object]) -> SceneInfo | None:
|
| 403 |
+
"""
|
| 404 |
+
Parse a scene dict into SceneInfo.
|
| 405 |
+
|
| 406 |
+
Args:
|
| 407 |
+
data: Raw scene data
|
| 408 |
+
|
| 409 |
+
Returns:
|
| 410 |
+
SceneInfo if valid, None otherwise
|
| 411 |
+
"""
|
| 412 |
+
try:
|
| 413 |
+
scene_id = str(data.get("scene_id", ""))
|
| 414 |
+
if not scene_id:
|
| 415 |
+
return None
|
| 416 |
+
|
| 417 |
+
# Extract sensory details
|
| 418 |
+
details = data.get("details", {})
|
| 419 |
+
sensory: dict[str, str] = {}
|
| 420 |
+
if isinstance(details, dict):
|
| 421 |
+
sensory_raw = details.get("sensory", {})
|
| 422 |
+
if isinstance(sensory_raw, dict):
|
| 423 |
+
for key, value in sensory_raw.items():
|
| 424 |
+
sensory[str(key)] = str(value)
|
| 425 |
+
|
| 426 |
+
# Extract searchable objects
|
| 427 |
+
searchable: list[dict[str, object]] = []
|
| 428 |
+
if isinstance(details, dict):
|
| 429 |
+
searchable_raw = details.get("searchable", [])
|
| 430 |
+
if isinstance(searchable_raw, list):
|
| 431 |
+
searchable = [
|
| 432 |
+
dict(obj) for obj in searchable_raw if isinstance(obj, dict)
|
| 433 |
+
]
|
| 434 |
+
|
| 435 |
+
# Extract encounter ID
|
| 436 |
+
encounter = data.get("encounter")
|
| 437 |
+
encounter_id: str | None = None
|
| 438 |
+
if isinstance(encounter, dict):
|
| 439 |
+
encounter_id = str(encounter.get("encounter_id", ""))
|
| 440 |
+
elif isinstance(encounter, str):
|
| 441 |
+
encounter_id = encounter
|
| 442 |
+
|
| 443 |
+
return SceneInfo(
|
| 444 |
+
scene_id=scene_id,
|
| 445 |
+
name=str(data.get("name", scene_id)),
|
| 446 |
+
description=str(data.get("description", "")),
|
| 447 |
+
sensory_details=sensory,
|
| 448 |
+
exits=dict(data.get("exits", {})),
|
| 449 |
+
npcs_present=list(data.get("npcs_present", [])),
|
| 450 |
+
items=list(data.get("items", [])),
|
| 451 |
+
encounter_id=encounter_id if encounter_id else None,
|
| 452 |
+
searchable_objects=searchable,
|
| 453 |
+
)
|
| 454 |
+
|
| 455 |
+
except Exception as e:
|
| 456 |
+
logger.warning(f"Failed to parse scene: {e}")
|
| 457 |
+
return None
|
| 458 |
+
|
| 459 |
+
def _parse_npc(self, data: dict[str, object]) -> NPCInfo | None:
|
| 460 |
+
"""
|
| 461 |
+
Parse an NPC dict into NPCInfo.
|
| 462 |
+
|
| 463 |
+
Args:
|
| 464 |
+
data: Raw NPC data
|
| 465 |
+
|
| 466 |
+
Returns:
|
| 467 |
+
NPCInfo if valid, None otherwise
|
| 468 |
+
"""
|
| 469 |
+
try:
|
| 470 |
+
npc_id = str(data.get("npc_id", ""))
|
| 471 |
+
if not npc_id:
|
| 472 |
+
return None
|
| 473 |
+
|
| 474 |
+
return NPCInfo(
|
| 475 |
+
npc_id=npc_id,
|
| 476 |
+
name=str(data.get("name", "Unknown")),
|
| 477 |
+
description=str(data.get("description", "")),
|
| 478 |
+
personality=str(data.get("personality", "")),
|
| 479 |
+
voice_profile=str(data.get("voice_profile", "dm")),
|
| 480 |
+
dialogue_hooks=list(data.get("dialogue_hooks", [])),
|
| 481 |
+
monster_stat_block=data.get("monster_stat_block"),
|
| 482 |
+
relationship="hostile"
|
| 483 |
+
if data.get("monster_stat_block")
|
| 484 |
+
else "neutral",
|
| 485 |
+
)
|
| 486 |
+
|
| 487 |
+
except Exception as e:
|
| 488 |
+
logger.warning(f"Failed to parse NPC: {e}")
|
| 489 |
+
return None
|
| 490 |
+
|
| 491 |
+
# =========================================================================
|
| 492 |
+
# Cache Management
|
| 493 |
+
# =========================================================================
|
| 494 |
+
|
| 495 |
+
def clear_cache(self) -> None:
|
| 496 |
+
"""Clear the adventure cache."""
|
| 497 |
+
self._cache.clear()
|
| 498 |
+
logger.debug("Adventure cache cleared")
|
| 499 |
+
|
| 500 |
+
def is_cached(self, adventure_name: str) -> bool:
|
| 501 |
+
"""
|
| 502 |
+
Check if an adventure is cached.
|
| 503 |
+
|
| 504 |
+
Args:
|
| 505 |
+
adventure_name: Name of adventure
|
| 506 |
+
|
| 507 |
+
Returns:
|
| 508 |
+
True if cached, False otherwise
|
| 509 |
+
"""
|
| 510 |
+
return adventure_name in self._cache
|
| 511 |
+
|
| 512 |
+
def preload(self, adventure_names: list[str]) -> int:
|
| 513 |
+
"""
|
| 514 |
+
Preload multiple adventures into cache.
|
| 515 |
+
|
| 516 |
+
Args:
|
| 517 |
+
adventure_names: List of adventure names to load
|
| 518 |
+
|
| 519 |
+
Returns:
|
| 520 |
+
Number of successfully loaded adventures
|
| 521 |
+
"""
|
| 522 |
+
loaded = 0
|
| 523 |
+
for name in adventure_names:
|
| 524 |
+
if self.load(name) is not None:
|
| 525 |
+
loaded += 1
|
| 526 |
+
return loaded
|
| 527 |
+
|
| 528 |
+
def __len__(self) -> int:
|
| 529 |
+
"""Return number of cached adventures."""
|
| 530 |
+
return len(self._cache)
|
src/game/event_logger.py
ADDED
|
@@ -0,0 +1,724 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DungeonMaster AI - Event Logger
|
| 3 |
+
|
| 4 |
+
Logs game events for context building and session history.
|
| 5 |
+
Syncs events to MCP session manager asynchronously.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import asyncio
|
| 11 |
+
import logging
|
| 12 |
+
import uuid
|
| 13 |
+
from datetime import datetime
|
| 14 |
+
from typing import TYPE_CHECKING
|
| 15 |
+
|
| 16 |
+
from .models import EventType, SessionEvent
|
| 17 |
+
|
| 18 |
+
if TYPE_CHECKING:
|
| 19 |
+
from src.mcp_integration.toolkit_client import TTRPGToolkitClient
|
| 20 |
+
|
| 21 |
+
logger = logging.getLogger(__name__)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
# Map EventType to MCP session manager event types
|
| 25 |
+
MCP_EVENT_TYPE_MAP: dict[EventType, str] = {
|
| 26 |
+
EventType.ROLL: "combat",
|
| 27 |
+
EventType.COMBAT_START: "combat",
|
| 28 |
+
EventType.COMBAT_END: "combat",
|
| 29 |
+
EventType.COMBAT_ACTION: "combat",
|
| 30 |
+
EventType.DAMAGE: "combat",
|
| 31 |
+
EventType.HEALING: "combat",
|
| 32 |
+
EventType.MOVEMENT: "discovery",
|
| 33 |
+
EventType.DIALOGUE: "roleplay",
|
| 34 |
+
EventType.DISCOVERY: "discovery",
|
| 35 |
+
EventType.ITEM_ACQUIRED: "loot",
|
| 36 |
+
EventType.REST: "rest",
|
| 37 |
+
EventType.LEVEL_UP: "level_up",
|
| 38 |
+
EventType.DEATH: "death",
|
| 39 |
+
EventType.STORY_FLAG: "note",
|
| 40 |
+
EventType.SYSTEM: "note",
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class EventLogger:
|
| 45 |
+
"""
|
| 46 |
+
Logs and manages game session events.
|
| 47 |
+
|
| 48 |
+
Provides type-specific logging methods and syncs events to MCP.
|
| 49 |
+
Events are stored locally and optionally synced to the MCP
|
| 50 |
+
session manager for persistent history.
|
| 51 |
+
"""
|
| 52 |
+
|
| 53 |
+
def __init__(
|
| 54 |
+
self,
|
| 55 |
+
toolkit_client: TTRPGToolkitClient | None = None,
|
| 56 |
+
max_events: int = 100,
|
| 57 |
+
) -> None:
|
| 58 |
+
"""
|
| 59 |
+
Initialize the event logger.
|
| 60 |
+
|
| 61 |
+
Args:
|
| 62 |
+
toolkit_client: Optional MCP toolkit client for syncing
|
| 63 |
+
max_events: Maximum events to keep in memory
|
| 64 |
+
"""
|
| 65 |
+
self._toolkit_client = toolkit_client
|
| 66 |
+
self._max_events = max_events
|
| 67 |
+
self._events: list[SessionEvent] = []
|
| 68 |
+
self._current_turn = 0
|
| 69 |
+
self._mcp_session_id: str | None = None
|
| 70 |
+
|
| 71 |
+
def set_toolkit_client(self, client: TTRPGToolkitClient | None) -> None:
|
| 72 |
+
"""Set or update the toolkit client."""
|
| 73 |
+
self._toolkit_client = client
|
| 74 |
+
|
| 75 |
+
def set_mcp_session_id(self, session_id: str | None) -> None:
|
| 76 |
+
"""Set the MCP session ID for syncing."""
|
| 77 |
+
self._mcp_session_id = session_id
|
| 78 |
+
|
| 79 |
+
def set_current_turn(self, turn: int) -> None:
|
| 80 |
+
"""Update the current turn number."""
|
| 81 |
+
self._current_turn = turn
|
| 82 |
+
|
| 83 |
+
@property
|
| 84 |
+
def events(self) -> list[SessionEvent]:
|
| 85 |
+
"""Get all logged events."""
|
| 86 |
+
return self._events.copy()
|
| 87 |
+
|
| 88 |
+
# =========================================================================
|
| 89 |
+
# Core Logging
|
| 90 |
+
# =========================================================================
|
| 91 |
+
|
| 92 |
+
def _create_event(
|
| 93 |
+
self,
|
| 94 |
+
event_type: EventType,
|
| 95 |
+
description: str,
|
| 96 |
+
data: dict[str, object] | None = None,
|
| 97 |
+
is_significant: bool = False,
|
| 98 |
+
) -> SessionEvent:
|
| 99 |
+
"""
|
| 100 |
+
Create and store a new event.
|
| 101 |
+
|
| 102 |
+
Args:
|
| 103 |
+
event_type: Type of event
|
| 104 |
+
description: Human-readable description
|
| 105 |
+
data: Event-specific data
|
| 106 |
+
is_significant: Whether event is significant for context
|
| 107 |
+
|
| 108 |
+
Returns:
|
| 109 |
+
The created SessionEvent
|
| 110 |
+
"""
|
| 111 |
+
event = SessionEvent(
|
| 112 |
+
event_id=str(uuid.uuid4()),
|
| 113 |
+
event_type=event_type,
|
| 114 |
+
description=description,
|
| 115 |
+
data=data or {},
|
| 116 |
+
timestamp=datetime.now(),
|
| 117 |
+
turn=self._current_turn,
|
| 118 |
+
is_significant=is_significant,
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
self._events.append(event)
|
| 122 |
+
|
| 123 |
+
# Trim to max size
|
| 124 |
+
if len(self._events) > self._max_events:
|
| 125 |
+
self._events = self._events[-self._max_events :]
|
| 126 |
+
|
| 127 |
+
# Fire-and-forget sync to MCP
|
| 128 |
+
self._sync_to_mcp(event)
|
| 129 |
+
|
| 130 |
+
return event
|
| 131 |
+
|
| 132 |
+
def _sync_to_mcp(self, event: SessionEvent) -> None:
|
| 133 |
+
"""
|
| 134 |
+
Sync an event to MCP session manager (fire-and-forget).
|
| 135 |
+
|
| 136 |
+
Args:
|
| 137 |
+
event: Event to sync
|
| 138 |
+
"""
|
| 139 |
+
if not self._toolkit_client or not self._mcp_session_id:
|
| 140 |
+
return
|
| 141 |
+
|
| 142 |
+
try:
|
| 143 |
+
# Check if we're in an async context
|
| 144 |
+
loop = asyncio.get_running_loop()
|
| 145 |
+
# Create task for async sync
|
| 146 |
+
loop.create_task(self._async_sync_event(event))
|
| 147 |
+
except RuntimeError:
|
| 148 |
+
# Not in async context, skip sync
|
| 149 |
+
logger.debug("Not in async context, skipping MCP event sync")
|
| 150 |
+
|
| 151 |
+
async def _async_sync_event(self, event: SessionEvent) -> None:
|
| 152 |
+
"""
|
| 153 |
+
Async helper to sync event to MCP.
|
| 154 |
+
|
| 155 |
+
Args:
|
| 156 |
+
event: Event to sync
|
| 157 |
+
"""
|
| 158 |
+
if not self._toolkit_client:
|
| 159 |
+
return
|
| 160 |
+
|
| 161 |
+
try:
|
| 162 |
+
mcp_event_type = MCP_EVENT_TYPE_MAP.get(event.event_type, "note")
|
| 163 |
+
|
| 164 |
+
await self._toolkit_client.call_tool(
|
| 165 |
+
"mcp_log_event",
|
| 166 |
+
{
|
| 167 |
+
"event_type": mcp_event_type,
|
| 168 |
+
"description": event.description,
|
| 169 |
+
"important": event.is_significant,
|
| 170 |
+
},
|
| 171 |
+
)
|
| 172 |
+
except Exception as e:
|
| 173 |
+
# Silent failure - don't block gameplay for sync failures
|
| 174 |
+
logger.debug(f"Failed to sync event to MCP: {e}")
|
| 175 |
+
|
| 176 |
+
# =========================================================================
|
| 177 |
+
# Type-Specific Logging Methods
|
| 178 |
+
# =========================================================================
|
| 179 |
+
|
| 180 |
+
def log_roll(
|
| 181 |
+
self,
|
| 182 |
+
notation: str,
|
| 183 |
+
total: int,
|
| 184 |
+
roll_type: str = "standard",
|
| 185 |
+
is_critical: bool = False,
|
| 186 |
+
is_fumble: bool = False,
|
| 187 |
+
character_name: str | None = None,
|
| 188 |
+
) -> SessionEvent:
|
| 189 |
+
"""
|
| 190 |
+
Log a dice roll event.
|
| 191 |
+
|
| 192 |
+
Args:
|
| 193 |
+
notation: Dice notation (e.g., "1d20+5")
|
| 194 |
+
total: Roll total
|
| 195 |
+
roll_type: Type of roll (attack, save, check, etc.)
|
| 196 |
+
is_critical: Whether this is a natural 20
|
| 197 |
+
is_fumble: Whether this is a natural 1
|
| 198 |
+
character_name: Name of character rolling
|
| 199 |
+
|
| 200 |
+
Returns:
|
| 201 |
+
Created SessionEvent
|
| 202 |
+
"""
|
| 203 |
+
actor = character_name or "Unknown"
|
| 204 |
+
|
| 205 |
+
# Build description
|
| 206 |
+
if is_critical:
|
| 207 |
+
desc = f"{actor} rolled {notation}: {total} - CRITICAL!"
|
| 208 |
+
elif is_fumble:
|
| 209 |
+
desc = f"{actor} rolled {notation}: {total} - Fumble!"
|
| 210 |
+
else:
|
| 211 |
+
desc = f"{actor} rolled {notation}: {total}"
|
| 212 |
+
|
| 213 |
+
if roll_type != "standard":
|
| 214 |
+
desc += f" ({roll_type})"
|
| 215 |
+
|
| 216 |
+
return self._create_event(
|
| 217 |
+
event_type=EventType.ROLL,
|
| 218 |
+
description=desc,
|
| 219 |
+
data={
|
| 220 |
+
"notation": notation,
|
| 221 |
+
"total": total,
|
| 222 |
+
"roll_type": roll_type,
|
| 223 |
+
"is_critical": is_critical,
|
| 224 |
+
"is_fumble": is_fumble,
|
| 225 |
+
"character": character_name,
|
| 226 |
+
},
|
| 227 |
+
is_significant=is_critical or is_fumble,
|
| 228 |
+
)
|
| 229 |
+
|
| 230 |
+
def log_combat_action(
|
| 231 |
+
self,
|
| 232 |
+
actor: str,
|
| 233 |
+
action: str,
|
| 234 |
+
target: str | None = None,
|
| 235 |
+
damage: int | None = None,
|
| 236 |
+
hit: bool | None = None,
|
| 237 |
+
) -> SessionEvent:
|
| 238 |
+
"""
|
| 239 |
+
Log a combat action event.
|
| 240 |
+
|
| 241 |
+
Args:
|
| 242 |
+
actor: Who performed the action
|
| 243 |
+
action: What action was taken (attack, cast, etc.)
|
| 244 |
+
target: Target of the action
|
| 245 |
+
damage: Damage dealt if applicable
|
| 246 |
+
hit: Whether attack hit if applicable
|
| 247 |
+
|
| 248 |
+
Returns:
|
| 249 |
+
Created SessionEvent
|
| 250 |
+
"""
|
| 251 |
+
parts = [f"{actor} {action}"]
|
| 252 |
+
|
| 253 |
+
if target:
|
| 254 |
+
parts.append(f"targeting {target}")
|
| 255 |
+
|
| 256 |
+
if hit is not None:
|
| 257 |
+
if hit:
|
| 258 |
+
parts.append("- Hit!")
|
| 259 |
+
if damage:
|
| 260 |
+
parts.append(f"for {damage} damage")
|
| 261 |
+
else:
|
| 262 |
+
parts.append("- Miss!")
|
| 263 |
+
|
| 264 |
+
desc = " ".join(parts)
|
| 265 |
+
|
| 266 |
+
return self._create_event(
|
| 267 |
+
event_type=EventType.COMBAT_ACTION,
|
| 268 |
+
description=desc,
|
| 269 |
+
data={
|
| 270 |
+
"actor": actor,
|
| 271 |
+
"action": action,
|
| 272 |
+
"target": target,
|
| 273 |
+
"damage": damage,
|
| 274 |
+
"hit": hit,
|
| 275 |
+
},
|
| 276 |
+
is_significant=damage is not None and damage >= 10,
|
| 277 |
+
)
|
| 278 |
+
|
| 279 |
+
def log_damage(
|
| 280 |
+
self,
|
| 281 |
+
character_name: str,
|
| 282 |
+
amount: int,
|
| 283 |
+
damage_type: str = "untyped",
|
| 284 |
+
source: str = "unknown",
|
| 285 |
+
is_lethal: bool = False,
|
| 286 |
+
) -> SessionEvent:
|
| 287 |
+
"""
|
| 288 |
+
Log a damage event.
|
| 289 |
+
|
| 290 |
+
Args:
|
| 291 |
+
character_name: Who took damage
|
| 292 |
+
amount: Amount of damage
|
| 293 |
+
damage_type: Type of damage
|
| 294 |
+
source: Source of damage
|
| 295 |
+
is_lethal: Whether damage was lethal
|
| 296 |
+
|
| 297 |
+
Returns:
|
| 298 |
+
Created SessionEvent
|
| 299 |
+
"""
|
| 300 |
+
if is_lethal:
|
| 301 |
+
desc = f"{character_name} took {amount} {damage_type} damage from {source} and fell unconscious!"
|
| 302 |
+
else:
|
| 303 |
+
desc = f"{character_name} took {amount} {damage_type} damage from {source}"
|
| 304 |
+
|
| 305 |
+
return self._create_event(
|
| 306 |
+
event_type=EventType.DAMAGE,
|
| 307 |
+
description=desc,
|
| 308 |
+
data={
|
| 309 |
+
"character": character_name,
|
| 310 |
+
"amount": amount,
|
| 311 |
+
"damage_type": damage_type,
|
| 312 |
+
"source": source,
|
| 313 |
+
"is_lethal": is_lethal,
|
| 314 |
+
},
|
| 315 |
+
is_significant=is_lethal or amount >= 10,
|
| 316 |
+
)
|
| 317 |
+
|
| 318 |
+
def log_healing(
|
| 319 |
+
self,
|
| 320 |
+
character_name: str,
|
| 321 |
+
amount: int,
|
| 322 |
+
source: str = "unknown",
|
| 323 |
+
) -> SessionEvent:
|
| 324 |
+
"""
|
| 325 |
+
Log a healing event.
|
| 326 |
+
|
| 327 |
+
Args:
|
| 328 |
+
character_name: Who was healed
|
| 329 |
+
amount: Amount of healing
|
| 330 |
+
source: Source of healing
|
| 331 |
+
|
| 332 |
+
Returns:
|
| 333 |
+
Created SessionEvent
|
| 334 |
+
"""
|
| 335 |
+
desc = f"{character_name} healed {amount} HP from {source}"
|
| 336 |
+
|
| 337 |
+
return self._create_event(
|
| 338 |
+
event_type=EventType.HEALING,
|
| 339 |
+
description=desc,
|
| 340 |
+
data={
|
| 341 |
+
"character": character_name,
|
| 342 |
+
"amount": amount,
|
| 343 |
+
"source": source,
|
| 344 |
+
},
|
| 345 |
+
is_significant=amount >= 10,
|
| 346 |
+
)
|
| 347 |
+
|
| 348 |
+
def log_dialogue(
|
| 349 |
+
self,
|
| 350 |
+
speaker: str,
|
| 351 |
+
summary: str,
|
| 352 |
+
is_npc: bool = True,
|
| 353 |
+
) -> SessionEvent:
|
| 354 |
+
"""
|
| 355 |
+
Log a dialogue event.
|
| 356 |
+
|
| 357 |
+
Args:
|
| 358 |
+
speaker: Who is speaking
|
| 359 |
+
summary: Summary of what was said
|
| 360 |
+
is_npc: Whether speaker is an NPC
|
| 361 |
+
|
| 362 |
+
Returns:
|
| 363 |
+
Created SessionEvent
|
| 364 |
+
"""
|
| 365 |
+
desc = f'{speaker}: "{summary}"'
|
| 366 |
+
|
| 367 |
+
return self._create_event(
|
| 368 |
+
event_type=EventType.DIALOGUE,
|
| 369 |
+
description=desc,
|
| 370 |
+
data={
|
| 371 |
+
"speaker": speaker,
|
| 372 |
+
"summary": summary,
|
| 373 |
+
"is_npc": is_npc,
|
| 374 |
+
},
|
| 375 |
+
is_significant=False,
|
| 376 |
+
)
|
| 377 |
+
|
| 378 |
+
def log_discovery(
|
| 379 |
+
self,
|
| 380 |
+
what: str,
|
| 381 |
+
details: str = "",
|
| 382 |
+
character_name: str | None = None,
|
| 383 |
+
) -> SessionEvent:
|
| 384 |
+
"""
|
| 385 |
+
Log a discovery event.
|
| 386 |
+
|
| 387 |
+
Args:
|
| 388 |
+
what: What was discovered
|
| 389 |
+
details: Additional details
|
| 390 |
+
character_name: Who made the discovery
|
| 391 |
+
|
| 392 |
+
Returns:
|
| 393 |
+
Created SessionEvent
|
| 394 |
+
"""
|
| 395 |
+
actor = character_name or "The party"
|
| 396 |
+
|
| 397 |
+
if details:
|
| 398 |
+
desc = f"{actor} discovered: {what} - {details}"
|
| 399 |
+
else:
|
| 400 |
+
desc = f"{actor} discovered: {what}"
|
| 401 |
+
|
| 402 |
+
return self._create_event(
|
| 403 |
+
event_type=EventType.DISCOVERY,
|
| 404 |
+
description=desc,
|
| 405 |
+
data={
|
| 406 |
+
"what": what,
|
| 407 |
+
"details": details,
|
| 408 |
+
"character": character_name,
|
| 409 |
+
},
|
| 410 |
+
is_significant=True, # Discoveries are always significant
|
| 411 |
+
)
|
| 412 |
+
|
| 413 |
+
def log_movement(
|
| 414 |
+
self,
|
| 415 |
+
from_location: str,
|
| 416 |
+
to_location: str,
|
| 417 |
+
) -> SessionEvent:
|
| 418 |
+
"""
|
| 419 |
+
Log a movement/location change event.
|
| 420 |
+
|
| 421 |
+
Args:
|
| 422 |
+
from_location: Previous location
|
| 423 |
+
to_location: New location
|
| 424 |
+
|
| 425 |
+
Returns:
|
| 426 |
+
Created SessionEvent
|
| 427 |
+
"""
|
| 428 |
+
desc = f"Moved from {from_location} to {to_location}"
|
| 429 |
+
|
| 430 |
+
return self._create_event(
|
| 431 |
+
event_type=EventType.MOVEMENT,
|
| 432 |
+
description=desc,
|
| 433 |
+
data={
|
| 434 |
+
"from": from_location,
|
| 435 |
+
"to": to_location,
|
| 436 |
+
},
|
| 437 |
+
is_significant=True, # Location changes are significant
|
| 438 |
+
)
|
| 439 |
+
|
| 440 |
+
def log_combat_start(
|
| 441 |
+
self,
|
| 442 |
+
description: str,
|
| 443 |
+
combatants: list[str] | None = None,
|
| 444 |
+
) -> SessionEvent:
|
| 445 |
+
"""
|
| 446 |
+
Log combat starting.
|
| 447 |
+
|
| 448 |
+
Args:
|
| 449 |
+
description: Description of combat start
|
| 450 |
+
combatants: List of combatant names
|
| 451 |
+
|
| 452 |
+
Returns:
|
| 453 |
+
Created SessionEvent
|
| 454 |
+
"""
|
| 455 |
+
return self._create_event(
|
| 456 |
+
event_type=EventType.COMBAT_START,
|
| 457 |
+
description=f"Combat began: {description}",
|
| 458 |
+
data={
|
| 459 |
+
"combatants": combatants or [],
|
| 460 |
+
},
|
| 461 |
+
is_significant=True,
|
| 462 |
+
)
|
| 463 |
+
|
| 464 |
+
def log_combat_end(
|
| 465 |
+
self,
|
| 466 |
+
outcome: str = "victory",
|
| 467 |
+
description: str = "",
|
| 468 |
+
) -> SessionEvent:
|
| 469 |
+
"""
|
| 470 |
+
Log combat ending.
|
| 471 |
+
|
| 472 |
+
Args:
|
| 473 |
+
outcome: Combat outcome (victory, defeat, fled)
|
| 474 |
+
description: Additional description
|
| 475 |
+
|
| 476 |
+
Returns:
|
| 477 |
+
Created SessionEvent
|
| 478 |
+
"""
|
| 479 |
+
desc = f"Combat ended: {outcome}"
|
| 480 |
+
if description:
|
| 481 |
+
desc += f" - {description}"
|
| 482 |
+
|
| 483 |
+
return self._create_event(
|
| 484 |
+
event_type=EventType.COMBAT_END,
|
| 485 |
+
description=desc,
|
| 486 |
+
data={
|
| 487 |
+
"outcome": outcome,
|
| 488 |
+
},
|
| 489 |
+
is_significant=True,
|
| 490 |
+
)
|
| 491 |
+
|
| 492 |
+
def log_item_acquired(
|
| 493 |
+
self,
|
| 494 |
+
item_name: str,
|
| 495 |
+
character_name: str | None = None,
|
| 496 |
+
quantity: int = 1,
|
| 497 |
+
) -> SessionEvent:
|
| 498 |
+
"""
|
| 499 |
+
Log acquiring an item.
|
| 500 |
+
|
| 501 |
+
Args:
|
| 502 |
+
item_name: Name of item
|
| 503 |
+
character_name: Who got the item
|
| 504 |
+
quantity: Number of items
|
| 505 |
+
|
| 506 |
+
Returns:
|
| 507 |
+
Created SessionEvent
|
| 508 |
+
"""
|
| 509 |
+
actor = character_name or "The party"
|
| 510 |
+
|
| 511 |
+
if quantity > 1:
|
| 512 |
+
desc = f"{actor} acquired {quantity}x {item_name}"
|
| 513 |
+
else:
|
| 514 |
+
desc = f"{actor} acquired {item_name}"
|
| 515 |
+
|
| 516 |
+
return self._create_event(
|
| 517 |
+
event_type=EventType.ITEM_ACQUIRED,
|
| 518 |
+
description=desc,
|
| 519 |
+
data={
|
| 520 |
+
"item": item_name,
|
| 521 |
+
"character": character_name,
|
| 522 |
+
"quantity": quantity,
|
| 523 |
+
},
|
| 524 |
+
is_significant=True,
|
| 525 |
+
)
|
| 526 |
+
|
| 527 |
+
def log_rest(
|
| 528 |
+
self,
|
| 529 |
+
rest_type: str,
|
| 530 |
+
character_name: str | None = None,
|
| 531 |
+
hp_recovered: int = 0,
|
| 532 |
+
) -> SessionEvent:
|
| 533 |
+
"""
|
| 534 |
+
Log a rest event.
|
| 535 |
+
|
| 536 |
+
Args:
|
| 537 |
+
rest_type: Type of rest (short, long)
|
| 538 |
+
character_name: Who rested
|
| 539 |
+
hp_recovered: HP recovered if applicable
|
| 540 |
+
|
| 541 |
+
Returns:
|
| 542 |
+
Created SessionEvent
|
| 543 |
+
"""
|
| 544 |
+
actor = character_name or "The party"
|
| 545 |
+
desc = f"{actor} took a {rest_type} rest"
|
| 546 |
+
|
| 547 |
+
if hp_recovered > 0:
|
| 548 |
+
desc += f" and recovered {hp_recovered} HP"
|
| 549 |
+
|
| 550 |
+
return self._create_event(
|
| 551 |
+
event_type=EventType.REST,
|
| 552 |
+
description=desc,
|
| 553 |
+
data={
|
| 554 |
+
"rest_type": rest_type,
|
| 555 |
+
"character": character_name,
|
| 556 |
+
"hp_recovered": hp_recovered,
|
| 557 |
+
},
|
| 558 |
+
is_significant=True,
|
| 559 |
+
)
|
| 560 |
+
|
| 561 |
+
def log_death(
|
| 562 |
+
self,
|
| 563 |
+
character_name: str,
|
| 564 |
+
cause: str = "unknown",
|
| 565 |
+
) -> SessionEvent:
|
| 566 |
+
"""
|
| 567 |
+
Log a character death.
|
| 568 |
+
|
| 569 |
+
Args:
|
| 570 |
+
character_name: Who died
|
| 571 |
+
cause: Cause of death
|
| 572 |
+
|
| 573 |
+
Returns:
|
| 574 |
+
Created SessionEvent
|
| 575 |
+
"""
|
| 576 |
+
return self._create_event(
|
| 577 |
+
event_type=EventType.DEATH,
|
| 578 |
+
description=f"{character_name} has fallen! Cause: {cause}",
|
| 579 |
+
data={
|
| 580 |
+
"character": character_name,
|
| 581 |
+
"cause": cause,
|
| 582 |
+
},
|
| 583 |
+
is_significant=True, # Deaths are always significant
|
| 584 |
+
)
|
| 585 |
+
|
| 586 |
+
def log_level_up(
|
| 587 |
+
self,
|
| 588 |
+
character_name: str,
|
| 589 |
+
new_level: int,
|
| 590 |
+
) -> SessionEvent:
|
| 591 |
+
"""
|
| 592 |
+
Log a level up.
|
| 593 |
+
|
| 594 |
+
Args:
|
| 595 |
+
character_name: Who leveled up
|
| 596 |
+
new_level: New level
|
| 597 |
+
|
| 598 |
+
Returns:
|
| 599 |
+
Created SessionEvent
|
| 600 |
+
"""
|
| 601 |
+
return self._create_event(
|
| 602 |
+
event_type=EventType.LEVEL_UP,
|
| 603 |
+
description=f"{character_name} reached level {new_level}!",
|
| 604 |
+
data={
|
| 605 |
+
"character": character_name,
|
| 606 |
+
"level": new_level,
|
| 607 |
+
},
|
| 608 |
+
is_significant=True,
|
| 609 |
+
)
|
| 610 |
+
|
| 611 |
+
def log_story_flag(
|
| 612 |
+
self,
|
| 613 |
+
flag: str,
|
| 614 |
+
value: object,
|
| 615 |
+
description: str = "",
|
| 616 |
+
) -> SessionEvent:
|
| 617 |
+
"""
|
| 618 |
+
Log a story flag change.
|
| 619 |
+
|
| 620 |
+
Args:
|
| 621 |
+
flag: Flag name
|
| 622 |
+
value: New value
|
| 623 |
+
description: Optional description
|
| 624 |
+
|
| 625 |
+
Returns:
|
| 626 |
+
Created SessionEvent
|
| 627 |
+
"""
|
| 628 |
+
desc = description or f"Story progress: {flag}"
|
| 629 |
+
|
| 630 |
+
return self._create_event(
|
| 631 |
+
event_type=EventType.STORY_FLAG,
|
| 632 |
+
description=desc,
|
| 633 |
+
data={
|
| 634 |
+
"flag": flag,
|
| 635 |
+
"value": value,
|
| 636 |
+
},
|
| 637 |
+
is_significant=True,
|
| 638 |
+
)
|
| 639 |
+
|
| 640 |
+
def log_system(
|
| 641 |
+
self,
|
| 642 |
+
message: str,
|
| 643 |
+
data: dict[str, object] | None = None,
|
| 644 |
+
) -> SessionEvent:
|
| 645 |
+
"""
|
| 646 |
+
Log a system event.
|
| 647 |
+
|
| 648 |
+
Args:
|
| 649 |
+
message: System message
|
| 650 |
+
data: Optional data
|
| 651 |
+
|
| 652 |
+
Returns:
|
| 653 |
+
Created SessionEvent
|
| 654 |
+
"""
|
| 655 |
+
return self._create_event(
|
| 656 |
+
event_type=EventType.SYSTEM,
|
| 657 |
+
description=message,
|
| 658 |
+
data=data or {},
|
| 659 |
+
is_significant=False,
|
| 660 |
+
)
|
| 661 |
+
|
| 662 |
+
# =========================================================================
|
| 663 |
+
# Retrieval Methods
|
| 664 |
+
# =========================================================================
|
| 665 |
+
|
| 666 |
+
def get_recent(
|
| 667 |
+
self,
|
| 668 |
+
count: int = 10,
|
| 669 |
+
event_type: EventType | None = None,
|
| 670 |
+
significant_only: bool = False,
|
| 671 |
+
) -> list[SessionEvent]:
|
| 672 |
+
"""
|
| 673 |
+
Get recent events with optional filtering.
|
| 674 |
+
|
| 675 |
+
Args:
|
| 676 |
+
count: Maximum events to return
|
| 677 |
+
event_type: Filter by event type
|
| 678 |
+
significant_only: Only return significant events
|
| 679 |
+
|
| 680 |
+
Returns:
|
| 681 |
+
List of matching events (most recent first)
|
| 682 |
+
"""
|
| 683 |
+
filtered = self._events.copy()
|
| 684 |
+
|
| 685 |
+
if event_type is not None:
|
| 686 |
+
filtered = [e for e in filtered if e.event_type == event_type]
|
| 687 |
+
|
| 688 |
+
if significant_only:
|
| 689 |
+
filtered = [e for e in filtered if e.is_significant]
|
| 690 |
+
|
| 691 |
+
# Return most recent first
|
| 692 |
+
return list(reversed(filtered[-count:]))
|
| 693 |
+
|
| 694 |
+
def get_events_for_turn(self, turn: int) -> list[SessionEvent]:
|
| 695 |
+
"""
|
| 696 |
+
Get all events for a specific turn.
|
| 697 |
+
|
| 698 |
+
Args:
|
| 699 |
+
turn: Turn number
|
| 700 |
+
|
| 701 |
+
Returns:
|
| 702 |
+
List of events from that turn
|
| 703 |
+
"""
|
| 704 |
+
return [e for e in self._events if e.turn == turn]
|
| 705 |
+
|
| 706 |
+
def get_events_since(self, timestamp: datetime) -> list[SessionEvent]:
|
| 707 |
+
"""
|
| 708 |
+
Get all events since a given timestamp.
|
| 709 |
+
|
| 710 |
+
Args:
|
| 711 |
+
timestamp: Cutoff timestamp
|
| 712 |
+
|
| 713 |
+
Returns:
|
| 714 |
+
List of events after timestamp
|
| 715 |
+
"""
|
| 716 |
+
return [e for e in self._events if e.timestamp > timestamp]
|
| 717 |
+
|
| 718 |
+
def clear(self) -> None:
|
| 719 |
+
"""Clear all logged events."""
|
| 720 |
+
self._events.clear()
|
| 721 |
+
|
| 722 |
+
def __len__(self) -> int:
|
| 723 |
+
"""Return number of logged events."""
|
| 724 |
+
return len(self._events)
|
src/game/game_state.py
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DungeonMaster AI - Game State Management
|
| 3 |
+
|
| 4 |
+
Minimal GameState stub for Phase 1 (MCP Integration).
|
| 5 |
+
This provides the interface that tool wrappers need.
|
| 6 |
+
Phase 4 will extend this with full implementation.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import uuid
|
| 12 |
+
from dataclasses import dataclass, field
|
| 13 |
+
from datetime import datetime
|
| 14 |
+
from typing import Protocol, runtime_checkable
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@runtime_checkable
|
| 18 |
+
class GameStateProtocol(Protocol):
|
| 19 |
+
"""
|
| 20 |
+
Protocol for game state access.
|
| 21 |
+
|
| 22 |
+
This interface allows MCP tool wrappers to update game state
|
| 23 |
+
without depending on the full GameState implementation.
|
| 24 |
+
Phase 4 will provide the complete implementation.
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
session_id: str
|
| 28 |
+
in_combat: bool
|
| 29 |
+
party: list[str]
|
| 30 |
+
recent_events: list[dict[str, object]]
|
| 31 |
+
|
| 32 |
+
def add_event(
|
| 33 |
+
self,
|
| 34 |
+
event_type: str,
|
| 35 |
+
description: str,
|
| 36 |
+
data: dict[str, object],
|
| 37 |
+
) -> None:
|
| 38 |
+
"""Add an event to recent events."""
|
| 39 |
+
...
|
| 40 |
+
|
| 41 |
+
def get_character(self, character_id: str) -> dict[str, object] | None:
|
| 42 |
+
"""Get character data from cache."""
|
| 43 |
+
...
|
| 44 |
+
|
| 45 |
+
def update_character_cache(
|
| 46 |
+
self,
|
| 47 |
+
character_id: str,
|
| 48 |
+
data: dict[str, object],
|
| 49 |
+
) -> None:
|
| 50 |
+
"""Update character data in cache."""
|
| 51 |
+
...
|
| 52 |
+
|
| 53 |
+
def set_combat_state(self, combat_state: dict[str, object] | None) -> None:
|
| 54 |
+
"""Set or clear combat state."""
|
| 55 |
+
...
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
@dataclass
|
| 59 |
+
class GameState:
|
| 60 |
+
"""
|
| 61 |
+
Minimal game state stub for Phase 1.
|
| 62 |
+
|
| 63 |
+
Provides the basic interface that MCP tool wrappers need.
|
| 64 |
+
Phase 4 will extend this with:
|
| 65 |
+
- Full Pydantic models (GameState, CombatState, etc.)
|
| 66 |
+
- GameStateManager class
|
| 67 |
+
- State persistence (save/load)
|
| 68 |
+
- Story context building
|
| 69 |
+
"""
|
| 70 |
+
|
| 71 |
+
# Core state
|
| 72 |
+
session_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
| 73 |
+
started_at: datetime = field(default_factory=datetime.now)
|
| 74 |
+
system: str = "dnd5e"
|
| 75 |
+
|
| 76 |
+
# Party management
|
| 77 |
+
party: list[str] = field(default_factory=list)
|
| 78 |
+
active_character_id: str | None = None
|
| 79 |
+
|
| 80 |
+
# Location
|
| 81 |
+
current_location: str = "Unknown"
|
| 82 |
+
current_scene: dict[str, object] = field(default_factory=dict)
|
| 83 |
+
|
| 84 |
+
# Combat
|
| 85 |
+
in_combat: bool = False
|
| 86 |
+
_combat_state: dict[str, object] | None = field(default=None, repr=False)
|
| 87 |
+
|
| 88 |
+
# Events
|
| 89 |
+
recent_events: list[dict[str, object]] = field(default_factory=list)
|
| 90 |
+
turn_count: int = 0
|
| 91 |
+
|
| 92 |
+
# Caches
|
| 93 |
+
_character_cache: dict[str, dict[str, object]] = field(
|
| 94 |
+
default_factory=dict,
|
| 95 |
+
repr=False,
|
| 96 |
+
)
|
| 97 |
+
_known_npcs: dict[str, dict[str, object]] = field(
|
| 98 |
+
default_factory=dict,
|
| 99 |
+
repr=False,
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
# Story tracking
|
| 103 |
+
story_flags: dict[str, object] = field(default_factory=dict)
|
| 104 |
+
current_adventure: str | None = None
|
| 105 |
+
|
| 106 |
+
# Metadata
|
| 107 |
+
last_updated: datetime = field(default_factory=datetime.now)
|
| 108 |
+
|
| 109 |
+
# Configuration
|
| 110 |
+
max_recent_events: int = field(default=20, repr=False)
|
| 111 |
+
|
| 112 |
+
def add_event(
|
| 113 |
+
self,
|
| 114 |
+
event_type: str,
|
| 115 |
+
description: str,
|
| 116 |
+
data: dict[str, object],
|
| 117 |
+
) -> None:
|
| 118 |
+
"""
|
| 119 |
+
Add an event to recent events list.
|
| 120 |
+
|
| 121 |
+
Keeps only the most recent events (default: 20).
|
| 122 |
+
|
| 123 |
+
Args:
|
| 124 |
+
event_type: Type of event (roll, combat, dialogue, etc.)
|
| 125 |
+
description: Human-readable description
|
| 126 |
+
data: Event-specific data
|
| 127 |
+
"""
|
| 128 |
+
event = {
|
| 129 |
+
"type": event_type,
|
| 130 |
+
"description": description,
|
| 131 |
+
"data": data,
|
| 132 |
+
"timestamp": datetime.now().isoformat(),
|
| 133 |
+
"turn": self.turn_count,
|
| 134 |
+
}
|
| 135 |
+
self.recent_events.append(event)
|
| 136 |
+
|
| 137 |
+
# Trim to max size
|
| 138 |
+
if len(self.recent_events) > self.max_recent_events:
|
| 139 |
+
self.recent_events = self.recent_events[-self.max_recent_events :]
|
| 140 |
+
|
| 141 |
+
self.last_updated = datetime.now()
|
| 142 |
+
|
| 143 |
+
def get_character(self, character_id: str) -> dict[str, object] | None:
|
| 144 |
+
"""
|
| 145 |
+
Get character data from cache.
|
| 146 |
+
|
| 147 |
+
Args:
|
| 148 |
+
character_id: Character ID to look up
|
| 149 |
+
|
| 150 |
+
Returns:
|
| 151 |
+
Character data dict or None if not cached
|
| 152 |
+
"""
|
| 153 |
+
return self._character_cache.get(character_id)
|
| 154 |
+
|
| 155 |
+
def update_character_cache(
|
| 156 |
+
self,
|
| 157 |
+
character_id: str,
|
| 158 |
+
data: dict[str, object],
|
| 159 |
+
) -> None:
|
| 160 |
+
"""
|
| 161 |
+
Update character data in cache.
|
| 162 |
+
|
| 163 |
+
Args:
|
| 164 |
+
character_id: Character ID
|
| 165 |
+
data: Character data to cache
|
| 166 |
+
"""
|
| 167 |
+
self._character_cache[character_id] = data
|
| 168 |
+
self.last_updated = datetime.now()
|
| 169 |
+
|
| 170 |
+
def set_combat_state(self, combat_state: dict[str, object] | None) -> None:
|
| 171 |
+
"""
|
| 172 |
+
Set or clear combat state.
|
| 173 |
+
|
| 174 |
+
Args:
|
| 175 |
+
combat_state: Combat state dict, or None to clear
|
| 176 |
+
"""
|
| 177 |
+
self._combat_state = combat_state
|
| 178 |
+
self.in_combat = combat_state is not None
|
| 179 |
+
self.last_updated = datetime.now()
|
| 180 |
+
|
| 181 |
+
# Log combat state change
|
| 182 |
+
if combat_state is not None:
|
| 183 |
+
self.add_event(
|
| 184 |
+
event_type="combat_start",
|
| 185 |
+
description="Combat has begun",
|
| 186 |
+
data={"combatants": combat_state.get("turn_order", [])},
|
| 187 |
+
)
|
| 188 |
+
else:
|
| 189 |
+
self.add_event(
|
| 190 |
+
event_type="combat_end",
|
| 191 |
+
description="Combat has ended",
|
| 192 |
+
data={},
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
@property
|
| 196 |
+
def combat_state(self) -> dict[str, object] | None:
|
| 197 |
+
"""Get current combat state."""
|
| 198 |
+
return self._combat_state
|
| 199 |
+
|
| 200 |
+
def add_character_to_party(self, character_id: str) -> None:
|
| 201 |
+
"""
|
| 202 |
+
Add a character to the party.
|
| 203 |
+
|
| 204 |
+
Args:
|
| 205 |
+
character_id: Character ID to add
|
| 206 |
+
"""
|
| 207 |
+
if character_id not in self.party:
|
| 208 |
+
self.party.append(character_id)
|
| 209 |
+
# Set as active if first character
|
| 210 |
+
if self.active_character_id is None:
|
| 211 |
+
self.active_character_id = character_id
|
| 212 |
+
self.last_updated = datetime.now()
|
| 213 |
+
|
| 214 |
+
def remove_character_from_party(self, character_id: str) -> None:
|
| 215 |
+
"""
|
| 216 |
+
Remove a character from the party.
|
| 217 |
+
|
| 218 |
+
Args:
|
| 219 |
+
character_id: Character ID to remove
|
| 220 |
+
"""
|
| 221 |
+
if character_id in self.party:
|
| 222 |
+
self.party.remove(character_id)
|
| 223 |
+
# Clear active if removed
|
| 224 |
+
if self.active_character_id == character_id:
|
| 225 |
+
self.active_character_id = self.party[0] if self.party else None
|
| 226 |
+
# Clear from cache
|
| 227 |
+
self._character_cache.pop(character_id, None)
|
| 228 |
+
self.last_updated = datetime.now()
|
| 229 |
+
|
| 230 |
+
def set_location(
|
| 231 |
+
self,
|
| 232 |
+
location: str,
|
| 233 |
+
scene: dict[str, object] | None = None,
|
| 234 |
+
) -> None:
|
| 235 |
+
"""
|
| 236 |
+
Update current location.
|
| 237 |
+
|
| 238 |
+
Args:
|
| 239 |
+
location: Location name/description
|
| 240 |
+
scene: Optional scene details
|
| 241 |
+
"""
|
| 242 |
+
self.current_location = location
|
| 243 |
+
if scene:
|
| 244 |
+
self.current_scene = scene
|
| 245 |
+
|
| 246 |
+
self.add_event(
|
| 247 |
+
event_type="movement",
|
| 248 |
+
description=f"Moved to {location}",
|
| 249 |
+
data={"location": location, "scene": scene or {}},
|
| 250 |
+
)
|
| 251 |
+
self.last_updated = datetime.now()
|
| 252 |
+
|
| 253 |
+
def increment_turn(self) -> int:
|
| 254 |
+
"""
|
| 255 |
+
Increment turn counter.
|
| 256 |
+
|
| 257 |
+
Returns:
|
| 258 |
+
New turn count
|
| 259 |
+
"""
|
| 260 |
+
self.turn_count += 1
|
| 261 |
+
self.last_updated = datetime.now()
|
| 262 |
+
return self.turn_count
|
| 263 |
+
|
| 264 |
+
def set_story_flag(self, flag: str, value: object) -> None:
|
| 265 |
+
"""
|
| 266 |
+
Set a story/quest flag.
|
| 267 |
+
|
| 268 |
+
Args:
|
| 269 |
+
flag: Flag name
|
| 270 |
+
value: Flag value
|
| 271 |
+
"""
|
| 272 |
+
self.story_flags[flag] = value
|
| 273 |
+
self.last_updated = datetime.now()
|
| 274 |
+
|
| 275 |
+
def get_story_flag(self, flag: str, default: object = None) -> object:
|
| 276 |
+
"""
|
| 277 |
+
Get a story/quest flag.
|
| 278 |
+
|
| 279 |
+
Args:
|
| 280 |
+
flag: Flag name
|
| 281 |
+
default: Default value if not set
|
| 282 |
+
|
| 283 |
+
Returns:
|
| 284 |
+
Flag value or default
|
| 285 |
+
"""
|
| 286 |
+
return self.story_flags.get(flag, default)
|
| 287 |
+
|
| 288 |
+
def add_known_npc(self, npc_id: str, data: dict[str, object]) -> None:
|
| 289 |
+
"""
|
| 290 |
+
Add or update a known NPC.
|
| 291 |
+
|
| 292 |
+
Args:
|
| 293 |
+
npc_id: NPC identifier
|
| 294 |
+
data: NPC data
|
| 295 |
+
"""
|
| 296 |
+
self._known_npcs[npc_id] = data
|
| 297 |
+
self.last_updated = datetime.now()
|
| 298 |
+
|
| 299 |
+
def get_known_npc(self, npc_id: str) -> dict[str, object] | None:
|
| 300 |
+
"""
|
| 301 |
+
Get known NPC data.
|
| 302 |
+
|
| 303 |
+
Args:
|
| 304 |
+
npc_id: NPC identifier
|
| 305 |
+
|
| 306 |
+
Returns:
|
| 307 |
+
NPC data or None
|
| 308 |
+
"""
|
| 309 |
+
return self._known_npcs.get(npc_id)
|
| 310 |
+
|
| 311 |
+
def get_recent_events_by_type(
|
| 312 |
+
self,
|
| 313 |
+
event_type: str,
|
| 314 |
+
limit: int = 10,
|
| 315 |
+
) -> list[dict[str, object]]:
|
| 316 |
+
"""
|
| 317 |
+
Get recent events filtered by type.
|
| 318 |
+
|
| 319 |
+
Args:
|
| 320 |
+
event_type: Event type to filter
|
| 321 |
+
limit: Maximum events to return
|
| 322 |
+
|
| 323 |
+
Returns:
|
| 324 |
+
List of matching events
|
| 325 |
+
"""
|
| 326 |
+
matching = [e for e in self.recent_events if e.get("type") == event_type]
|
| 327 |
+
return matching[-limit:]
|
| 328 |
+
|
| 329 |
+
def to_summary(self) -> dict[str, object]:
|
| 330 |
+
"""
|
| 331 |
+
Create a summary dict for LLM context.
|
| 332 |
+
|
| 333 |
+
Returns:
|
| 334 |
+
Summary dict with key state information
|
| 335 |
+
"""
|
| 336 |
+
return {
|
| 337 |
+
"session_id": self.session_id,
|
| 338 |
+
"system": self.system,
|
| 339 |
+
"turn_count": self.turn_count,
|
| 340 |
+
"party_size": len(self.party),
|
| 341 |
+
"active_character": self.active_character_id,
|
| 342 |
+
"location": self.current_location,
|
| 343 |
+
"in_combat": self.in_combat,
|
| 344 |
+
"combat_round": (
|
| 345 |
+
self._combat_state.get("round") if self._combat_state else None
|
| 346 |
+
),
|
| 347 |
+
"recent_events_count": len(self.recent_events),
|
| 348 |
+
"adventure": self.current_adventure,
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
def reset(self) -> None:
|
| 352 |
+
"""Reset game state for new game."""
|
| 353 |
+
self.session_id = str(uuid.uuid4())
|
| 354 |
+
self.started_at = datetime.now()
|
| 355 |
+
self.party.clear()
|
| 356 |
+
self.active_character_id = None
|
| 357 |
+
self.current_location = "Unknown"
|
| 358 |
+
self.current_scene.clear()
|
| 359 |
+
self.in_combat = False
|
| 360 |
+
self._combat_state = None
|
| 361 |
+
self.recent_events.clear()
|
| 362 |
+
self.turn_count = 0
|
| 363 |
+
self._character_cache.clear()
|
| 364 |
+
self._known_npcs.clear()
|
| 365 |
+
self.story_flags.clear()
|
| 366 |
+
self.current_adventure = None
|
| 367 |
+
self.last_updated = datetime.now()
|
src/game/game_state_manager.py
ADDED
|
@@ -0,0 +1,992 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DungeonMaster AI - Game State Manager
|
| 3 |
+
|
| 4 |
+
High-level manager for game state, integrating MCP tools,
|
| 5 |
+
character caching, combat tracking, and save/load functionality.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import json
|
| 11 |
+
import logging
|
| 12 |
+
import uuid
|
| 13 |
+
from datetime import datetime, timedelta
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
from typing import TYPE_CHECKING
|
| 16 |
+
|
| 17 |
+
from .event_logger import EventLogger
|
| 18 |
+
from .models import (
|
| 19 |
+
CharacterSnapshot,
|
| 20 |
+
CombatantStatus,
|
| 21 |
+
Combatant,
|
| 22 |
+
CombatState,
|
| 23 |
+
EventType,
|
| 24 |
+
GameSaveData,
|
| 25 |
+
NPCInfo,
|
| 26 |
+
SceneInfo,
|
| 27 |
+
SessionEvent,
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
if TYPE_CHECKING:
|
| 31 |
+
from src.mcp_integration.toolkit_client import TTRPGToolkitClient
|
| 32 |
+
|
| 33 |
+
logger = logging.getLogger(__name__)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class GameStateManager:
|
| 37 |
+
"""
|
| 38 |
+
High-level manager for game state.
|
| 39 |
+
|
| 40 |
+
Orchestrates MCP tool integration, character caching, combat state,
|
| 41 |
+
event logging, and save/load functionality. This is the main interface
|
| 42 |
+
for game state management used by the agent orchestrator.
|
| 43 |
+
"""
|
| 44 |
+
|
| 45 |
+
def __init__(
|
| 46 |
+
self,
|
| 47 |
+
toolkit_client: TTRPGToolkitClient | None = None,
|
| 48 |
+
max_recent_events: int = 50,
|
| 49 |
+
character_cache_ttl: int = 300, # 5 minutes
|
| 50 |
+
) -> None:
|
| 51 |
+
"""
|
| 52 |
+
Initialize the game state manager.
|
| 53 |
+
|
| 54 |
+
Args:
|
| 55 |
+
toolkit_client: Optional MCP toolkit client for remote operations
|
| 56 |
+
max_recent_events: Maximum events to keep in memory
|
| 57 |
+
character_cache_ttl: Character cache TTL in seconds
|
| 58 |
+
"""
|
| 59 |
+
self._toolkit_client = toolkit_client
|
| 60 |
+
self._max_recent_events = max_recent_events
|
| 61 |
+
self._character_cache_ttl = character_cache_ttl
|
| 62 |
+
|
| 63 |
+
# Session state
|
| 64 |
+
self._session_id = str(uuid.uuid4())
|
| 65 |
+
self._mcp_session_id: str | None = None
|
| 66 |
+
self._started_at = datetime.now()
|
| 67 |
+
self._turn_count = 0
|
| 68 |
+
|
| 69 |
+
# Party state
|
| 70 |
+
self._party_ids: list[str] = []
|
| 71 |
+
self._active_character_id: str | None = None
|
| 72 |
+
|
| 73 |
+
# Location state
|
| 74 |
+
self._current_location = "Unknown"
|
| 75 |
+
self._current_scene: SceneInfo | None = None
|
| 76 |
+
|
| 77 |
+
# Combat state
|
| 78 |
+
self._in_combat = False
|
| 79 |
+
self._combat_state: CombatState | None = None
|
| 80 |
+
|
| 81 |
+
# Tracking
|
| 82 |
+
self._story_flags: dict[str, object] = {}
|
| 83 |
+
self._known_npcs: dict[str, NPCInfo] = {}
|
| 84 |
+
|
| 85 |
+
# Caching
|
| 86 |
+
self._character_cache: dict[str, CharacterSnapshot] = {}
|
| 87 |
+
self._character_cache_times: dict[str, datetime] = {}
|
| 88 |
+
|
| 89 |
+
# Adventure
|
| 90 |
+
self._adventure_name: str | None = None
|
| 91 |
+
|
| 92 |
+
# Event logger
|
| 93 |
+
self._event_logger = EventLogger(
|
| 94 |
+
toolkit_client=toolkit_client,
|
| 95 |
+
max_events=max_recent_events,
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
logger.debug(f"GameStateManager initialized with session: {self._session_id}")
|
| 99 |
+
|
| 100 |
+
# =========================================================================
|
| 101 |
+
# Properties
|
| 102 |
+
# =========================================================================
|
| 103 |
+
|
| 104 |
+
@property
|
| 105 |
+
def session_id(self) -> str:
|
| 106 |
+
"""Get the current session ID."""
|
| 107 |
+
return self._session_id
|
| 108 |
+
|
| 109 |
+
@property
|
| 110 |
+
def mcp_session_id(self) -> str | None:
|
| 111 |
+
"""Get the MCP session ID if connected."""
|
| 112 |
+
return self._mcp_session_id
|
| 113 |
+
|
| 114 |
+
@property
|
| 115 |
+
def turn_count(self) -> int:
|
| 116 |
+
"""Get the current turn count."""
|
| 117 |
+
return self._turn_count
|
| 118 |
+
|
| 119 |
+
@property
|
| 120 |
+
def party_ids(self) -> list[str]:
|
| 121 |
+
"""Get list of character IDs in the party."""
|
| 122 |
+
return self._party_ids.copy()
|
| 123 |
+
|
| 124 |
+
@property
|
| 125 |
+
def active_character_id(self) -> str | None:
|
| 126 |
+
"""Get the active character ID."""
|
| 127 |
+
return self._active_character_id
|
| 128 |
+
|
| 129 |
+
@property
|
| 130 |
+
def current_location(self) -> str:
|
| 131 |
+
"""Get the current location name."""
|
| 132 |
+
return self._current_location
|
| 133 |
+
|
| 134 |
+
@property
|
| 135 |
+
def current_scene(self) -> SceneInfo | None:
|
| 136 |
+
"""Get the current scene info."""
|
| 137 |
+
return self._current_scene
|
| 138 |
+
|
| 139 |
+
@property
|
| 140 |
+
def in_combat(self) -> bool:
|
| 141 |
+
"""Check if combat is active."""
|
| 142 |
+
return self._in_combat
|
| 143 |
+
|
| 144 |
+
@property
|
| 145 |
+
def combat_state(self) -> CombatState | None:
|
| 146 |
+
"""Get the current combat state."""
|
| 147 |
+
return self._combat_state
|
| 148 |
+
|
| 149 |
+
@property
|
| 150 |
+
def adventure_name(self) -> str | None:
|
| 151 |
+
"""Get the loaded adventure name."""
|
| 152 |
+
return self._adventure_name
|
| 153 |
+
|
| 154 |
+
@property
|
| 155 |
+
def story_flags(self) -> dict[str, object]:
|
| 156 |
+
"""Get all story flags."""
|
| 157 |
+
return self._story_flags.copy()
|
| 158 |
+
|
| 159 |
+
@property
|
| 160 |
+
def event_logger(self) -> EventLogger:
|
| 161 |
+
"""Get the event logger."""
|
| 162 |
+
return self._event_logger
|
| 163 |
+
|
| 164 |
+
@property
|
| 165 |
+
def recent_events(self) -> list[SessionEvent]:
|
| 166 |
+
"""Get recent events from the logger."""
|
| 167 |
+
return self._event_logger.events
|
| 168 |
+
|
| 169 |
+
# =========================================================================
|
| 170 |
+
# Session Lifecycle
|
| 171 |
+
# =========================================================================
|
| 172 |
+
|
| 173 |
+
async def new_game(self, adventure: str | None = None) -> str:
|
| 174 |
+
"""
|
| 175 |
+
Start a new game session.
|
| 176 |
+
|
| 177 |
+
Resets all state and optionally starts an MCP session.
|
| 178 |
+
|
| 179 |
+
Args:
|
| 180 |
+
adventure: Optional adventure name
|
| 181 |
+
|
| 182 |
+
Returns:
|
| 183 |
+
The new session ID
|
| 184 |
+
"""
|
| 185 |
+
# Reset state
|
| 186 |
+
self._session_id = str(uuid.uuid4())
|
| 187 |
+
self._started_at = datetime.now()
|
| 188 |
+
self._turn_count = 0
|
| 189 |
+
self._party_ids.clear()
|
| 190 |
+
self._active_character_id = None
|
| 191 |
+
self._current_location = "Unknown"
|
| 192 |
+
self._current_scene = None
|
| 193 |
+
self._in_combat = False
|
| 194 |
+
self._combat_state = None
|
| 195 |
+
self._story_flags.clear()
|
| 196 |
+
self._known_npcs.clear()
|
| 197 |
+
self._character_cache.clear()
|
| 198 |
+
self._character_cache_times.clear()
|
| 199 |
+
self._adventure_name = adventure
|
| 200 |
+
|
| 201 |
+
# Clear event logger
|
| 202 |
+
self._event_logger.clear()
|
| 203 |
+
self._event_logger.set_current_turn(0)
|
| 204 |
+
|
| 205 |
+
# Start MCP session if connected
|
| 206 |
+
if self._toolkit_client and self._toolkit_client.is_connected:
|
| 207 |
+
try:
|
| 208 |
+
result = await self._toolkit_client.call_tool(
|
| 209 |
+
"mcp_start_session",
|
| 210 |
+
{
|
| 211 |
+
"campaign_name": adventure or "DungeonMaster AI Session",
|
| 212 |
+
"system": "dnd5e",
|
| 213 |
+
},
|
| 214 |
+
)
|
| 215 |
+
if isinstance(result, dict):
|
| 216 |
+
self._mcp_session_id = str(result.get("session_id", ""))
|
| 217 |
+
self._event_logger.set_mcp_session_id(self._mcp_session_id)
|
| 218 |
+
logger.info(f"Started MCP session: {self._mcp_session_id}")
|
| 219 |
+
except Exception as e:
|
| 220 |
+
logger.warning(f"Failed to start MCP session: {e}")
|
| 221 |
+
self._mcp_session_id = None
|
| 222 |
+
|
| 223 |
+
# Log system event
|
| 224 |
+
self._event_logger.log_system(
|
| 225 |
+
f"New game started" + (f": {adventure}" if adventure else ""),
|
| 226 |
+
{"adventure": adventure},
|
| 227 |
+
)
|
| 228 |
+
|
| 229 |
+
logger.info(f"New game started with session: {self._session_id}")
|
| 230 |
+
return self._session_id
|
| 231 |
+
|
| 232 |
+
async def end_game(self) -> None:
|
| 233 |
+
"""
|
| 234 |
+
End the current game session.
|
| 235 |
+
|
| 236 |
+
Ends the MCP session if connected and logs the event.
|
| 237 |
+
"""
|
| 238 |
+
# End MCP session if active
|
| 239 |
+
if self._toolkit_client and self._mcp_session_id:
|
| 240 |
+
try:
|
| 241 |
+
await self._toolkit_client.call_tool(
|
| 242 |
+
"mcp_end_session",
|
| 243 |
+
{"session_id": self._mcp_session_id},
|
| 244 |
+
)
|
| 245 |
+
logger.info(f"Ended MCP session: {self._mcp_session_id}")
|
| 246 |
+
except Exception as e:
|
| 247 |
+
logger.warning(f"Failed to end MCP session: {e}")
|
| 248 |
+
|
| 249 |
+
# Log event
|
| 250 |
+
self._event_logger.log_system(
|
| 251 |
+
"Game ended",
|
| 252 |
+
{"turns": self._turn_count, "duration_minutes": self._get_session_duration()},
|
| 253 |
+
)
|
| 254 |
+
|
| 255 |
+
self._mcp_session_id = None
|
| 256 |
+
|
| 257 |
+
def _get_session_duration(self) -> int:
|
| 258 |
+
"""Get session duration in minutes."""
|
| 259 |
+
delta = datetime.now() - self._started_at
|
| 260 |
+
return int(delta.total_seconds() / 60)
|
| 261 |
+
|
| 262 |
+
# =========================================================================
|
| 263 |
+
# Character Management
|
| 264 |
+
# =========================================================================
|
| 265 |
+
|
| 266 |
+
async def add_character(self, character_id: str) -> CharacterSnapshot | None:
|
| 267 |
+
"""
|
| 268 |
+
Add a character to the party.
|
| 269 |
+
|
| 270 |
+
Fetches character data from MCP and caches it.
|
| 271 |
+
|
| 272 |
+
Args:
|
| 273 |
+
character_id: ID of character to add
|
| 274 |
+
|
| 275 |
+
Returns:
|
| 276 |
+
CharacterSnapshot if successful, None otherwise
|
| 277 |
+
"""
|
| 278 |
+
# Check if already in party
|
| 279 |
+
if character_id in self._party_ids:
|
| 280 |
+
return self._character_cache.get(character_id)
|
| 281 |
+
|
| 282 |
+
# Fetch from MCP
|
| 283 |
+
snapshot = await self._fetch_character(character_id)
|
| 284 |
+
if snapshot is None:
|
| 285 |
+
logger.warning(f"Failed to fetch character: {character_id}")
|
| 286 |
+
return None
|
| 287 |
+
|
| 288 |
+
# Add to party
|
| 289 |
+
self._party_ids.append(character_id)
|
| 290 |
+
|
| 291 |
+
# Set as active if first character
|
| 292 |
+
if self._active_character_id is None:
|
| 293 |
+
self._active_character_id = character_id
|
| 294 |
+
|
| 295 |
+
# Log event
|
| 296 |
+
self._event_logger.log_system(
|
| 297 |
+
f"{snapshot.name} joined the party",
|
| 298 |
+
{"character_id": character_id, "name": snapshot.name},
|
| 299 |
+
)
|
| 300 |
+
|
| 301 |
+
logger.info(f"Added character to party: {snapshot.name} ({character_id})")
|
| 302 |
+
return snapshot
|
| 303 |
+
|
| 304 |
+
async def _fetch_character(self, character_id: str) -> CharacterSnapshot | None:
|
| 305 |
+
"""
|
| 306 |
+
Fetch character data from MCP and cache it.
|
| 307 |
+
|
| 308 |
+
Args:
|
| 309 |
+
character_id: Character ID to fetch
|
| 310 |
+
|
| 311 |
+
Returns:
|
| 312 |
+
CharacterSnapshot if successful, None otherwise
|
| 313 |
+
"""
|
| 314 |
+
if not self._toolkit_client or not self._toolkit_client.is_connected:
|
| 315 |
+
# Return cached if available
|
| 316 |
+
return self._character_cache.get(character_id)
|
| 317 |
+
|
| 318 |
+
try:
|
| 319 |
+
result = await self._toolkit_client.call_tool(
|
| 320 |
+
"mcp_get_character",
|
| 321 |
+
{"character_id": character_id},
|
| 322 |
+
)
|
| 323 |
+
|
| 324 |
+
if not isinstance(result, dict):
|
| 325 |
+
return None
|
| 326 |
+
|
| 327 |
+
if not result.get("success", False):
|
| 328 |
+
return None
|
| 329 |
+
|
| 330 |
+
snapshot = CharacterSnapshot.from_mcp_result(result)
|
| 331 |
+
|
| 332 |
+
# Cache the result
|
| 333 |
+
self._character_cache[character_id] = snapshot
|
| 334 |
+
self._character_cache_times[character_id] = datetime.now()
|
| 335 |
+
|
| 336 |
+
return snapshot
|
| 337 |
+
|
| 338 |
+
except Exception as e:
|
| 339 |
+
logger.warning(f"Failed to fetch character {character_id}: {e}")
|
| 340 |
+
return self._character_cache.get(character_id)
|
| 341 |
+
|
| 342 |
+
async def get_active_character(self) -> CharacterSnapshot | None:
|
| 343 |
+
"""
|
| 344 |
+
Get the active character's data.
|
| 345 |
+
|
| 346 |
+
Refreshes from MCP if cache is stale.
|
| 347 |
+
|
| 348 |
+
Returns:
|
| 349 |
+
CharacterSnapshot if available, None otherwise
|
| 350 |
+
"""
|
| 351 |
+
if self._active_character_id is None:
|
| 352 |
+
return None
|
| 353 |
+
|
| 354 |
+
# Check cache freshness
|
| 355 |
+
if self._is_cache_stale(self._active_character_id):
|
| 356 |
+
await self.refresh_character(self._active_character_id)
|
| 357 |
+
|
| 358 |
+
return self._character_cache.get(self._active_character_id)
|
| 359 |
+
|
| 360 |
+
def _is_cache_stale(self, character_id: str) -> bool:
|
| 361 |
+
"""Check if a character's cache is stale."""
|
| 362 |
+
cache_time = self._character_cache_times.get(character_id)
|
| 363 |
+
if cache_time is None:
|
| 364 |
+
return True
|
| 365 |
+
|
| 366 |
+
age = datetime.now() - cache_time
|
| 367 |
+
return age.total_seconds() > self._character_cache_ttl
|
| 368 |
+
|
| 369 |
+
async def refresh_character(
|
| 370 |
+
self,
|
| 371 |
+
character_id: str,
|
| 372 |
+
) -> CharacterSnapshot | None:
|
| 373 |
+
"""
|
| 374 |
+
Force refresh a character's data from MCP.
|
| 375 |
+
|
| 376 |
+
Args:
|
| 377 |
+
character_id: Character to refresh
|
| 378 |
+
|
| 379 |
+
Returns:
|
| 380 |
+
Updated CharacterSnapshot if successful
|
| 381 |
+
"""
|
| 382 |
+
return await self._fetch_character(character_id)
|
| 383 |
+
|
| 384 |
+
def set_active_character(self, character_id: str) -> bool:
|
| 385 |
+
"""
|
| 386 |
+
Set the active character.
|
| 387 |
+
|
| 388 |
+
Args:
|
| 389 |
+
character_id: Character ID to make active
|
| 390 |
+
|
| 391 |
+
Returns:
|
| 392 |
+
True if successful, False if not in party
|
| 393 |
+
"""
|
| 394 |
+
if character_id not in self._party_ids:
|
| 395 |
+
return False
|
| 396 |
+
|
| 397 |
+
self._active_character_id = character_id
|
| 398 |
+
return True
|
| 399 |
+
|
| 400 |
+
def remove_character(self, character_id: str) -> None:
|
| 401 |
+
"""
|
| 402 |
+
Remove a character from the party.
|
| 403 |
+
|
| 404 |
+
Args:
|
| 405 |
+
character_id: Character to remove
|
| 406 |
+
"""
|
| 407 |
+
if character_id in self._party_ids:
|
| 408 |
+
self._party_ids.remove(character_id)
|
| 409 |
+
|
| 410 |
+
# Clear active if removed
|
| 411 |
+
if self._active_character_id == character_id:
|
| 412 |
+
self._active_character_id = (
|
| 413 |
+
self._party_ids[0] if self._party_ids else None
|
| 414 |
+
)
|
| 415 |
+
|
| 416 |
+
# Clear from cache
|
| 417 |
+
self._character_cache.pop(character_id, None)
|
| 418 |
+
self._character_cache_times.pop(character_id, None)
|
| 419 |
+
|
| 420 |
+
def get_character_snapshot(
|
| 421 |
+
self,
|
| 422 |
+
character_id: str,
|
| 423 |
+
) -> CharacterSnapshot | None:
|
| 424 |
+
"""
|
| 425 |
+
Get a character's cached snapshot.
|
| 426 |
+
|
| 427 |
+
Args:
|
| 428 |
+
character_id: Character ID
|
| 429 |
+
|
| 430 |
+
Returns:
|
| 431 |
+
CharacterSnapshot if cached, None otherwise
|
| 432 |
+
"""
|
| 433 |
+
return self._character_cache.get(character_id)
|
| 434 |
+
|
| 435 |
+
def get_party_snapshots(self) -> list[CharacterSnapshot]:
|
| 436 |
+
"""
|
| 437 |
+
Get all party members' cached snapshots.
|
| 438 |
+
|
| 439 |
+
Returns:
|
| 440 |
+
List of CharacterSnapshot objects
|
| 441 |
+
"""
|
| 442 |
+
return [
|
| 443 |
+
self._character_cache[cid]
|
| 444 |
+
for cid in self._party_ids
|
| 445 |
+
if cid in self._character_cache
|
| 446 |
+
]
|
| 447 |
+
|
| 448 |
+
# =========================================================================
|
| 449 |
+
# Tool Result Processing
|
| 450 |
+
# =========================================================================
|
| 451 |
+
|
| 452 |
+
async def update_from_tool_calls(
|
| 453 |
+
self,
|
| 454 |
+
tool_results: list[dict[str, object]],
|
| 455 |
+
) -> None:
|
| 456 |
+
"""
|
| 457 |
+
Update state based on MCP tool call results.
|
| 458 |
+
|
| 459 |
+
Args:
|
| 460 |
+
tool_results: List of {tool_name, result} dicts
|
| 461 |
+
"""
|
| 462 |
+
for entry in tool_results:
|
| 463 |
+
tool_name = str(entry.get("tool_name", ""))
|
| 464 |
+
result = entry.get("result", {})
|
| 465 |
+
|
| 466 |
+
if not isinstance(result, dict):
|
| 467 |
+
continue
|
| 468 |
+
|
| 469 |
+
# Dispatch to appropriate handler
|
| 470 |
+
if "modify_hp" in tool_name:
|
| 471 |
+
await self._process_hp_change(result)
|
| 472 |
+
elif "start_combat" in tool_name:
|
| 473 |
+
await self._process_combat_start(result)
|
| 474 |
+
elif "end_combat" in tool_name:
|
| 475 |
+
await self._process_combat_end(result)
|
| 476 |
+
elif "next_turn" in tool_name:
|
| 477 |
+
await self._process_next_turn(result)
|
| 478 |
+
elif "add_condition" in tool_name:
|
| 479 |
+
await self._process_condition_change(result, added=True)
|
| 480 |
+
elif "remove_condition" in tool_name:
|
| 481 |
+
await self._process_condition_change(result, added=False)
|
| 482 |
+
elif "rest" in tool_name:
|
| 483 |
+
await self._process_rest(result)
|
| 484 |
+
|
| 485 |
+
async def _process_hp_change(self, result: dict[str, object]) -> None:
|
| 486 |
+
"""Process HP modification result."""
|
| 487 |
+
character_id = str(result.get("character_id", ""))
|
| 488 |
+
if not character_id:
|
| 489 |
+
return
|
| 490 |
+
|
| 491 |
+
# Update cache
|
| 492 |
+
new_hp = int(result.get("new_hp", result.get("current_hp", 0)))
|
| 493 |
+
max_hp = int(result.get("max_hp", 1))
|
| 494 |
+
previous_hp = int(result.get("previous_hp", 0))
|
| 495 |
+
|
| 496 |
+
if character_id in self._character_cache:
|
| 497 |
+
snapshot = self._character_cache[character_id]
|
| 498 |
+
updated_data = snapshot.model_dump()
|
| 499 |
+
updated_data["hp_current"] = new_hp
|
| 500 |
+
updated_data["hp_max"] = max_hp
|
| 501 |
+
updated_data["cached_at"] = datetime.now()
|
| 502 |
+
self._character_cache[character_id] = CharacterSnapshot.model_validate(
|
| 503 |
+
updated_data
|
| 504 |
+
)
|
| 505 |
+
|
| 506 |
+
# Check for death
|
| 507 |
+
if new_hp <= 0 and previous_hp > 0:
|
| 508 |
+
name = str(result.get("name", "Character"))
|
| 509 |
+
is_damage = result.get("is_damage", True)
|
| 510 |
+
if is_damage:
|
| 511 |
+
self._event_logger.log_damage(
|
| 512 |
+
character_name=name,
|
| 513 |
+
amount=previous_hp - new_hp,
|
| 514 |
+
damage_type=str(result.get("damage_type", "untyped")),
|
| 515 |
+
source="unknown",
|
| 516 |
+
is_lethal=True,
|
| 517 |
+
)
|
| 518 |
+
|
| 519 |
+
# Update combat state if applicable
|
| 520 |
+
if self._combat_state:
|
| 521 |
+
combatant = self._combat_state.get_combatant(character_id)
|
| 522 |
+
if combatant:
|
| 523 |
+
self._combat_state.update_combatant(
|
| 524 |
+
character_id,
|
| 525 |
+
hp_current=new_hp,
|
| 526 |
+
status=CombatantStatus.UNCONSCIOUS,
|
| 527 |
+
)
|
| 528 |
+
|
| 529 |
+
async def _process_combat_start(self, result: dict[str, object]) -> None:
|
| 530 |
+
"""Process combat start result."""
|
| 531 |
+
self._in_combat = True
|
| 532 |
+
|
| 533 |
+
# Build combatant list
|
| 534 |
+
combatants: list[Combatant] = []
|
| 535 |
+
turn_order = result.get("turn_order", [])
|
| 536 |
+
|
| 537 |
+
if isinstance(turn_order, list):
|
| 538 |
+
for i, entry in enumerate(turn_order):
|
| 539 |
+
if isinstance(entry, dict):
|
| 540 |
+
combatants.append(
|
| 541 |
+
Combatant(
|
| 542 |
+
combatant_id=str(entry.get("id", str(uuid.uuid4()))),
|
| 543 |
+
name=str(entry.get("name", f"Combatant {i + 1}")),
|
| 544 |
+
initiative=int(entry.get("initiative", 0)),
|
| 545 |
+
is_player=bool(entry.get("is_player", False)),
|
| 546 |
+
hp_current=int(entry.get("hp_current", 10)),
|
| 547 |
+
hp_max=int(entry.get("hp_max", 10)),
|
| 548 |
+
armor_class=int(entry.get("ac", 10)),
|
| 549 |
+
conditions=list(entry.get("conditions", [])),
|
| 550 |
+
status=CombatantStatus.ACTIVE,
|
| 551 |
+
)
|
| 552 |
+
)
|
| 553 |
+
|
| 554 |
+
self._combat_state = CombatState(
|
| 555 |
+
combat_id=str(result.get("combat_id", str(uuid.uuid4()))),
|
| 556 |
+
round_number=1,
|
| 557 |
+
turn_index=0,
|
| 558 |
+
combatants=combatants,
|
| 559 |
+
started_at=datetime.now(),
|
| 560 |
+
)
|
| 561 |
+
|
| 562 |
+
# Log event
|
| 563 |
+
self._event_logger.log_combat_start(
|
| 564 |
+
description=str(result.get("description", "Combat began!")),
|
| 565 |
+
combatants=[c.name for c in combatants],
|
| 566 |
+
)
|
| 567 |
+
|
| 568 |
+
async def _process_combat_end(self, result: dict[str, object]) -> None:
|
| 569 |
+
"""Process combat end result."""
|
| 570 |
+
outcome = str(result.get("outcome", "victory"))
|
| 571 |
+
|
| 572 |
+
self._event_logger.log_combat_end(
|
| 573 |
+
outcome=outcome,
|
| 574 |
+
description=str(result.get("description", "")),
|
| 575 |
+
)
|
| 576 |
+
|
| 577 |
+
self._in_combat = False
|
| 578 |
+
self._combat_state = None
|
| 579 |
+
|
| 580 |
+
async def _process_next_turn(self, result: dict[str, object]) -> None:
|
| 581 |
+
"""Process next turn result."""
|
| 582 |
+
if not self._combat_state:
|
| 583 |
+
return
|
| 584 |
+
|
| 585 |
+
# Advance turn
|
| 586 |
+
new_combatant = self._combat_state.advance_turn()
|
| 587 |
+
|
| 588 |
+
if new_combatant:
|
| 589 |
+
# Update from result if provided
|
| 590 |
+
turn_index = result.get("turn_index")
|
| 591 |
+
if isinstance(turn_index, int):
|
| 592 |
+
self._combat_state.turn_index = turn_index
|
| 593 |
+
|
| 594 |
+
round_number = result.get("round_number")
|
| 595 |
+
if isinstance(round_number, int):
|
| 596 |
+
self._combat_state.round_number = round_number
|
| 597 |
+
|
| 598 |
+
async def _process_condition_change(
|
| 599 |
+
self,
|
| 600 |
+
result: dict[str, object],
|
| 601 |
+
added: bool,
|
| 602 |
+
) -> None:
|
| 603 |
+
"""Process condition add/remove result."""
|
| 604 |
+
character_id = str(result.get("character_id", ""))
|
| 605 |
+
condition = str(result.get("condition", ""))
|
| 606 |
+
|
| 607 |
+
if not character_id or not condition:
|
| 608 |
+
return
|
| 609 |
+
|
| 610 |
+
# Update character cache
|
| 611 |
+
if character_id in self._character_cache:
|
| 612 |
+
snapshot = self._character_cache[character_id]
|
| 613 |
+
conditions = snapshot.conditions.copy()
|
| 614 |
+
|
| 615 |
+
if added and condition not in conditions:
|
| 616 |
+
conditions.append(condition)
|
| 617 |
+
elif not added and condition in conditions:
|
| 618 |
+
conditions.remove(condition)
|
| 619 |
+
|
| 620 |
+
updated_data = snapshot.model_dump()
|
| 621 |
+
updated_data["conditions"] = conditions
|
| 622 |
+
updated_data["cached_at"] = datetime.now()
|
| 623 |
+
self._character_cache[character_id] = CharacterSnapshot.model_validate(
|
| 624 |
+
updated_data
|
| 625 |
+
)
|
| 626 |
+
|
| 627 |
+
# Update combat state if applicable
|
| 628 |
+
if self._combat_state:
|
| 629 |
+
combatant = self._combat_state.get_combatant(character_id)
|
| 630 |
+
if combatant:
|
| 631 |
+
conditions = combatant.conditions.copy()
|
| 632 |
+
if added and condition not in conditions:
|
| 633 |
+
conditions.append(condition)
|
| 634 |
+
elif not added and condition in conditions:
|
| 635 |
+
conditions.remove(condition)
|
| 636 |
+
self._combat_state.update_combatant(
|
| 637 |
+
character_id, conditions=conditions
|
| 638 |
+
)
|
| 639 |
+
|
| 640 |
+
async def _process_rest(self, result: dict[str, object]) -> None:
|
| 641 |
+
"""Process rest result."""
|
| 642 |
+
character_id = str(result.get("character_id", ""))
|
| 643 |
+
rest_type = str(result.get("rest_type", "short"))
|
| 644 |
+
hp_recovered = int(result.get("hp_recovered", 0))
|
| 645 |
+
|
| 646 |
+
# Refresh character from MCP
|
| 647 |
+
if character_id:
|
| 648 |
+
await self.refresh_character(character_id)
|
| 649 |
+
|
| 650 |
+
# Log event
|
| 651 |
+
name = str(result.get("name", "Character"))
|
| 652 |
+
self._event_logger.log_rest(
|
| 653 |
+
rest_type=rest_type,
|
| 654 |
+
character_name=name,
|
| 655 |
+
hp_recovered=hp_recovered,
|
| 656 |
+
)
|
| 657 |
+
|
| 658 |
+
# =========================================================================
|
| 659 |
+
# Event Management
|
| 660 |
+
# =========================================================================
|
| 661 |
+
|
| 662 |
+
def add_event(
|
| 663 |
+
self,
|
| 664 |
+
event_type: EventType,
|
| 665 |
+
description: str,
|
| 666 |
+
data: dict[str, object] | None = None,
|
| 667 |
+
is_significant: bool = False,
|
| 668 |
+
) -> SessionEvent:
|
| 669 |
+
"""
|
| 670 |
+
Add a game event.
|
| 671 |
+
|
| 672 |
+
Args:
|
| 673 |
+
event_type: Type of event
|
| 674 |
+
description: Human-readable description
|
| 675 |
+
data: Event-specific data
|
| 676 |
+
is_significant: Whether event is significant
|
| 677 |
+
|
| 678 |
+
Returns:
|
| 679 |
+
Created SessionEvent
|
| 680 |
+
"""
|
| 681 |
+
return self._event_logger._create_event(
|
| 682 |
+
event_type=event_type,
|
| 683 |
+
description=description,
|
| 684 |
+
data=data,
|
| 685 |
+
is_significant=is_significant,
|
| 686 |
+
)
|
| 687 |
+
|
| 688 |
+
# =========================================================================
|
| 689 |
+
# Location Management
|
| 690 |
+
# =========================================================================
|
| 691 |
+
|
| 692 |
+
def set_location(
|
| 693 |
+
self,
|
| 694 |
+
location: str,
|
| 695 |
+
scene: SceneInfo | None = None,
|
| 696 |
+
) -> None:
|
| 697 |
+
"""
|
| 698 |
+
Update the current location.
|
| 699 |
+
|
| 700 |
+
Args:
|
| 701 |
+
location: Location name
|
| 702 |
+
scene: Optional scene info
|
| 703 |
+
"""
|
| 704 |
+
old_location = self._current_location
|
| 705 |
+
self._current_location = location
|
| 706 |
+
self._current_scene = scene
|
| 707 |
+
|
| 708 |
+
# Log movement if location changed
|
| 709 |
+
if old_location != location and old_location != "Unknown":
|
| 710 |
+
self._event_logger.log_movement(old_location, location)
|
| 711 |
+
|
| 712 |
+
def add_known_npc(self, npc: NPCInfo) -> None:
|
| 713 |
+
"""
|
| 714 |
+
Add or update a known NPC.
|
| 715 |
+
|
| 716 |
+
Args:
|
| 717 |
+
npc: NPC info to add
|
| 718 |
+
"""
|
| 719 |
+
self._known_npcs[npc.npc_id] = npc
|
| 720 |
+
|
| 721 |
+
def get_npc(self, npc_id: str) -> NPCInfo | None:
|
| 722 |
+
"""
|
| 723 |
+
Get a known NPC by ID.
|
| 724 |
+
|
| 725 |
+
Args:
|
| 726 |
+
npc_id: NPC ID
|
| 727 |
+
|
| 728 |
+
Returns:
|
| 729 |
+
NPCInfo if found, None otherwise
|
| 730 |
+
"""
|
| 731 |
+
return self._known_npcs.get(npc_id)
|
| 732 |
+
|
| 733 |
+
def get_npcs_in_scene(self) -> list[NPCInfo]:
|
| 734 |
+
"""
|
| 735 |
+
Get NPCs present in the current scene.
|
| 736 |
+
|
| 737 |
+
Returns:
|
| 738 |
+
List of NPCInfo objects
|
| 739 |
+
"""
|
| 740 |
+
if not self._current_scene:
|
| 741 |
+
return []
|
| 742 |
+
|
| 743 |
+
return [
|
| 744 |
+
self._known_npcs[npc_id]
|
| 745 |
+
for npc_id in self._current_scene.npcs_present
|
| 746 |
+
if npc_id in self._known_npcs
|
| 747 |
+
]
|
| 748 |
+
|
| 749 |
+
def set_story_flag(self, flag: str, value: object) -> None:
|
| 750 |
+
"""
|
| 751 |
+
Set a story/quest flag.
|
| 752 |
+
|
| 753 |
+
Args:
|
| 754 |
+
flag: Flag name
|
| 755 |
+
value: Flag value
|
| 756 |
+
"""
|
| 757 |
+
self._story_flags[flag] = value
|
| 758 |
+
|
| 759 |
+
# Log significant flags
|
| 760 |
+
self._event_logger.log_story_flag(flag, value)
|
| 761 |
+
|
| 762 |
+
def get_story_flag(self, flag: str, default: object = None) -> object:
|
| 763 |
+
"""
|
| 764 |
+
Get a story/quest flag.
|
| 765 |
+
|
| 766 |
+
Args:
|
| 767 |
+
flag: Flag name
|
| 768 |
+
default: Default value
|
| 769 |
+
|
| 770 |
+
Returns:
|
| 771 |
+
Flag value or default
|
| 772 |
+
"""
|
| 773 |
+
return self._story_flags.get(flag, default)
|
| 774 |
+
|
| 775 |
+
# =========================================================================
|
| 776 |
+
# Turn Management
|
| 777 |
+
# =========================================================================
|
| 778 |
+
|
| 779 |
+
def increment_turn(self) -> int:
|
| 780 |
+
"""
|
| 781 |
+
Increment the turn counter.
|
| 782 |
+
|
| 783 |
+
Returns:
|
| 784 |
+
New turn count
|
| 785 |
+
"""
|
| 786 |
+
self._turn_count += 1
|
| 787 |
+
self._event_logger.set_current_turn(self._turn_count)
|
| 788 |
+
return self._turn_count
|
| 789 |
+
|
| 790 |
+
# =========================================================================
|
| 791 |
+
# Save/Load
|
| 792 |
+
# =========================================================================
|
| 793 |
+
|
| 794 |
+
async def save(
|
| 795 |
+
self,
|
| 796 |
+
file_path: Path | str | None = None,
|
| 797 |
+
conversation_history: list[dict[str, object]] | None = None,
|
| 798 |
+
) -> GameSaveData:
|
| 799 |
+
"""
|
| 800 |
+
Save the current game state.
|
| 801 |
+
|
| 802 |
+
Args:
|
| 803 |
+
file_path: Optional path to save JSON file
|
| 804 |
+
conversation_history: Optional chat history to include
|
| 805 |
+
|
| 806 |
+
Returns:
|
| 807 |
+
GameSaveData object
|
| 808 |
+
"""
|
| 809 |
+
# Refresh all character caches before saving
|
| 810 |
+
for character_id in self._party_ids:
|
| 811 |
+
await self.refresh_character(character_id)
|
| 812 |
+
|
| 813 |
+
# Build save data
|
| 814 |
+
save_data = GameSaveData(
|
| 815 |
+
version="1.0.0",
|
| 816 |
+
saved_at=datetime.now(),
|
| 817 |
+
session_id=self._session_id,
|
| 818 |
+
turn_count=self._turn_count,
|
| 819 |
+
party_ids=self._party_ids.copy(),
|
| 820 |
+
active_character_id=self._active_character_id,
|
| 821 |
+
character_snapshots=list(self._character_cache.values()),
|
| 822 |
+
current_location=self._current_location,
|
| 823 |
+
current_scene=self._current_scene,
|
| 824 |
+
in_combat=self._in_combat,
|
| 825 |
+
combat_state=self._combat_state,
|
| 826 |
+
story_flags=self._story_flags.copy(),
|
| 827 |
+
known_npcs=self._known_npcs.copy(),
|
| 828 |
+
recent_events=self._event_logger.events.copy(),
|
| 829 |
+
adventure_name=self._adventure_name,
|
| 830 |
+
conversation_history=conversation_history or [],
|
| 831 |
+
)
|
| 832 |
+
|
| 833 |
+
# Write to file if path provided
|
| 834 |
+
if file_path:
|
| 835 |
+
path = Path(file_path)
|
| 836 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 837 |
+
with open(path, "w", encoding="utf-8") as f:
|
| 838 |
+
f.write(save_data.model_dump_json(indent=2))
|
| 839 |
+
logger.info(f"Game saved to: {path}")
|
| 840 |
+
|
| 841 |
+
return save_data
|
| 842 |
+
|
| 843 |
+
async def load(self, file_path: Path | str) -> bool:
|
| 844 |
+
"""
|
| 845 |
+
Load a saved game.
|
| 846 |
+
|
| 847 |
+
Args:
|
| 848 |
+
file_path: Path to save file
|
| 849 |
+
|
| 850 |
+
Returns:
|
| 851 |
+
True if successful, False otherwise
|
| 852 |
+
"""
|
| 853 |
+
try:
|
| 854 |
+
path = Path(file_path)
|
| 855 |
+
with open(path, encoding="utf-8") as f:
|
| 856 |
+
data = json.load(f)
|
| 857 |
+
|
| 858 |
+
save_data = GameSaveData.model_validate(data)
|
| 859 |
+
|
| 860 |
+
# Restore state
|
| 861 |
+
self._session_id = save_data.session_id
|
| 862 |
+
self._turn_count = save_data.turn_count
|
| 863 |
+
self._party_ids = save_data.party_ids.copy()
|
| 864 |
+
self._active_character_id = save_data.active_character_id
|
| 865 |
+
self._current_location = save_data.current_location
|
| 866 |
+
self._current_scene = save_data.current_scene
|
| 867 |
+
self._in_combat = save_data.in_combat
|
| 868 |
+
self._combat_state = save_data.combat_state
|
| 869 |
+
self._story_flags = dict(save_data.story_flags)
|
| 870 |
+
self._known_npcs = dict(save_data.known_npcs)
|
| 871 |
+
self._adventure_name = save_data.adventure_name
|
| 872 |
+
|
| 873 |
+
# Restore character cache
|
| 874 |
+
self._character_cache.clear()
|
| 875 |
+
self._character_cache_times.clear()
|
| 876 |
+
for snapshot in save_data.character_snapshots:
|
| 877 |
+
self._character_cache[snapshot.character_id] = snapshot
|
| 878 |
+
self._character_cache_times[snapshot.character_id] = datetime.now()
|
| 879 |
+
|
| 880 |
+
# Restore events
|
| 881 |
+
self._event_logger.clear()
|
| 882 |
+
for event in save_data.recent_events:
|
| 883 |
+
self._event_logger._events.append(event)
|
| 884 |
+
self._event_logger.set_current_turn(self._turn_count)
|
| 885 |
+
|
| 886 |
+
# Start new MCP session for loaded game
|
| 887 |
+
if self._toolkit_client and self._toolkit_client.is_connected:
|
| 888 |
+
try:
|
| 889 |
+
result = await self._toolkit_client.call_tool(
|
| 890 |
+
"mcp_start_session",
|
| 891 |
+
{
|
| 892 |
+
"campaign_name": f"Loaded: {self._adventure_name or 'Session'}",
|
| 893 |
+
"system": "dnd5e",
|
| 894 |
+
},
|
| 895 |
+
)
|
| 896 |
+
if isinstance(result, dict):
|
| 897 |
+
self._mcp_session_id = str(result.get("session_id", ""))
|
| 898 |
+
self._event_logger.set_mcp_session_id(self._mcp_session_id)
|
| 899 |
+
except Exception as e:
|
| 900 |
+
logger.warning(f"Failed to start MCP session for loaded game: {e}")
|
| 901 |
+
|
| 902 |
+
# Log event
|
| 903 |
+
self._event_logger.log_system(
|
| 904 |
+
"Game loaded",
|
| 905 |
+
{"loaded_from": str(path)},
|
| 906 |
+
)
|
| 907 |
+
|
| 908 |
+
logger.info(f"Game loaded from: {path}")
|
| 909 |
+
return True
|
| 910 |
+
|
| 911 |
+
except Exception as e:
|
| 912 |
+
logger.error(f"Failed to load game: {e}")
|
| 913 |
+
return False
|
| 914 |
+
|
| 915 |
+
def export_for_download(
|
| 916 |
+
self,
|
| 917 |
+
conversation_history: list[dict[str, object]] | None = None,
|
| 918 |
+
) -> str:
|
| 919 |
+
"""
|
| 920 |
+
Export game state as JSON string for browser download.
|
| 921 |
+
|
| 922 |
+
Args:
|
| 923 |
+
conversation_history: Optional chat history to include
|
| 924 |
+
|
| 925 |
+
Returns:
|
| 926 |
+
JSON string
|
| 927 |
+
"""
|
| 928 |
+
save_data = GameSaveData(
|
| 929 |
+
version="1.0.0",
|
| 930 |
+
saved_at=datetime.now(),
|
| 931 |
+
session_id=self._session_id,
|
| 932 |
+
turn_count=self._turn_count,
|
| 933 |
+
party_ids=self._party_ids.copy(),
|
| 934 |
+
active_character_id=self._active_character_id,
|
| 935 |
+
character_snapshots=list(self._character_cache.values()),
|
| 936 |
+
current_location=self._current_location,
|
| 937 |
+
current_scene=self._current_scene,
|
| 938 |
+
in_combat=self._in_combat,
|
| 939 |
+
combat_state=self._combat_state,
|
| 940 |
+
story_flags=self._story_flags.copy(),
|
| 941 |
+
known_npcs=self._known_npcs.copy(),
|
| 942 |
+
recent_events=self._event_logger.events.copy(),
|
| 943 |
+
adventure_name=self._adventure_name,
|
| 944 |
+
conversation_history=conversation_history or [],
|
| 945 |
+
)
|
| 946 |
+
|
| 947 |
+
return save_data.model_dump_json(indent=2)
|
| 948 |
+
|
| 949 |
+
# =========================================================================
|
| 950 |
+
# Utilities
|
| 951 |
+
# =========================================================================
|
| 952 |
+
|
| 953 |
+
def set_toolkit_client(self, client: TTRPGToolkitClient | None) -> None:
|
| 954 |
+
"""
|
| 955 |
+
Set or update the toolkit client.
|
| 956 |
+
|
| 957 |
+
Args:
|
| 958 |
+
client: New toolkit client
|
| 959 |
+
"""
|
| 960 |
+
self._toolkit_client = client
|
| 961 |
+
self._event_logger.set_toolkit_client(client)
|
| 962 |
+
|
| 963 |
+
def to_summary(self) -> dict[str, object]:
|
| 964 |
+
"""
|
| 965 |
+
Create a summary dict for quick state overview.
|
| 966 |
+
|
| 967 |
+
Returns:
|
| 968 |
+
Summary dict
|
| 969 |
+
"""
|
| 970 |
+
return {
|
| 971 |
+
"session_id": self._session_id,
|
| 972 |
+
"turn_count": self._turn_count,
|
| 973 |
+
"party_size": len(self._party_ids),
|
| 974 |
+
"active_character": self._active_character_id,
|
| 975 |
+
"location": self._current_location,
|
| 976 |
+
"in_combat": self._in_combat,
|
| 977 |
+
"combat_round": self._combat_state.round_number
|
| 978 |
+
if self._combat_state
|
| 979 |
+
else None,
|
| 980 |
+
"adventure": self._adventure_name,
|
| 981 |
+
"event_count": len(self._event_logger),
|
| 982 |
+
}
|
| 983 |
+
|
| 984 |
+
def __repr__(self) -> str:
|
| 985 |
+
"""String representation."""
|
| 986 |
+
return (
|
| 987 |
+
f"GameStateManager("
|
| 988 |
+
f"session={self._session_id[:8]}..., "
|
| 989 |
+
f"turn={self._turn_count}, "
|
| 990 |
+
f"party={len(self._party_ids)}, "
|
| 991 |
+
f"combat={self._in_combat})"
|
| 992 |
+
)
|
src/game/models.py
ADDED
|
@@ -0,0 +1,774 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DungeonMaster AI - Game State Models
|
| 3 |
+
|
| 4 |
+
Pydantic models for game entities including events, combat state,
|
| 5 |
+
characters, NPCs, scenes, and adventure data.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import uuid
|
| 11 |
+
from datetime import datetime
|
| 12 |
+
from enum import Enum
|
| 13 |
+
from typing import Optional
|
| 14 |
+
|
| 15 |
+
from pydantic import BaseModel, Field, computed_field
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
# =============================================================================
|
| 19 |
+
# Enums
|
| 20 |
+
# =============================================================================
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class EventType(str, Enum):
|
| 24 |
+
"""Types of game events for logging."""
|
| 25 |
+
|
| 26 |
+
ROLL = "roll"
|
| 27 |
+
COMBAT_START = "combat_start"
|
| 28 |
+
COMBAT_END = "combat_end"
|
| 29 |
+
COMBAT_ACTION = "combat_action"
|
| 30 |
+
DAMAGE = "damage"
|
| 31 |
+
HEALING = "healing"
|
| 32 |
+
MOVEMENT = "movement"
|
| 33 |
+
DIALOGUE = "dialogue"
|
| 34 |
+
DISCOVERY = "discovery"
|
| 35 |
+
ITEM_ACQUIRED = "item_acquired"
|
| 36 |
+
REST = "rest"
|
| 37 |
+
LEVEL_UP = "level_up"
|
| 38 |
+
DEATH = "death"
|
| 39 |
+
STORY_FLAG = "story_flag"
|
| 40 |
+
SYSTEM = "system"
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class CombatantStatus(str, Enum):
|
| 44 |
+
"""Status of a combatant in combat."""
|
| 45 |
+
|
| 46 |
+
ACTIVE = "active"
|
| 47 |
+
UNCONSCIOUS = "unconscious"
|
| 48 |
+
DEAD = "dead"
|
| 49 |
+
FLED = "fled"
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class HPStatus(str, Enum):
|
| 53 |
+
"""Health status based on HP percentage."""
|
| 54 |
+
|
| 55 |
+
HEALTHY = "healthy" # > 50%
|
| 56 |
+
WOUNDED = "wounded" # 25-50%
|
| 57 |
+
CRITICAL = "critical" # 1-25%
|
| 58 |
+
UNCONSCIOUS = "unconscious" # 0
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
# =============================================================================
|
| 62 |
+
# Event Models
|
| 63 |
+
# =============================================================================
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
class SessionEvent(BaseModel):
|
| 67 |
+
"""
|
| 68 |
+
A single event in the game session.
|
| 69 |
+
|
| 70 |
+
Events are logged for context building and session history.
|
| 71 |
+
"""
|
| 72 |
+
|
| 73 |
+
event_id: str = Field(
|
| 74 |
+
default_factory=lambda: str(uuid.uuid4()),
|
| 75 |
+
description="Unique event identifier",
|
| 76 |
+
)
|
| 77 |
+
event_type: EventType = Field(
|
| 78 |
+
description="Type of event",
|
| 79 |
+
)
|
| 80 |
+
description: str = Field(
|
| 81 |
+
description="Human-readable event description",
|
| 82 |
+
)
|
| 83 |
+
data: dict[str, object] = Field(
|
| 84 |
+
default_factory=dict,
|
| 85 |
+
description="Event-specific data",
|
| 86 |
+
)
|
| 87 |
+
timestamp: datetime = Field(
|
| 88 |
+
default_factory=datetime.now,
|
| 89 |
+
description="When the event occurred",
|
| 90 |
+
)
|
| 91 |
+
turn: int = Field(
|
| 92 |
+
default=0,
|
| 93 |
+
description="Game turn when event occurred",
|
| 94 |
+
)
|
| 95 |
+
is_significant: bool = Field(
|
| 96 |
+
default=False,
|
| 97 |
+
description="Whether this event is significant for context",
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
# =============================================================================
|
| 102 |
+
# Combat Models
|
| 103 |
+
# =============================================================================
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
class Combatant(BaseModel):
|
| 107 |
+
"""
|
| 108 |
+
A participant in combat.
|
| 109 |
+
|
| 110 |
+
Tracks initiative, HP, conditions, and status.
|
| 111 |
+
"""
|
| 112 |
+
|
| 113 |
+
combatant_id: str = Field(
|
| 114 |
+
description="Unique combatant identifier",
|
| 115 |
+
)
|
| 116 |
+
name: str = Field(
|
| 117 |
+
description="Combatant name",
|
| 118 |
+
)
|
| 119 |
+
initiative: int = Field(
|
| 120 |
+
description="Initiative roll result",
|
| 121 |
+
)
|
| 122 |
+
is_player: bool = Field(
|
| 123 |
+
default=False,
|
| 124 |
+
description="Whether this is a player character",
|
| 125 |
+
)
|
| 126 |
+
hp_current: int = Field(
|
| 127 |
+
default=0,
|
| 128 |
+
description="Current hit points",
|
| 129 |
+
)
|
| 130 |
+
hp_max: int = Field(
|
| 131 |
+
default=1,
|
| 132 |
+
description="Maximum hit points",
|
| 133 |
+
)
|
| 134 |
+
armor_class: int = Field(
|
| 135 |
+
default=10,
|
| 136 |
+
description="Armor class",
|
| 137 |
+
)
|
| 138 |
+
conditions: list[str] = Field(
|
| 139 |
+
default_factory=list,
|
| 140 |
+
description="Active conditions",
|
| 141 |
+
)
|
| 142 |
+
status: CombatantStatus = Field(
|
| 143 |
+
default=CombatantStatus.ACTIVE,
|
| 144 |
+
description="Combat status",
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
@computed_field
|
| 148 |
+
@property
|
| 149 |
+
def hp_percent(self) -> float:
|
| 150 |
+
"""HP as percentage of max."""
|
| 151 |
+
if self.hp_max <= 0:
|
| 152 |
+
return 0.0
|
| 153 |
+
return (self.hp_current / self.hp_max) * 100.0
|
| 154 |
+
|
| 155 |
+
@computed_field
|
| 156 |
+
@property
|
| 157 |
+
def hp_status(self) -> HPStatus:
|
| 158 |
+
"""Health status based on HP percentage."""
|
| 159 |
+
if self.hp_current <= 0:
|
| 160 |
+
return HPStatus.UNCONSCIOUS
|
| 161 |
+
pct = self.hp_percent
|
| 162 |
+
if pct > 50:
|
| 163 |
+
return HPStatus.HEALTHY
|
| 164 |
+
if pct > 25:
|
| 165 |
+
return HPStatus.WOUNDED
|
| 166 |
+
return HPStatus.CRITICAL
|
| 167 |
+
|
| 168 |
+
@computed_field
|
| 169 |
+
@property
|
| 170 |
+
def is_bloodied(self) -> bool:
|
| 171 |
+
"""Whether combatant is at 50% HP or below."""
|
| 172 |
+
return self.hp_percent <= 50.0
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
class CombatState(BaseModel):
|
| 176 |
+
"""
|
| 177 |
+
State of an active combat encounter.
|
| 178 |
+
|
| 179 |
+
Tracks round, turn order, and all combatants.
|
| 180 |
+
"""
|
| 181 |
+
|
| 182 |
+
combat_id: str = Field(
|
| 183 |
+
default_factory=lambda: str(uuid.uuid4()),
|
| 184 |
+
description="Unique combat identifier",
|
| 185 |
+
)
|
| 186 |
+
round_number: int = Field(
|
| 187 |
+
default=1,
|
| 188 |
+
description="Current combat round",
|
| 189 |
+
)
|
| 190 |
+
turn_index: int = Field(
|
| 191 |
+
default=0,
|
| 192 |
+
description="Index of current combatant in turn order",
|
| 193 |
+
)
|
| 194 |
+
combatants: list[Combatant] = Field(
|
| 195 |
+
default_factory=list,
|
| 196 |
+
description="All combatants in initiative order",
|
| 197 |
+
)
|
| 198 |
+
started_at: datetime = Field(
|
| 199 |
+
default_factory=datetime.now,
|
| 200 |
+
description="When combat started",
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
@computed_field
|
| 204 |
+
@property
|
| 205 |
+
def current_combatant(self) -> Optional[Combatant]:
|
| 206 |
+
"""Get the combatant whose turn it is."""
|
| 207 |
+
if not self.combatants or self.turn_index >= len(self.combatants):
|
| 208 |
+
return None
|
| 209 |
+
return self.combatants[self.turn_index]
|
| 210 |
+
|
| 211 |
+
@computed_field
|
| 212 |
+
@property
|
| 213 |
+
def turn_order(self) -> list[str]:
|
| 214 |
+
"""List of combatant names in initiative order."""
|
| 215 |
+
return [c.name for c in self.combatants]
|
| 216 |
+
|
| 217 |
+
@computed_field
|
| 218 |
+
@property
|
| 219 |
+
def active_combatants(self) -> list[Combatant]:
|
| 220 |
+
"""List of combatants still active in combat."""
|
| 221 |
+
return [c for c in self.combatants if c.status == CombatantStatus.ACTIVE]
|
| 222 |
+
|
| 223 |
+
@computed_field
|
| 224 |
+
@property
|
| 225 |
+
def is_player_turn(self) -> bool:
|
| 226 |
+
"""Whether it's currently a player's turn."""
|
| 227 |
+
current = self.current_combatant
|
| 228 |
+
return current.is_player if current else False
|
| 229 |
+
|
| 230 |
+
def advance_turn(self) -> Optional[Combatant]:
|
| 231 |
+
"""
|
| 232 |
+
Advance to the next turn, skipping inactive combatants.
|
| 233 |
+
|
| 234 |
+
Returns:
|
| 235 |
+
The new current combatant, or None if combat should end.
|
| 236 |
+
"""
|
| 237 |
+
if not self.active_combatants:
|
| 238 |
+
return None
|
| 239 |
+
|
| 240 |
+
# Find next active combatant
|
| 241 |
+
start_index = self.turn_index
|
| 242 |
+
attempts = 0
|
| 243 |
+
max_attempts = len(self.combatants)
|
| 244 |
+
|
| 245 |
+
while attempts < max_attempts:
|
| 246 |
+
self.turn_index = (self.turn_index + 1) % len(self.combatants)
|
| 247 |
+
|
| 248 |
+
# Check for new round
|
| 249 |
+
if self.turn_index == 0:
|
| 250 |
+
self.round_number += 1
|
| 251 |
+
|
| 252 |
+
current = self.combatants[self.turn_index]
|
| 253 |
+
if current.status == CombatantStatus.ACTIVE:
|
| 254 |
+
return current
|
| 255 |
+
|
| 256 |
+
attempts += 1
|
| 257 |
+
|
| 258 |
+
return None
|
| 259 |
+
|
| 260 |
+
def get_combatant(self, combatant_id: str) -> Optional[Combatant]:
|
| 261 |
+
"""Get a combatant by ID."""
|
| 262 |
+
for c in self.combatants:
|
| 263 |
+
if c.combatant_id == combatant_id:
|
| 264 |
+
return c
|
| 265 |
+
return None
|
| 266 |
+
|
| 267 |
+
def update_combatant(self, combatant_id: str, **updates: object) -> bool:
|
| 268 |
+
"""
|
| 269 |
+
Update a combatant's attributes.
|
| 270 |
+
|
| 271 |
+
Args:
|
| 272 |
+
combatant_id: ID of combatant to update
|
| 273 |
+
**updates: Attribute updates
|
| 274 |
+
|
| 275 |
+
Returns:
|
| 276 |
+
True if combatant was found and updated
|
| 277 |
+
"""
|
| 278 |
+
for i, c in enumerate(self.combatants):
|
| 279 |
+
if c.combatant_id == combatant_id:
|
| 280 |
+
updated_data = c.model_dump()
|
| 281 |
+
updated_data.update(updates)
|
| 282 |
+
self.combatants[i] = Combatant.model_validate(updated_data)
|
| 283 |
+
return True
|
| 284 |
+
return False
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
# =============================================================================
|
| 288 |
+
# Character Models
|
| 289 |
+
# =============================================================================
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
class CharacterSnapshot(BaseModel):
|
| 293 |
+
"""
|
| 294 |
+
Cached snapshot of character data from MCP.
|
| 295 |
+
|
| 296 |
+
Used for quick access without hitting MCP on every request.
|
| 297 |
+
"""
|
| 298 |
+
|
| 299 |
+
character_id: str = Field(
|
| 300 |
+
description="Character ID from MCP",
|
| 301 |
+
)
|
| 302 |
+
name: str = Field(
|
| 303 |
+
description="Character name",
|
| 304 |
+
)
|
| 305 |
+
race: str = Field(
|
| 306 |
+
default="Unknown",
|
| 307 |
+
description="Character race",
|
| 308 |
+
)
|
| 309 |
+
character_class: str = Field(
|
| 310 |
+
default="Unknown",
|
| 311 |
+
description="Character class",
|
| 312 |
+
)
|
| 313 |
+
level: int = Field(
|
| 314 |
+
default=1,
|
| 315 |
+
description="Character level",
|
| 316 |
+
)
|
| 317 |
+
hp_current: int = Field(
|
| 318 |
+
default=0,
|
| 319 |
+
description="Current hit points",
|
| 320 |
+
)
|
| 321 |
+
hp_max: int = Field(
|
| 322 |
+
default=1,
|
| 323 |
+
description="Maximum hit points",
|
| 324 |
+
)
|
| 325 |
+
armor_class: int = Field(
|
| 326 |
+
default=10,
|
| 327 |
+
description="Armor class",
|
| 328 |
+
)
|
| 329 |
+
initiative_bonus: int = Field(
|
| 330 |
+
default=0,
|
| 331 |
+
description="Initiative modifier",
|
| 332 |
+
)
|
| 333 |
+
speed: int = Field(
|
| 334 |
+
default=30,
|
| 335 |
+
description="Movement speed in feet",
|
| 336 |
+
)
|
| 337 |
+
conditions: list[str] = Field(
|
| 338 |
+
default_factory=list,
|
| 339 |
+
description="Active conditions",
|
| 340 |
+
)
|
| 341 |
+
ability_scores: dict[str, int] = Field(
|
| 342 |
+
default_factory=lambda: {
|
| 343 |
+
"strength": 10,
|
| 344 |
+
"dexterity": 10,
|
| 345 |
+
"constitution": 10,
|
| 346 |
+
"intelligence": 10,
|
| 347 |
+
"wisdom": 10,
|
| 348 |
+
"charisma": 10,
|
| 349 |
+
},
|
| 350 |
+
description="Ability scores",
|
| 351 |
+
)
|
| 352 |
+
proficiency_bonus: int = Field(
|
| 353 |
+
default=2,
|
| 354 |
+
description="Proficiency bonus",
|
| 355 |
+
)
|
| 356 |
+
cached_at: datetime = Field(
|
| 357 |
+
default_factory=datetime.now,
|
| 358 |
+
description="When this snapshot was created",
|
| 359 |
+
)
|
| 360 |
+
|
| 361 |
+
@computed_field
|
| 362 |
+
@property
|
| 363 |
+
def hp_percent(self) -> float:
|
| 364 |
+
"""HP as percentage of max."""
|
| 365 |
+
if self.hp_max <= 0:
|
| 366 |
+
return 0.0
|
| 367 |
+
return (self.hp_current / self.hp_max) * 100.0
|
| 368 |
+
|
| 369 |
+
@computed_field
|
| 370 |
+
@property
|
| 371 |
+
def hp_status(self) -> HPStatus:
|
| 372 |
+
"""Health status based on HP percentage."""
|
| 373 |
+
if self.hp_current <= 0:
|
| 374 |
+
return HPStatus.UNCONSCIOUS
|
| 375 |
+
pct = self.hp_percent
|
| 376 |
+
if pct > 50:
|
| 377 |
+
return HPStatus.HEALTHY
|
| 378 |
+
if pct > 25:
|
| 379 |
+
return HPStatus.WOUNDED
|
| 380 |
+
return HPStatus.CRITICAL
|
| 381 |
+
|
| 382 |
+
@computed_field
|
| 383 |
+
@property
|
| 384 |
+
def is_bloodied(self) -> bool:
|
| 385 |
+
"""Whether character is at 50% HP or below."""
|
| 386 |
+
return self.hp_percent <= 50.0
|
| 387 |
+
|
| 388 |
+
@classmethod
|
| 389 |
+
def from_mcp_result(cls, data: dict[str, object]) -> CharacterSnapshot:
|
| 390 |
+
"""
|
| 391 |
+
Create a CharacterSnapshot from MCP get_character result.
|
| 392 |
+
|
| 393 |
+
Args:
|
| 394 |
+
data: Raw result from mcp_get_character
|
| 395 |
+
|
| 396 |
+
Returns:
|
| 397 |
+
CharacterSnapshot instance
|
| 398 |
+
"""
|
| 399 |
+
# Handle nested character data
|
| 400 |
+
char_data = data.get("character", data)
|
| 401 |
+
|
| 402 |
+
# Extract ability scores
|
| 403 |
+
ability_scores = {}
|
| 404 |
+
raw_abilities = char_data.get("ability_scores", {})
|
| 405 |
+
if isinstance(raw_abilities, dict):
|
| 406 |
+
for ability in [
|
| 407 |
+
"strength",
|
| 408 |
+
"dexterity",
|
| 409 |
+
"constitution",
|
| 410 |
+
"intelligence",
|
| 411 |
+
"wisdom",
|
| 412 |
+
"charisma",
|
| 413 |
+
]:
|
| 414 |
+
ability_scores[ability] = int(raw_abilities.get(ability, 10))
|
| 415 |
+
else:
|
| 416 |
+
ability_scores = {
|
| 417 |
+
"strength": 10,
|
| 418 |
+
"dexterity": 10,
|
| 419 |
+
"constitution": 10,
|
| 420 |
+
"intelligence": 10,
|
| 421 |
+
"wisdom": 10,
|
| 422 |
+
"charisma": 10,
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
return cls(
|
| 426 |
+
character_id=str(char_data.get("id", "")),
|
| 427 |
+
name=str(char_data.get("name", "Unknown")),
|
| 428 |
+
race=str(char_data.get("race", "Unknown")),
|
| 429 |
+
character_class=str(char_data.get("character_class", "Unknown")),
|
| 430 |
+
level=int(char_data.get("level", 1)),
|
| 431 |
+
hp_current=int(char_data.get("current_hp", char_data.get("hp_current", 0))),
|
| 432 |
+
hp_max=int(char_data.get("max_hp", char_data.get("hp_max", 1))),
|
| 433 |
+
armor_class=int(char_data.get("armor_class", 10)),
|
| 434 |
+
initiative_bonus=int(char_data.get("initiative_bonus", 0)),
|
| 435 |
+
speed=int(char_data.get("speed", 30)),
|
| 436 |
+
conditions=list(char_data.get("conditions", [])),
|
| 437 |
+
ability_scores=ability_scores,
|
| 438 |
+
proficiency_bonus=int(char_data.get("proficiency_bonus", 2)),
|
| 439 |
+
)
|
| 440 |
+
|
| 441 |
+
|
| 442 |
+
# =============================================================================
|
| 443 |
+
# NPC and Scene Models
|
| 444 |
+
# =============================================================================
|
| 445 |
+
|
| 446 |
+
|
| 447 |
+
class NPCInfo(BaseModel):
|
| 448 |
+
"""
|
| 449 |
+
Information about an NPC.
|
| 450 |
+
|
| 451 |
+
Includes personality, voice, and dialogue hooks.
|
| 452 |
+
"""
|
| 453 |
+
|
| 454 |
+
npc_id: str = Field(
|
| 455 |
+
description="Unique NPC identifier",
|
| 456 |
+
)
|
| 457 |
+
name: str = Field(
|
| 458 |
+
description="NPC name",
|
| 459 |
+
)
|
| 460 |
+
description: str = Field(
|
| 461 |
+
default="",
|
| 462 |
+
description="Physical and role description",
|
| 463 |
+
)
|
| 464 |
+
personality: str = Field(
|
| 465 |
+
default="",
|
| 466 |
+
description="Personality traits",
|
| 467 |
+
)
|
| 468 |
+
voice_profile: str = Field(
|
| 469 |
+
default="dm",
|
| 470 |
+
description="Voice profile for TTS",
|
| 471 |
+
)
|
| 472 |
+
dialogue_hooks: list[str] = Field(
|
| 473 |
+
default_factory=list,
|
| 474 |
+
description="Sample dialogue lines",
|
| 475 |
+
)
|
| 476 |
+
monster_stat_block: Optional[str] = Field(
|
| 477 |
+
default=None,
|
| 478 |
+
description="Monster stat block name if applicable",
|
| 479 |
+
)
|
| 480 |
+
relationship: str = Field(
|
| 481 |
+
default="neutral",
|
| 482 |
+
description="Relationship to players (friendly, neutral, hostile)",
|
| 483 |
+
)
|
| 484 |
+
|
| 485 |
+
|
| 486 |
+
class SceneInfo(BaseModel):
|
| 487 |
+
"""
|
| 488 |
+
Information about a location/scene.
|
| 489 |
+
|
| 490 |
+
Includes description, sensory details, exits, and present NPCs.
|
| 491 |
+
"""
|
| 492 |
+
|
| 493 |
+
scene_id: str = Field(
|
| 494 |
+
description="Unique scene identifier",
|
| 495 |
+
)
|
| 496 |
+
name: str = Field(
|
| 497 |
+
description="Scene/location name",
|
| 498 |
+
)
|
| 499 |
+
description: str = Field(
|
| 500 |
+
default="",
|
| 501 |
+
description="Scene description",
|
| 502 |
+
)
|
| 503 |
+
sensory_details: dict[str, str] = Field(
|
| 504 |
+
default_factory=dict,
|
| 505 |
+
description="Sensory details (sight, sound, smell)",
|
| 506 |
+
)
|
| 507 |
+
exits: dict[str, str] = Field(
|
| 508 |
+
default_factory=dict,
|
| 509 |
+
description="Available exits (direction -> destination scene_id)",
|
| 510 |
+
)
|
| 511 |
+
npcs_present: list[str] = Field(
|
| 512 |
+
default_factory=list,
|
| 513 |
+
description="NPC IDs present in scene",
|
| 514 |
+
)
|
| 515 |
+
items: list[dict[str, object]] = Field(
|
| 516 |
+
default_factory=list,
|
| 517 |
+
description="Items in the scene",
|
| 518 |
+
)
|
| 519 |
+
encounter_id: Optional[str] = Field(
|
| 520 |
+
default=None,
|
| 521 |
+
description="Encounter ID if scene has combat",
|
| 522 |
+
)
|
| 523 |
+
searchable_objects: list[dict[str, object]] = Field(
|
| 524 |
+
default_factory=list,
|
| 525 |
+
description="Objects that can be searched/investigated",
|
| 526 |
+
)
|
| 527 |
+
|
| 528 |
+
|
| 529 |
+
# =============================================================================
|
| 530 |
+
# Save/Load Models
|
| 531 |
+
# =============================================================================
|
| 532 |
+
|
| 533 |
+
|
| 534 |
+
class GameSaveData(BaseModel):
|
| 535 |
+
"""
|
| 536 |
+
Complete game state for save/load.
|
| 537 |
+
|
| 538 |
+
Includes all state needed to restore a game session.
|
| 539 |
+
"""
|
| 540 |
+
|
| 541 |
+
version: str = Field(
|
| 542 |
+
default="1.0.0",
|
| 543 |
+
description="Save file version",
|
| 544 |
+
)
|
| 545 |
+
saved_at: datetime = Field(
|
| 546 |
+
default_factory=datetime.now,
|
| 547 |
+
description="When the game was saved",
|
| 548 |
+
)
|
| 549 |
+
session_id: str = Field(
|
| 550 |
+
description="Game session ID",
|
| 551 |
+
)
|
| 552 |
+
turn_count: int = Field(
|
| 553 |
+
default=0,
|
| 554 |
+
description="Current turn count",
|
| 555 |
+
)
|
| 556 |
+
party_ids: list[str] = Field(
|
| 557 |
+
default_factory=list,
|
| 558 |
+
description="Character IDs in party",
|
| 559 |
+
)
|
| 560 |
+
active_character_id: Optional[str] = Field(
|
| 561 |
+
default=None,
|
| 562 |
+
description="Currently active character ID",
|
| 563 |
+
)
|
| 564 |
+
character_snapshots: list[CharacterSnapshot] = Field(
|
| 565 |
+
default_factory=list,
|
| 566 |
+
description="Cached character data",
|
| 567 |
+
)
|
| 568 |
+
current_location: str = Field(
|
| 569 |
+
default="Unknown",
|
| 570 |
+
description="Current location name",
|
| 571 |
+
)
|
| 572 |
+
current_scene: Optional[SceneInfo] = Field(
|
| 573 |
+
default=None,
|
| 574 |
+
description="Current scene data",
|
| 575 |
+
)
|
| 576 |
+
in_combat: bool = Field(
|
| 577 |
+
default=False,
|
| 578 |
+
description="Whether combat is active",
|
| 579 |
+
)
|
| 580 |
+
combat_state: Optional[CombatState] = Field(
|
| 581 |
+
default=None,
|
| 582 |
+
description="Combat state if in combat",
|
| 583 |
+
)
|
| 584 |
+
story_flags: dict[str, object] = Field(
|
| 585 |
+
default_factory=dict,
|
| 586 |
+
description="Story/quest progress flags",
|
| 587 |
+
)
|
| 588 |
+
known_npcs: dict[str, NPCInfo] = Field(
|
| 589 |
+
default_factory=dict,
|
| 590 |
+
description="NPCs encountered (id -> info)",
|
| 591 |
+
)
|
| 592 |
+
recent_events: list[SessionEvent] = Field(
|
| 593 |
+
default_factory=list,
|
| 594 |
+
description="Recent session events",
|
| 595 |
+
)
|
| 596 |
+
adventure_name: Optional[str] = Field(
|
| 597 |
+
default=None,
|
| 598 |
+
description="Loaded adventure name",
|
| 599 |
+
)
|
| 600 |
+
conversation_history: list[dict[str, object]] = Field(
|
| 601 |
+
default_factory=list,
|
| 602 |
+
description="Chat history for restoration",
|
| 603 |
+
)
|
| 604 |
+
|
| 605 |
+
|
| 606 |
+
# =============================================================================
|
| 607 |
+
# Adventure Models
|
| 608 |
+
# =============================================================================
|
| 609 |
+
|
| 610 |
+
|
| 611 |
+
class AdventureMetadata(BaseModel):
|
| 612 |
+
"""
|
| 613 |
+
Metadata for an adventure module.
|
| 614 |
+
"""
|
| 615 |
+
|
| 616 |
+
name: str = Field(
|
| 617 |
+
description="Adventure name",
|
| 618 |
+
)
|
| 619 |
+
description: str = Field(
|
| 620 |
+
default="",
|
| 621 |
+
description="Adventure description",
|
| 622 |
+
)
|
| 623 |
+
difficulty: str = Field(
|
| 624 |
+
default="medium",
|
| 625 |
+
description="Difficulty level (easy, medium, hard)",
|
| 626 |
+
)
|
| 627 |
+
estimated_time: str = Field(
|
| 628 |
+
default="1-2 hours",
|
| 629 |
+
description="Estimated play time",
|
| 630 |
+
)
|
| 631 |
+
recommended_level: int = Field(
|
| 632 |
+
default=1,
|
| 633 |
+
description="Recommended character level",
|
| 634 |
+
)
|
| 635 |
+
tags: list[str] = Field(
|
| 636 |
+
default_factory=list,
|
| 637 |
+
description="Adventure tags",
|
| 638 |
+
)
|
| 639 |
+
author: str = Field(
|
| 640 |
+
default="DungeonMaster AI",
|
| 641 |
+
description="Adventure author",
|
| 642 |
+
)
|
| 643 |
+
version: str = Field(
|
| 644 |
+
default="1.0.0",
|
| 645 |
+
description="Adventure version",
|
| 646 |
+
)
|
| 647 |
+
|
| 648 |
+
|
| 649 |
+
class EncounterData(BaseModel):
|
| 650 |
+
"""
|
| 651 |
+
Data for a combat encounter.
|
| 652 |
+
"""
|
| 653 |
+
|
| 654 |
+
encounter_id: str = Field(
|
| 655 |
+
description="Unique encounter identifier",
|
| 656 |
+
)
|
| 657 |
+
name: str = Field(
|
| 658 |
+
description="Encounter name",
|
| 659 |
+
)
|
| 660 |
+
description: str = Field(
|
| 661 |
+
default="",
|
| 662 |
+
description="Encounter description",
|
| 663 |
+
)
|
| 664 |
+
enemies: list[dict[str, object]] = Field(
|
| 665 |
+
default_factory=list,
|
| 666 |
+
description="Enemy definitions [{monster, count, name?}]",
|
| 667 |
+
)
|
| 668 |
+
difficulty: str = Field(
|
| 669 |
+
default="medium",
|
| 670 |
+
description="Encounter difficulty",
|
| 671 |
+
)
|
| 672 |
+
tactics: str = Field(
|
| 673 |
+
default="",
|
| 674 |
+
description="Enemy tactics description",
|
| 675 |
+
)
|
| 676 |
+
rewards: dict[str, object] = Field(
|
| 677 |
+
default_factory=dict,
|
| 678 |
+
description="Rewards (xp, loot)",
|
| 679 |
+
)
|
| 680 |
+
|
| 681 |
+
|
| 682 |
+
class AdventureData(BaseModel):
|
| 683 |
+
"""
|
| 684 |
+
Complete adventure data loaded from JSON.
|
| 685 |
+
"""
|
| 686 |
+
|
| 687 |
+
metadata: AdventureMetadata = Field(
|
| 688 |
+
description="Adventure metadata",
|
| 689 |
+
)
|
| 690 |
+
starting_scene: dict[str, object] = Field(
|
| 691 |
+
description="Starting scene configuration",
|
| 692 |
+
)
|
| 693 |
+
scenes: list[dict[str, object]] = Field(
|
| 694 |
+
default_factory=list,
|
| 695 |
+
description="All scenes in the adventure",
|
| 696 |
+
)
|
| 697 |
+
npcs: list[dict[str, object]] = Field(
|
| 698 |
+
default_factory=list,
|
| 699 |
+
description="NPC definitions",
|
| 700 |
+
)
|
| 701 |
+
encounters: list[EncounterData] = Field(
|
| 702 |
+
default_factory=list,
|
| 703 |
+
description="Combat encounters",
|
| 704 |
+
)
|
| 705 |
+
loot_tables: list[dict[str, object]] = Field(
|
| 706 |
+
default_factory=list,
|
| 707 |
+
description="Loot table definitions",
|
| 708 |
+
)
|
| 709 |
+
victory_conditions: dict[str, object] = Field(
|
| 710 |
+
default_factory=dict,
|
| 711 |
+
description="Win conditions",
|
| 712 |
+
)
|
| 713 |
+
completion_narrative: str = Field(
|
| 714 |
+
default="",
|
| 715 |
+
description="Narrative text when adventure is completed",
|
| 716 |
+
)
|
| 717 |
+
|
| 718 |
+
@classmethod
|
| 719 |
+
def from_json(cls, data: dict[str, object]) -> AdventureData:
|
| 720 |
+
"""
|
| 721 |
+
Create AdventureData from raw JSON data.
|
| 722 |
+
|
| 723 |
+
Args:
|
| 724 |
+
data: Parsed JSON adventure data
|
| 725 |
+
|
| 726 |
+
Returns:
|
| 727 |
+
AdventureData instance
|
| 728 |
+
"""
|
| 729 |
+
# Parse metadata
|
| 730 |
+
metadata_raw = data.get("metadata", {})
|
| 731 |
+
if isinstance(metadata_raw, dict):
|
| 732 |
+
metadata = AdventureMetadata.model_validate(metadata_raw)
|
| 733 |
+
else:
|
| 734 |
+
metadata = AdventureMetadata(name="Unknown Adventure")
|
| 735 |
+
|
| 736 |
+
# Parse encounters
|
| 737 |
+
encounters_raw = data.get("encounters", [])
|
| 738 |
+
encounters = []
|
| 739 |
+
if isinstance(encounters_raw, list):
|
| 740 |
+
for enc in encounters_raw:
|
| 741 |
+
if isinstance(enc, dict):
|
| 742 |
+
encounters.append(EncounterData.model_validate(enc))
|
| 743 |
+
|
| 744 |
+
return cls(
|
| 745 |
+
metadata=metadata,
|
| 746 |
+
starting_scene=dict(data.get("starting_scene", {})),
|
| 747 |
+
scenes=list(data.get("scenes", [])),
|
| 748 |
+
npcs=list(data.get("npcs", [])),
|
| 749 |
+
encounters=encounters,
|
| 750 |
+
loot_tables=list(data.get("loot_tables", [])),
|
| 751 |
+
victory_conditions=dict(data.get("victory_conditions", {})),
|
| 752 |
+
completion_narrative=str(data.get("completion_narrative", "")),
|
| 753 |
+
)
|
| 754 |
+
|
| 755 |
+
def get_scene(self, scene_id: str) -> Optional[dict[str, object]]:
|
| 756 |
+
"""Get a scene by ID."""
|
| 757 |
+
for scene in self.scenes:
|
| 758 |
+
if isinstance(scene, dict) and scene.get("scene_id") == scene_id:
|
| 759 |
+
return scene
|
| 760 |
+
return None
|
| 761 |
+
|
| 762 |
+
def get_npc(self, npc_id: str) -> Optional[dict[str, object]]:
|
| 763 |
+
"""Get an NPC by ID."""
|
| 764 |
+
for npc in self.npcs:
|
| 765 |
+
if isinstance(npc, dict) and npc.get("npc_id") == npc_id:
|
| 766 |
+
return npc
|
| 767 |
+
return None
|
| 768 |
+
|
| 769 |
+
def get_encounter(self, encounter_id: str) -> Optional[EncounterData]:
|
| 770 |
+
"""Get an encounter by ID."""
|
| 771 |
+
for enc in self.encounters:
|
| 772 |
+
if enc.encounter_id == encounter_id:
|
| 773 |
+
return enc
|
| 774 |
+
return None
|
src/game/story_context.py
ADDED
|
@@ -0,0 +1,601 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DungeonMaster AI - Story Context Builder
|
| 3 |
+
|
| 4 |
+
Builds formatted context strings for LLM prompts from game state.
|
| 5 |
+
Manages token budgets and prioritizes information for context windows.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import logging
|
| 11 |
+
from typing import TYPE_CHECKING
|
| 12 |
+
|
| 13 |
+
from .models import (
|
| 14 |
+
CombatState,
|
| 15 |
+
CombatantStatus,
|
| 16 |
+
CharacterSnapshot,
|
| 17 |
+
HPStatus,
|
| 18 |
+
NPCInfo,
|
| 19 |
+
SceneInfo,
|
| 20 |
+
SessionEvent,
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
if TYPE_CHECKING:
|
| 24 |
+
from .game_state_manager import GameStateManager
|
| 25 |
+
|
| 26 |
+
logger = logging.getLogger(__name__)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class StoryContextBuilder:
|
| 30 |
+
"""
|
| 31 |
+
Builds LLM context strings from game state.
|
| 32 |
+
|
| 33 |
+
Formats party status, combat state, location, recent events,
|
| 34 |
+
and NPCs into a structured context for the DM agent.
|
| 35 |
+
Manages token budgets and prioritizes important information.
|
| 36 |
+
"""
|
| 37 |
+
|
| 38 |
+
def __init__(
|
| 39 |
+
self,
|
| 40 |
+
max_tokens: int = 2000,
|
| 41 |
+
max_events: int = 10,
|
| 42 |
+
include_sensory_details: bool = True,
|
| 43 |
+
) -> None:
|
| 44 |
+
"""
|
| 45 |
+
Initialize the context builder.
|
| 46 |
+
|
| 47 |
+
Args:
|
| 48 |
+
max_tokens: Maximum estimated tokens for context
|
| 49 |
+
max_events: Maximum recent events to include
|
| 50 |
+
include_sensory_details: Whether to include sensory details
|
| 51 |
+
"""
|
| 52 |
+
self._max_tokens = max_tokens
|
| 53 |
+
self._max_events = max_events
|
| 54 |
+
self._include_sensory = include_sensory_details
|
| 55 |
+
|
| 56 |
+
# =========================================================================
|
| 57 |
+
# Main Context Building
|
| 58 |
+
# =========================================================================
|
| 59 |
+
|
| 60 |
+
def build_full_context(self, manager: GameStateManager) -> str:
|
| 61 |
+
"""
|
| 62 |
+
Build the complete context string for LLM prompts.
|
| 63 |
+
|
| 64 |
+
Args:
|
| 65 |
+
manager: GameStateManager with current state
|
| 66 |
+
|
| 67 |
+
Returns:
|
| 68 |
+
Formatted context string
|
| 69 |
+
"""
|
| 70 |
+
sections: list[tuple[str, int]] = [] # (content, priority)
|
| 71 |
+
|
| 72 |
+
# Combat section (highest priority if in combat)
|
| 73 |
+
if manager.in_combat and manager.combat_state:
|
| 74 |
+
combat_section = self.build_combat_summary(manager.combat_state)
|
| 75 |
+
sections.append((combat_section, 100))
|
| 76 |
+
|
| 77 |
+
# Party section (high priority)
|
| 78 |
+
party_section = self.build_party_summary(manager)
|
| 79 |
+
if party_section:
|
| 80 |
+
sections.append((party_section, 90))
|
| 81 |
+
|
| 82 |
+
# Location section
|
| 83 |
+
location_section = self._build_location_section(manager)
|
| 84 |
+
if location_section:
|
| 85 |
+
sections.append((location_section, 70))
|
| 86 |
+
|
| 87 |
+
# Recent events section
|
| 88 |
+
events_section = self._build_events_section(manager)
|
| 89 |
+
if events_section:
|
| 90 |
+
sections.append((events_section, 60))
|
| 91 |
+
|
| 92 |
+
# NPCs section
|
| 93 |
+
npcs_section = self._build_npcs_section(manager)
|
| 94 |
+
if npcs_section:
|
| 95 |
+
sections.append((npcs_section, 50))
|
| 96 |
+
|
| 97 |
+
# Combine sections within token budget
|
| 98 |
+
return self._combine_sections(sections)
|
| 99 |
+
|
| 100 |
+
def _combine_sections(
|
| 101 |
+
self,
|
| 102 |
+
sections: list[tuple[str, int]],
|
| 103 |
+
) -> str:
|
| 104 |
+
"""
|
| 105 |
+
Combine sections within token budget.
|
| 106 |
+
|
| 107 |
+
Args:
|
| 108 |
+
sections: List of (content, priority) tuples
|
| 109 |
+
|
| 110 |
+
Returns:
|
| 111 |
+
Combined context string
|
| 112 |
+
"""
|
| 113 |
+
# Sort by priority (highest first)
|
| 114 |
+
sections.sort(key=lambda x: x[1], reverse=True)
|
| 115 |
+
|
| 116 |
+
result_parts: list[str] = []
|
| 117 |
+
current_tokens = 0
|
| 118 |
+
|
| 119 |
+
for content, priority in sections:
|
| 120 |
+
section_tokens = self._estimate_tokens(content)
|
| 121 |
+
|
| 122 |
+
if current_tokens + section_tokens <= self._max_tokens:
|
| 123 |
+
result_parts.append(content)
|
| 124 |
+
current_tokens += section_tokens
|
| 125 |
+
else:
|
| 126 |
+
# Try to truncate section to fit
|
| 127 |
+
remaining_tokens = self._max_tokens - current_tokens
|
| 128 |
+
if remaining_tokens > 100: # Worth including something
|
| 129 |
+
truncated = self._truncate_to_tokens(content, remaining_tokens)
|
| 130 |
+
if truncated:
|
| 131 |
+
result_parts.append(truncated)
|
| 132 |
+
current_tokens += self._estimate_tokens(truncated)
|
| 133 |
+
break
|
| 134 |
+
|
| 135 |
+
return "\n\n".join(result_parts)
|
| 136 |
+
|
| 137 |
+
# =========================================================================
|
| 138 |
+
# Combat Summary
|
| 139 |
+
# =========================================================================
|
| 140 |
+
|
| 141 |
+
def build_combat_summary(self, combat: CombatState) -> str:
|
| 142 |
+
"""
|
| 143 |
+
Build a combat state summary.
|
| 144 |
+
|
| 145 |
+
Args:
|
| 146 |
+
combat: Current combat state
|
| 147 |
+
|
| 148 |
+
Returns:
|
| 149 |
+
Formatted combat summary
|
| 150 |
+
"""
|
| 151 |
+
lines: list[str] = []
|
| 152 |
+
|
| 153 |
+
# Header
|
| 154 |
+
lines.append(f"**COMBAT - Round {combat.round_number}**")
|
| 155 |
+
|
| 156 |
+
# Turn order
|
| 157 |
+
for i, combatant in enumerate(combat.combatants):
|
| 158 |
+
# Skip dead/fled combatants
|
| 159 |
+
if combatant.status in (CombatantStatus.DEAD, CombatantStatus.FLED):
|
| 160 |
+
continue
|
| 161 |
+
|
| 162 |
+
# Current turn marker
|
| 163 |
+
is_current = i == combat.turn_index
|
| 164 |
+
marker = ">" if is_current else " "
|
| 165 |
+
|
| 166 |
+
# Player/Enemy tag
|
| 167 |
+
tag = "Player" if combatant.is_player else "Enemy"
|
| 168 |
+
|
| 169 |
+
# HP status
|
| 170 |
+
hp_status = self._get_hp_status_string(combatant.hp_current, combatant.hp_max)
|
| 171 |
+
hp_display = f"{combatant.hp_current}/{combatant.hp_max} HP"
|
| 172 |
+
|
| 173 |
+
# Conditions
|
| 174 |
+
conditions_str = ""
|
| 175 |
+
if combatant.conditions:
|
| 176 |
+
conditions_str = f" [{', '.join(combatant.conditions)}]"
|
| 177 |
+
elif combatant.status == CombatantStatus.UNCONSCIOUS:
|
| 178 |
+
conditions_str = " [Unconscious]"
|
| 179 |
+
|
| 180 |
+
# Status indicator for unconscious
|
| 181 |
+
status_suffix = ""
|
| 182 |
+
if combatant.status == CombatantStatus.UNCONSCIOUS:
|
| 183 |
+
status_suffix = " - DOWN"
|
| 184 |
+
|
| 185 |
+
# Build line
|
| 186 |
+
turn_indicator = " <- Your Turn" if is_current and combatant.is_player else ""
|
| 187 |
+
line = (
|
| 188 |
+
f"{marker} {combatant.initiative}: {combatant.name} ({tag}) - "
|
| 189 |
+
f"{hp_display} [{hp_status}]{conditions_str}{status_suffix}{turn_indicator}"
|
| 190 |
+
)
|
| 191 |
+
lines.append(line)
|
| 192 |
+
|
| 193 |
+
return "\n".join(lines)
|
| 194 |
+
|
| 195 |
+
# =========================================================================
|
| 196 |
+
# Party Summary
|
| 197 |
+
# =========================================================================
|
| 198 |
+
|
| 199 |
+
def build_party_summary(self, manager: GameStateManager) -> str:
|
| 200 |
+
"""
|
| 201 |
+
Build a party status summary.
|
| 202 |
+
|
| 203 |
+
Args:
|
| 204 |
+
manager: GameStateManager with party data
|
| 205 |
+
|
| 206 |
+
Returns:
|
| 207 |
+
Formatted party summary
|
| 208 |
+
"""
|
| 209 |
+
snapshots = manager.get_party_snapshots()
|
| 210 |
+
if not snapshots:
|
| 211 |
+
return ""
|
| 212 |
+
|
| 213 |
+
lines: list[str] = ["**Party Status:**"]
|
| 214 |
+
|
| 215 |
+
for snapshot in snapshots:
|
| 216 |
+
# Active marker
|
| 217 |
+
is_active = snapshot.character_id == manager.active_character_id
|
| 218 |
+
active_marker = " (Active)" if is_active else ""
|
| 219 |
+
|
| 220 |
+
# HP status
|
| 221 |
+
hp_status = self._get_hp_status_string(snapshot.hp_current, snapshot.hp_max)
|
| 222 |
+
hp_display = f"{snapshot.hp_current}/{snapshot.hp_max} HP"
|
| 223 |
+
|
| 224 |
+
# Conditions
|
| 225 |
+
conditions_str = ""
|
| 226 |
+
if snapshot.conditions:
|
| 227 |
+
conditions_str = f" [Conditions: {', '.join(snapshot.conditions)}]"
|
| 228 |
+
|
| 229 |
+
# Class and level
|
| 230 |
+
class_info = f"Lvl {snapshot.level} {snapshot.character_class}"
|
| 231 |
+
|
| 232 |
+
line = (
|
| 233 |
+
f"- {snapshot.name}{active_marker}: {hp_display} [{hp_status}] "
|
| 234 |
+
f"- {class_info}{conditions_str}"
|
| 235 |
+
)
|
| 236 |
+
lines.append(line)
|
| 237 |
+
|
| 238 |
+
return "\n".join(lines)
|
| 239 |
+
|
| 240 |
+
# =========================================================================
|
| 241 |
+
# Location Section
|
| 242 |
+
# =========================================================================
|
| 243 |
+
|
| 244 |
+
def _build_location_section(self, manager: GameStateManager) -> str:
|
| 245 |
+
"""
|
| 246 |
+
Build location/scene section.
|
| 247 |
+
|
| 248 |
+
Args:
|
| 249 |
+
manager: GameStateManager with location data
|
| 250 |
+
|
| 251 |
+
Returns:
|
| 252 |
+
Formatted location section
|
| 253 |
+
"""
|
| 254 |
+
lines: list[str] = []
|
| 255 |
+
|
| 256 |
+
# Location header
|
| 257 |
+
lines.append(f"**Location: {manager.current_location}**")
|
| 258 |
+
|
| 259 |
+
scene = manager.current_scene
|
| 260 |
+
if scene:
|
| 261 |
+
# Description
|
| 262 |
+
if scene.description:
|
| 263 |
+
lines.append(scene.description)
|
| 264 |
+
|
| 265 |
+
# Sensory details (if enabled)
|
| 266 |
+
if self._include_sensory and scene.sensory_details:
|
| 267 |
+
sensory_parts: list[str] = []
|
| 268 |
+
for sense, detail in scene.sensory_details.items():
|
| 269 |
+
if detail:
|
| 270 |
+
sensory_parts.append(f"*{sense.capitalize()}*: {detail}")
|
| 271 |
+
if sensory_parts:
|
| 272 |
+
lines.append("\n".join(sensory_parts))
|
| 273 |
+
|
| 274 |
+
# Exits
|
| 275 |
+
if scene.exits:
|
| 276 |
+
exits_str = ", ".join(
|
| 277 |
+
f"{direction} to {dest}"
|
| 278 |
+
for direction, dest in scene.exits.items()
|
| 279 |
+
)
|
| 280 |
+
lines.append(f"*Exits*: {exits_str}")
|
| 281 |
+
|
| 282 |
+
return "\n".join(lines)
|
| 283 |
+
|
| 284 |
+
# =========================================================================
|
| 285 |
+
# Events Section
|
| 286 |
+
# =========================================================================
|
| 287 |
+
|
| 288 |
+
def _build_events_section(self, manager: GameStateManager) -> str:
|
| 289 |
+
"""
|
| 290 |
+
Build recent events section.
|
| 291 |
+
|
| 292 |
+
Args:
|
| 293 |
+
manager: GameStateManager with events
|
| 294 |
+
|
| 295 |
+
Returns:
|
| 296 |
+
Formatted events section
|
| 297 |
+
"""
|
| 298 |
+
# Get significant events first, then recent
|
| 299 |
+
events = manager.event_logger.get_recent(
|
| 300 |
+
count=self._max_events,
|
| 301 |
+
significant_only=False,
|
| 302 |
+
)
|
| 303 |
+
|
| 304 |
+
if not events:
|
| 305 |
+
return ""
|
| 306 |
+
|
| 307 |
+
# Filter to most relevant
|
| 308 |
+
significant = [e for e in events if e.is_significant]
|
| 309 |
+
recent = events[:5] # Last 5 events
|
| 310 |
+
|
| 311 |
+
# Combine, preferring significant
|
| 312 |
+
to_show: list[SessionEvent] = []
|
| 313 |
+
seen_ids: set[str] = set()
|
| 314 |
+
|
| 315 |
+
for event in significant + recent:
|
| 316 |
+
if event.event_id not in seen_ids:
|
| 317 |
+
to_show.append(event)
|
| 318 |
+
seen_ids.add(event.event_id)
|
| 319 |
+
if len(to_show) >= self._max_events:
|
| 320 |
+
break
|
| 321 |
+
|
| 322 |
+
if not to_show:
|
| 323 |
+
return ""
|
| 324 |
+
|
| 325 |
+
lines: list[str] = ["**Recent Events:**"]
|
| 326 |
+
for event in to_show:
|
| 327 |
+
lines.append(f"- {event.description}")
|
| 328 |
+
|
| 329 |
+
return "\n".join(lines)
|
| 330 |
+
|
| 331 |
+
# =========================================================================
|
| 332 |
+
# NPCs Section
|
| 333 |
+
# =========================================================================
|
| 334 |
+
|
| 335 |
+
def _build_npcs_section(self, manager: GameStateManager) -> str:
|
| 336 |
+
"""
|
| 337 |
+
Build NPCs present section.
|
| 338 |
+
|
| 339 |
+
Args:
|
| 340 |
+
manager: GameStateManager with NPC data
|
| 341 |
+
|
| 342 |
+
Returns:
|
| 343 |
+
Formatted NPCs section
|
| 344 |
+
"""
|
| 345 |
+
npcs = manager.get_npcs_in_scene()
|
| 346 |
+
if not npcs:
|
| 347 |
+
return ""
|
| 348 |
+
|
| 349 |
+
lines: list[str] = ["**NPCs Present:**"]
|
| 350 |
+
|
| 351 |
+
for npc in npcs:
|
| 352 |
+
# Name and brief description
|
| 353 |
+
desc = npc.description[:100] + "..." if len(npc.description) > 100 else npc.description
|
| 354 |
+
line = f"- {npc.name}: {desc}"
|
| 355 |
+
|
| 356 |
+
# Add personality hint if available
|
| 357 |
+
if npc.personality:
|
| 358 |
+
personality_short = (
|
| 359 |
+
npc.personality[:50] + "..."
|
| 360 |
+
if len(npc.personality) > 50
|
| 361 |
+
else npc.personality
|
| 362 |
+
)
|
| 363 |
+
line += f" ({personality_short})"
|
| 364 |
+
|
| 365 |
+
lines.append(line)
|
| 366 |
+
|
| 367 |
+
return "\n".join(lines)
|
| 368 |
+
|
| 369 |
+
# =========================================================================
|
| 370 |
+
# Helper Methods
|
| 371 |
+
# =========================================================================
|
| 372 |
+
|
| 373 |
+
def _get_hp_status_string(self, hp_current: int, hp_max: int) -> str:
|
| 374 |
+
"""
|
| 375 |
+
Get HP status string from current/max HP.
|
| 376 |
+
|
| 377 |
+
Args:
|
| 378 |
+
hp_current: Current HP
|
| 379 |
+
hp_max: Maximum HP
|
| 380 |
+
|
| 381 |
+
Returns:
|
| 382 |
+
Status string (Healthy, Wounded, Critical, Unconscious)
|
| 383 |
+
"""
|
| 384 |
+
if hp_current <= 0:
|
| 385 |
+
return "Unconscious"
|
| 386 |
+
|
| 387 |
+
if hp_max <= 0:
|
| 388 |
+
return "Unknown"
|
| 389 |
+
|
| 390 |
+
percent = (hp_current / hp_max) * 100
|
| 391 |
+
|
| 392 |
+
if percent > 50:
|
| 393 |
+
return "Healthy"
|
| 394 |
+
elif percent > 25:
|
| 395 |
+
return "Wounded"
|
| 396 |
+
else:
|
| 397 |
+
return "Critical"
|
| 398 |
+
|
| 399 |
+
def _estimate_tokens(self, text: str) -> int:
|
| 400 |
+
"""
|
| 401 |
+
Estimate token count for text.
|
| 402 |
+
|
| 403 |
+
Uses rough approximation of 4 characters per token.
|
| 404 |
+
|
| 405 |
+
Args:
|
| 406 |
+
text: Text to estimate
|
| 407 |
+
|
| 408 |
+
Returns:
|
| 409 |
+
Estimated token count
|
| 410 |
+
"""
|
| 411 |
+
return len(text) // 4
|
| 412 |
+
|
| 413 |
+
def _truncate_to_tokens(self, text: str, max_tokens: int) -> str:
|
| 414 |
+
"""
|
| 415 |
+
Truncate text to fit within token budget.
|
| 416 |
+
|
| 417 |
+
Args:
|
| 418 |
+
text: Text to truncate
|
| 419 |
+
max_tokens: Maximum tokens
|
| 420 |
+
|
| 421 |
+
Returns:
|
| 422 |
+
Truncated text
|
| 423 |
+
"""
|
| 424 |
+
max_chars = max_tokens * 4
|
| 425 |
+
|
| 426 |
+
if len(text) <= max_chars:
|
| 427 |
+
return text
|
| 428 |
+
|
| 429 |
+
# Find a good break point
|
| 430 |
+
truncated = text[:max_chars]
|
| 431 |
+
|
| 432 |
+
# Try to break at a newline
|
| 433 |
+
last_newline = truncated.rfind("\n")
|
| 434 |
+
if last_newline > max_chars * 0.5:
|
| 435 |
+
return truncated[:last_newline]
|
| 436 |
+
|
| 437 |
+
# Try to break at a sentence
|
| 438 |
+
for punct in [".", "!", "?"]:
|
| 439 |
+
last_punct = truncated.rfind(punct)
|
| 440 |
+
if last_punct > max_chars * 0.5:
|
| 441 |
+
return truncated[: last_punct + 1]
|
| 442 |
+
|
| 443 |
+
# Just truncate with ellipsis
|
| 444 |
+
return truncated[:-3] + "..."
|
| 445 |
+
|
| 446 |
+
# =========================================================================
|
| 447 |
+
# Specialized Context Builders
|
| 448 |
+
# =========================================================================
|
| 449 |
+
|
| 450 |
+
def build_combat_context(self, manager: GameStateManager) -> str:
|
| 451 |
+
"""
|
| 452 |
+
Build context optimized for combat situations.
|
| 453 |
+
|
| 454 |
+
Prioritizes combat state, current combatant, and party HP.
|
| 455 |
+
|
| 456 |
+
Args:
|
| 457 |
+
manager: GameStateManager
|
| 458 |
+
|
| 459 |
+
Returns:
|
| 460 |
+
Combat-focused context
|
| 461 |
+
"""
|
| 462 |
+
if not manager.in_combat or not manager.combat_state:
|
| 463 |
+
return self.build_full_context(manager)
|
| 464 |
+
|
| 465 |
+
sections: list[str] = []
|
| 466 |
+
|
| 467 |
+
# Combat state (required)
|
| 468 |
+
sections.append(self.build_combat_summary(manager.combat_state))
|
| 469 |
+
|
| 470 |
+
# Party HP summary
|
| 471 |
+
party_section = self.build_party_summary(manager)
|
| 472 |
+
if party_section:
|
| 473 |
+
sections.append(party_section)
|
| 474 |
+
|
| 475 |
+
# Current location (brief)
|
| 476 |
+
sections.append(f"**Location:** {manager.current_location}")
|
| 477 |
+
|
| 478 |
+
return "\n\n".join(sections)
|
| 479 |
+
|
| 480 |
+
def build_exploration_context(self, manager: GameStateManager) -> str:
|
| 481 |
+
"""
|
| 482 |
+
Build context optimized for exploration.
|
| 483 |
+
|
| 484 |
+
Prioritizes location details, sensory info, and NPCs.
|
| 485 |
+
|
| 486 |
+
Args:
|
| 487 |
+
manager: GameStateManager
|
| 488 |
+
|
| 489 |
+
Returns:
|
| 490 |
+
Exploration-focused context
|
| 491 |
+
"""
|
| 492 |
+
sections: list[str] = []
|
| 493 |
+
|
| 494 |
+
# Location (detailed)
|
| 495 |
+
location_section = self._build_location_section(manager)
|
| 496 |
+
if location_section:
|
| 497 |
+
sections.append(location_section)
|
| 498 |
+
|
| 499 |
+
# NPCs
|
| 500 |
+
npcs_section = self._build_npcs_section(manager)
|
| 501 |
+
if npcs_section:
|
| 502 |
+
sections.append(npcs_section)
|
| 503 |
+
|
| 504 |
+
# Party status (brief)
|
| 505 |
+
party_section = self.build_party_summary(manager)
|
| 506 |
+
if party_section:
|
| 507 |
+
sections.append(party_section)
|
| 508 |
+
|
| 509 |
+
# Recent events
|
| 510 |
+
events_section = self._build_events_section(manager)
|
| 511 |
+
if events_section:
|
| 512 |
+
sections.append(events_section)
|
| 513 |
+
|
| 514 |
+
return "\n\n".join(sections)
|
| 515 |
+
|
| 516 |
+
def build_social_context(self, manager: GameStateManager) -> str:
|
| 517 |
+
"""
|
| 518 |
+
Build context optimized for social encounters.
|
| 519 |
+
|
| 520 |
+
Prioritizes NPC information and dialogue history.
|
| 521 |
+
|
| 522 |
+
Args:
|
| 523 |
+
manager: GameStateManager
|
| 524 |
+
|
| 525 |
+
Returns:
|
| 526 |
+
Social-focused context
|
| 527 |
+
"""
|
| 528 |
+
sections: list[str] = []
|
| 529 |
+
|
| 530 |
+
# NPCs (detailed)
|
| 531 |
+
npcs = manager.get_npcs_in_scene()
|
| 532 |
+
if npcs:
|
| 533 |
+
lines: list[str] = ["**NPCs Present:**"]
|
| 534 |
+
for npc in npcs:
|
| 535 |
+
lines.append(f"\n**{npc.name}**")
|
| 536 |
+
if npc.description:
|
| 537 |
+
lines.append(npc.description)
|
| 538 |
+
if npc.personality:
|
| 539 |
+
lines.append(f"*Personality*: {npc.personality}")
|
| 540 |
+
if npc.dialogue_hooks:
|
| 541 |
+
lines.append(f"*Might say*: \"{npc.dialogue_hooks[0]}\"")
|
| 542 |
+
sections.append("\n".join(lines))
|
| 543 |
+
|
| 544 |
+
# Location (brief)
|
| 545 |
+
sections.append(f"**Location:** {manager.current_location}")
|
| 546 |
+
|
| 547 |
+
# Recent dialogue events
|
| 548 |
+
dialogue_events = manager.event_logger.get_recent(
|
| 549 |
+
count=5,
|
| 550 |
+
event_type=None, # Get all, filter below
|
| 551 |
+
)
|
| 552 |
+
dialogue_lines: list[str] = ["**Recent Conversation:**"]
|
| 553 |
+
for event in dialogue_events:
|
| 554 |
+
if "dialogue" in event.event_type.value.lower():
|
| 555 |
+
dialogue_lines.append(f"- {event.description}")
|
| 556 |
+
if len(dialogue_lines) > 1:
|
| 557 |
+
sections.append("\n".join(dialogue_lines))
|
| 558 |
+
|
| 559 |
+
# Party status
|
| 560 |
+
party_section = self.build_party_summary(manager)
|
| 561 |
+
if party_section:
|
| 562 |
+
sections.append(party_section)
|
| 563 |
+
|
| 564 |
+
return "\n\n".join(sections)
|
| 565 |
+
|
| 566 |
+
def build_minimal_context(self, manager: GameStateManager) -> str:
|
| 567 |
+
"""
|
| 568 |
+
Build minimal context for token-constrained situations.
|
| 569 |
+
|
| 570 |
+
Only includes essential information.
|
| 571 |
+
|
| 572 |
+
Args:
|
| 573 |
+
manager: GameStateManager
|
| 574 |
+
|
| 575 |
+
Returns:
|
| 576 |
+
Minimal context string
|
| 577 |
+
"""
|
| 578 |
+
parts: list[str] = []
|
| 579 |
+
|
| 580 |
+
# Combat status
|
| 581 |
+
if manager.in_combat and manager.combat_state:
|
| 582 |
+
current = manager.combat_state.current_combatant
|
| 583 |
+
if current:
|
| 584 |
+
parts.append(
|
| 585 |
+
f"Combat Round {manager.combat_state.round_number}, "
|
| 586 |
+
f"{current.name}'s turn"
|
| 587 |
+
)
|
| 588 |
+
|
| 589 |
+
# Location
|
| 590 |
+
parts.append(f"Location: {manager.current_location}")
|
| 591 |
+
|
| 592 |
+
# Active character HP
|
| 593 |
+
for snapshot in manager.get_party_snapshots():
|
| 594 |
+
if snapshot.character_id == manager.active_character_id:
|
| 595 |
+
parts.append(
|
| 596 |
+
f"Active: {snapshot.name} "
|
| 597 |
+
f"({snapshot.hp_current}/{snapshot.hp_max} HP)"
|
| 598 |
+
)
|
| 599 |
+
break
|
| 600 |
+
|
| 601 |
+
return " | ".join(parts)
|
src/mcp_integration/__init__.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DungeonMaster AI - MCP Integration Package
|
| 3 |
+
|
| 4 |
+
Provides connection management, tool wrappers, and graceful degradation
|
| 5 |
+
for the TTRPG-Toolkit MCP server integration.
|
| 6 |
+
|
| 7 |
+
Usage:
|
| 8 |
+
```python
|
| 9 |
+
from src.mcp_integration import (
|
| 10 |
+
TTRPGToolkitClient,
|
| 11 |
+
ConnectionManager,
|
| 12 |
+
GameAwareTools,
|
| 13 |
+
TOOL_CATEGORIES,
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
# Create and connect client
|
| 17 |
+
manager = ConnectionManager()
|
| 18 |
+
await manager.connect()
|
| 19 |
+
|
| 20 |
+
# Get tools for agents
|
| 21 |
+
all_tools = await manager.get_tools()
|
| 22 |
+
rules_tools = await manager.get_tools(categories=["rules"])
|
| 23 |
+
|
| 24 |
+
# Wrap with game awareness
|
| 25 |
+
wrapper = GameAwareTools(game_state=state)
|
| 26 |
+
enhanced_tools = wrapper.wrap_tools(all_tools)
|
| 27 |
+
```
|
| 28 |
+
"""
|
| 29 |
+
|
| 30 |
+
from .connection_manager import ConnectionManager
|
| 31 |
+
from .exceptions import (
|
| 32 |
+
MCPCircuitBreakerOpenError,
|
| 33 |
+
MCPConnectionError,
|
| 34 |
+
MCPIntegrationError,
|
| 35 |
+
MCPInvalidResponseError,
|
| 36 |
+
MCPTimeoutError,
|
| 37 |
+
MCPToolExecutionError,
|
| 38 |
+
MCPToolNotFoundError,
|
| 39 |
+
MCPUnavailableError,
|
| 40 |
+
)
|
| 41 |
+
from .fallbacks import FallbackHandler
|
| 42 |
+
from .models import (
|
| 43 |
+
CircuitBreakerState,
|
| 44 |
+
CombatantInfo,
|
| 45 |
+
CombatStateResult,
|
| 46 |
+
ConnectionState,
|
| 47 |
+
DiceRollResult,
|
| 48 |
+
FormattedResult,
|
| 49 |
+
GameStateProtocol,
|
| 50 |
+
HPChangeResult,
|
| 51 |
+
MCPConnectionStatus,
|
| 52 |
+
RollType,
|
| 53 |
+
)
|
| 54 |
+
from .tool_wrappers import GameAwareTools
|
| 55 |
+
from .toolkit_client import TOOL_CATEGORIES, TTRPGToolkitClient
|
| 56 |
+
|
| 57 |
+
__all__ = [
|
| 58 |
+
# Exceptions
|
| 59 |
+
"MCPIntegrationError",
|
| 60 |
+
"MCPConnectionError",
|
| 61 |
+
"MCPTimeoutError",
|
| 62 |
+
"MCPToolNotFoundError",
|
| 63 |
+
"MCPToolExecutionError",
|
| 64 |
+
"MCPUnavailableError",
|
| 65 |
+
"MCPCircuitBreakerOpenError",
|
| 66 |
+
"MCPInvalidResponseError",
|
| 67 |
+
# Models & Enums
|
| 68 |
+
"ConnectionState",
|
| 69 |
+
"CircuitBreakerState",
|
| 70 |
+
"RollType",
|
| 71 |
+
"GameStateProtocol",
|
| 72 |
+
"FormattedResult",
|
| 73 |
+
"DiceRollResult",
|
| 74 |
+
"HPChangeResult",
|
| 75 |
+
"CombatantInfo",
|
| 76 |
+
"CombatStateResult",
|
| 77 |
+
"MCPConnectionStatus",
|
| 78 |
+
# Client & Connection
|
| 79 |
+
"TTRPGToolkitClient",
|
| 80 |
+
"TOOL_CATEGORIES",
|
| 81 |
+
"ConnectionManager",
|
| 82 |
+
# Tool Wrappers
|
| 83 |
+
"GameAwareTools",
|
| 84 |
+
# Fallbacks
|
| 85 |
+
"FallbackHandler",
|
| 86 |
+
]
|
src/mcp_integration/connection_manager.py
ADDED
|
@@ -0,0 +1,565 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DungeonMaster AI - MCP Connection Manager
|
| 3 |
+
|
| 4 |
+
Manages MCP connection lifecycle with health checks, automatic reconnection,
|
| 5 |
+
circuit breaker pattern, and graceful degradation.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import asyncio
|
| 11 |
+
import contextlib
|
| 12 |
+
import logging
|
| 13 |
+
import random
|
| 14 |
+
import time
|
| 15 |
+
from collections.abc import Sequence
|
| 16 |
+
from datetime import datetime
|
| 17 |
+
from typing import TYPE_CHECKING
|
| 18 |
+
|
| 19 |
+
from llama_index.core.tools import FunctionTool
|
| 20 |
+
|
| 21 |
+
from src.config.settings import get_settings
|
| 22 |
+
|
| 23 |
+
from .exceptions import (
|
| 24 |
+
MCPCircuitBreakerOpenError,
|
| 25 |
+
MCPUnavailableError,
|
| 26 |
+
)
|
| 27 |
+
from .fallbacks import FallbackHandler
|
| 28 |
+
from .models import (
|
| 29 |
+
CircuitBreakerState,
|
| 30 |
+
ConnectionState,
|
| 31 |
+
MCPConnectionStatus,
|
| 32 |
+
)
|
| 33 |
+
from .toolkit_client import TTRPGToolkitClient
|
| 34 |
+
|
| 35 |
+
if TYPE_CHECKING:
|
| 36 |
+
from typing import Any
|
| 37 |
+
|
| 38 |
+
logger = logging.getLogger(__name__)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class CircuitBreaker:
|
| 42 |
+
"""
|
| 43 |
+
Circuit breaker pattern implementation.
|
| 44 |
+
|
| 45 |
+
Prevents repeated calls to a failing service by tracking failures
|
| 46 |
+
and temporarily rejecting requests when failure threshold is reached.
|
| 47 |
+
|
| 48 |
+
States:
|
| 49 |
+
- CLOSED: Normal operation, requests allowed
|
| 50 |
+
- OPEN: Too many failures, requests rejected for reset_timeout
|
| 51 |
+
- HALF_OPEN: Testing if service recovered with a single request
|
| 52 |
+
"""
|
| 53 |
+
|
| 54 |
+
def __init__(
|
| 55 |
+
self,
|
| 56 |
+
failure_threshold: int = 5,
|
| 57 |
+
reset_timeout: float = 30.0,
|
| 58 |
+
half_open_max_calls: int = 1,
|
| 59 |
+
) -> None:
|
| 60 |
+
"""
|
| 61 |
+
Initialize circuit breaker.
|
| 62 |
+
|
| 63 |
+
Args:
|
| 64 |
+
failure_threshold: Failures before opening circuit
|
| 65 |
+
reset_timeout: Seconds to wait before trying again
|
| 66 |
+
half_open_max_calls: Max calls allowed in half-open state
|
| 67 |
+
"""
|
| 68 |
+
self.failure_threshold = failure_threshold
|
| 69 |
+
self.reset_timeout = reset_timeout
|
| 70 |
+
self.half_open_max_calls = half_open_max_calls
|
| 71 |
+
|
| 72 |
+
self._state = CircuitBreakerState.CLOSED
|
| 73 |
+
self._failure_count = 0
|
| 74 |
+
self._last_failure_time: float | None = None
|
| 75 |
+
self._half_open_calls = 0
|
| 76 |
+
|
| 77 |
+
@property
|
| 78 |
+
def state(self) -> CircuitBreakerState:
|
| 79 |
+
"""Get current circuit breaker state."""
|
| 80 |
+
# Check if we should transition from OPEN to HALF_OPEN
|
| 81 |
+
if (
|
| 82 |
+
self._state == CircuitBreakerState.OPEN
|
| 83 |
+
and self._last_failure_time is not None
|
| 84 |
+
):
|
| 85 |
+
elapsed = time.time() - self._last_failure_time
|
| 86 |
+
if elapsed >= self.reset_timeout:
|
| 87 |
+
self._state = CircuitBreakerState.HALF_OPEN
|
| 88 |
+
self._half_open_calls = 0
|
| 89 |
+
logger.info("Circuit breaker transitioned to HALF_OPEN")
|
| 90 |
+
|
| 91 |
+
return self._state
|
| 92 |
+
|
| 93 |
+
@property
|
| 94 |
+
def is_open(self) -> bool:
|
| 95 |
+
"""Check if circuit is open (rejecting requests)."""
|
| 96 |
+
return self.state == CircuitBreakerState.OPEN
|
| 97 |
+
|
| 98 |
+
@property
|
| 99 |
+
def time_until_retry(self) -> float | None:
|
| 100 |
+
"""Seconds until retry is allowed, or None if not in OPEN state."""
|
| 101 |
+
if self._state != CircuitBreakerState.OPEN:
|
| 102 |
+
return None
|
| 103 |
+
if self._last_failure_time is None:
|
| 104 |
+
return None
|
| 105 |
+
elapsed = time.time() - self._last_failure_time
|
| 106 |
+
remaining = self.reset_timeout - elapsed
|
| 107 |
+
return max(0.0, remaining)
|
| 108 |
+
|
| 109 |
+
def record_success(self) -> None:
|
| 110 |
+
"""Record a successful call."""
|
| 111 |
+
if self._state == CircuitBreakerState.HALF_OPEN:
|
| 112 |
+
# Successful call in half-open means we can close
|
| 113 |
+
self._state = CircuitBreakerState.CLOSED
|
| 114 |
+
logger.info("Circuit breaker closed after successful recovery")
|
| 115 |
+
|
| 116 |
+
self._failure_count = 0
|
| 117 |
+
self._last_failure_time = None
|
| 118 |
+
|
| 119 |
+
def record_failure(self) -> None:
|
| 120 |
+
"""Record a failed call."""
|
| 121 |
+
self._failure_count += 1
|
| 122 |
+
self._last_failure_time = time.time()
|
| 123 |
+
|
| 124 |
+
if self._state == CircuitBreakerState.HALF_OPEN:
|
| 125 |
+
# Failure in half-open means service still failing
|
| 126 |
+
self._state = CircuitBreakerState.OPEN
|
| 127 |
+
logger.warning("Circuit breaker reopened after half-open failure")
|
| 128 |
+
elif self._failure_count >= self.failure_threshold:
|
| 129 |
+
self._state = CircuitBreakerState.OPEN
|
| 130 |
+
logger.warning(
|
| 131 |
+
f"Circuit breaker opened after {self._failure_count} failures"
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
def allow_request(self) -> bool:
|
| 135 |
+
"""
|
| 136 |
+
Check if a request should be allowed.
|
| 137 |
+
|
| 138 |
+
Returns:
|
| 139 |
+
True if request is allowed, False if should be rejected.
|
| 140 |
+
"""
|
| 141 |
+
state = self.state # This may transition OPEN -> HALF_OPEN
|
| 142 |
+
|
| 143 |
+
if state == CircuitBreakerState.CLOSED:
|
| 144 |
+
return True
|
| 145 |
+
|
| 146 |
+
if state == CircuitBreakerState.HALF_OPEN:
|
| 147 |
+
if self._half_open_calls < self.half_open_max_calls:
|
| 148 |
+
self._half_open_calls += 1
|
| 149 |
+
return True
|
| 150 |
+
return False
|
| 151 |
+
|
| 152 |
+
# OPEN state
|
| 153 |
+
return False
|
| 154 |
+
|
| 155 |
+
def reset(self) -> None:
|
| 156 |
+
"""Reset circuit breaker to initial state."""
|
| 157 |
+
self._state = CircuitBreakerState.CLOSED
|
| 158 |
+
self._failure_count = 0
|
| 159 |
+
self._last_failure_time = None
|
| 160 |
+
self._half_open_calls = 0
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
class ConnectionManager:
|
| 164 |
+
"""
|
| 165 |
+
Manages MCP connection lifecycle with health checks and reconnection.
|
| 166 |
+
|
| 167 |
+
Features:
|
| 168 |
+
- Automatic reconnection with exponential backoff
|
| 169 |
+
- Circuit breaker to prevent hammering failed server
|
| 170 |
+
- Health check monitoring
|
| 171 |
+
- Graceful degradation via FallbackHandler
|
| 172 |
+
- Connection status tracking
|
| 173 |
+
|
| 174 |
+
Example:
|
| 175 |
+
```python
|
| 176 |
+
manager = ConnectionManager()
|
| 177 |
+
connected = await manager.connect()
|
| 178 |
+
|
| 179 |
+
if manager.is_available:
|
| 180 |
+
tools = await manager.get_tools()
|
| 181 |
+
result = await manager.execute_tool("roll", {"notation": "1d20"})
|
| 182 |
+
else:
|
| 183 |
+
# Fallback handling
|
| 184 |
+
result = await manager.execute_with_fallback("roll", {"notation": "1d20"})
|
| 185 |
+
```
|
| 186 |
+
"""
|
| 187 |
+
|
| 188 |
+
def __init__(
|
| 189 |
+
self,
|
| 190 |
+
toolkit_client: TTRPGToolkitClient | None = None,
|
| 191 |
+
max_retries: int | None = None,
|
| 192 |
+
retry_delay: float | None = None,
|
| 193 |
+
fallback_handler: FallbackHandler | None = None,
|
| 194 |
+
) -> None:
|
| 195 |
+
"""
|
| 196 |
+
Initialize connection manager.
|
| 197 |
+
|
| 198 |
+
Args:
|
| 199 |
+
toolkit_client: Pre-configured client, or None to create new one
|
| 200 |
+
max_retries: Max reconnection attempts (default from settings)
|
| 201 |
+
retry_delay: Base delay between retries (default from settings)
|
| 202 |
+
fallback_handler: Handler for graceful degradation
|
| 203 |
+
"""
|
| 204 |
+
settings = get_settings()
|
| 205 |
+
|
| 206 |
+
self._client = toolkit_client or TTRPGToolkitClient()
|
| 207 |
+
self._max_retries: int = max_retries or settings.mcp.mcp_retry_attempts
|
| 208 |
+
self._retry_delay: float = retry_delay or settings.mcp.mcp_retry_delay
|
| 209 |
+
self._fallback_handler = fallback_handler or FallbackHandler()
|
| 210 |
+
|
| 211 |
+
# Connection state
|
| 212 |
+
self._state = ConnectionState.DISCONNECTED
|
| 213 |
+
self._last_successful_call: datetime | None = None
|
| 214 |
+
self._consecutive_failures = 0
|
| 215 |
+
self._last_error: str | None = None
|
| 216 |
+
|
| 217 |
+
# Circuit breaker
|
| 218 |
+
self._circuit_breaker = CircuitBreaker(
|
| 219 |
+
failure_threshold=5,
|
| 220 |
+
reset_timeout=30.0,
|
| 221 |
+
)
|
| 222 |
+
|
| 223 |
+
# Health check task
|
| 224 |
+
self._health_check_task: asyncio.Task[None] | None = None
|
| 225 |
+
self._health_check_interval = 60.0 # seconds
|
| 226 |
+
|
| 227 |
+
@property
|
| 228 |
+
def state(self) -> ConnectionState:
|
| 229 |
+
"""Get current connection state."""
|
| 230 |
+
return self._state
|
| 231 |
+
|
| 232 |
+
@property
|
| 233 |
+
def is_available(self) -> bool:
|
| 234 |
+
"""Check if MCP is available for use."""
|
| 235 |
+
return (
|
| 236 |
+
self._state == ConnectionState.CONNECTED
|
| 237 |
+
and not self._circuit_breaker.is_open
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
+
@property
|
| 241 |
+
def client(self) -> TTRPGToolkitClient:
|
| 242 |
+
"""Get the underlying toolkit client."""
|
| 243 |
+
return self._client
|
| 244 |
+
|
| 245 |
+
def get_status(self) -> MCPConnectionStatus:
|
| 246 |
+
"""Get detailed connection status."""
|
| 247 |
+
return MCPConnectionStatus(
|
| 248 |
+
state=self._state,
|
| 249 |
+
is_available=self.is_available,
|
| 250 |
+
url=self._client.url,
|
| 251 |
+
last_successful_call=self._last_successful_call,
|
| 252 |
+
consecutive_failures=self._consecutive_failures,
|
| 253 |
+
circuit_breaker_state=self._circuit_breaker.state,
|
| 254 |
+
tools_count=self._client.tools_count,
|
| 255 |
+
error_message=self._last_error,
|
| 256 |
+
)
|
| 257 |
+
|
| 258 |
+
async def connect(self) -> bool:
|
| 259 |
+
"""
|
| 260 |
+
Connect to MCP server with retry logic.
|
| 261 |
+
|
| 262 |
+
Returns:
|
| 263 |
+
True if connection successful, False otherwise.
|
| 264 |
+
"""
|
| 265 |
+
self._state = ConnectionState.CONNECTING
|
| 266 |
+
logger.info("Attempting to connect to MCP server...")
|
| 267 |
+
|
| 268 |
+
for attempt in range(self._max_retries):
|
| 269 |
+
try:
|
| 270 |
+
await self._client.connect()
|
| 271 |
+
self._state = ConnectionState.CONNECTED
|
| 272 |
+
self._consecutive_failures = 0
|
| 273 |
+
self._last_successful_call = datetime.now()
|
| 274 |
+
self._last_error = None
|
| 275 |
+
self._circuit_breaker.reset()
|
| 276 |
+
|
| 277 |
+
logger.info("Successfully connected to MCP server")
|
| 278 |
+
return True
|
| 279 |
+
|
| 280 |
+
except Exception as e:
|
| 281 |
+
self._consecutive_failures += 1
|
| 282 |
+
self._last_error = str(e)
|
| 283 |
+
logger.warning(
|
| 284 |
+
f"Connection attempt {attempt + 1}/{self._max_retries} failed: {e}"
|
| 285 |
+
)
|
| 286 |
+
|
| 287 |
+
if attempt < self._max_retries - 1:
|
| 288 |
+
delay = self._calculate_backoff_delay(attempt)
|
| 289 |
+
logger.info(f"Retrying in {delay:.2f} seconds...")
|
| 290 |
+
await asyncio.sleep(delay)
|
| 291 |
+
|
| 292 |
+
self._state = ConnectionState.ERROR
|
| 293 |
+
logger.error(f"Failed to connect after {self._max_retries} attempts")
|
| 294 |
+
return False
|
| 295 |
+
|
| 296 |
+
async def disconnect(self) -> None:
|
| 297 |
+
"""Disconnect from MCP server."""
|
| 298 |
+
# Stop health check task if running
|
| 299 |
+
if self._health_check_task and not self._health_check_task.done():
|
| 300 |
+
self._health_check_task.cancel()
|
| 301 |
+
with contextlib.suppress(asyncio.CancelledError):
|
| 302 |
+
await self._health_check_task
|
| 303 |
+
|
| 304 |
+
await self._client.disconnect()
|
| 305 |
+
self._state = ConnectionState.DISCONNECTED
|
| 306 |
+
logger.info("Disconnected from MCP server")
|
| 307 |
+
|
| 308 |
+
async def health_check(self) -> bool:
|
| 309 |
+
"""
|
| 310 |
+
Perform health check by listing tools.
|
| 311 |
+
|
| 312 |
+
Returns:
|
| 313 |
+
True if healthy, False otherwise.
|
| 314 |
+
"""
|
| 315 |
+
try:
|
| 316 |
+
await self._client.list_tool_names()
|
| 317 |
+
self._last_successful_call = datetime.now()
|
| 318 |
+
self._consecutive_failures = 0
|
| 319 |
+
self._circuit_breaker.record_success()
|
| 320 |
+
return True
|
| 321 |
+
|
| 322 |
+
except Exception as e:
|
| 323 |
+
self._consecutive_failures += 1
|
| 324 |
+
self._circuit_breaker.record_failure()
|
| 325 |
+
logger.warning(f"Health check failed: {e}")
|
| 326 |
+
return False
|
| 327 |
+
|
| 328 |
+
async def get_tools(
|
| 329 |
+
self,
|
| 330 |
+
categories: Sequence[str] | None = None,
|
| 331 |
+
) -> Sequence[FunctionTool]:
|
| 332 |
+
"""
|
| 333 |
+
Get tools with automatic reconnection on failure.
|
| 334 |
+
|
| 335 |
+
Args:
|
| 336 |
+
categories: Optional list of categories to filter.
|
| 337 |
+
|
| 338 |
+
Returns:
|
| 339 |
+
Sequence of FunctionTool objects.
|
| 340 |
+
|
| 341 |
+
Raises:
|
| 342 |
+
MCPUnavailableError: If MCP is unavailable after reconnection attempt.
|
| 343 |
+
"""
|
| 344 |
+
if not self.is_available:
|
| 345 |
+
await self._attempt_reconnect()
|
| 346 |
+
|
| 347 |
+
if not self.is_available:
|
| 348 |
+
raise MCPUnavailableError(
|
| 349 |
+
"MCP server is unavailable",
|
| 350 |
+
reason=self._last_error,
|
| 351 |
+
)
|
| 352 |
+
|
| 353 |
+
try:
|
| 354 |
+
tools: Sequence[FunctionTool]
|
| 355 |
+
if categories:
|
| 356 |
+
tools = await self._client.get_tools_by_category(categories)
|
| 357 |
+
else:
|
| 358 |
+
tools = await self._client.get_all_tools()
|
| 359 |
+
|
| 360 |
+
self._last_successful_call = datetime.now()
|
| 361 |
+
self._circuit_breaker.record_success()
|
| 362 |
+
return tools
|
| 363 |
+
|
| 364 |
+
except Exception as e:
|
| 365 |
+
self._circuit_breaker.record_failure()
|
| 366 |
+
self._last_error = str(e)
|
| 367 |
+
logger.error(f"Failed to get tools: {e}")
|
| 368 |
+
|
| 369 |
+
# Try reconnection
|
| 370 |
+
if await self._attempt_reconnect():
|
| 371 |
+
# Retry after reconnection
|
| 372 |
+
if categories:
|
| 373 |
+
return await self._client.get_tools_by_category(categories)
|
| 374 |
+
return await self._client.get_all_tools()
|
| 375 |
+
|
| 376 |
+
raise MCPUnavailableError(
|
| 377 |
+
"Unable to get tools after reconnection",
|
| 378 |
+
reason=str(e),
|
| 379 |
+
) from e
|
| 380 |
+
|
| 381 |
+
async def execute_tool(
|
| 382 |
+
self,
|
| 383 |
+
tool_name: str,
|
| 384 |
+
arguments: dict[str, Any],
|
| 385 |
+
) -> Any:
|
| 386 |
+
"""
|
| 387 |
+
Execute a tool with connection management.
|
| 388 |
+
|
| 389 |
+
Args:
|
| 390 |
+
tool_name: Name of the tool to call.
|
| 391 |
+
arguments: Tool arguments.
|
| 392 |
+
|
| 393 |
+
Returns:
|
| 394 |
+
Tool result.
|
| 395 |
+
|
| 396 |
+
Raises:
|
| 397 |
+
MCPCircuitBreakerOpenError: If circuit breaker is open.
|
| 398 |
+
MCPUnavailableError: If MCP is unavailable.
|
| 399 |
+
"""
|
| 400 |
+
# Check circuit breaker
|
| 401 |
+
if not self._circuit_breaker.allow_request():
|
| 402 |
+
retry_after = self._circuit_breaker.time_until_retry
|
| 403 |
+
raise MCPCircuitBreakerOpenError(retry_after_seconds=retry_after)
|
| 404 |
+
|
| 405 |
+
if not self.is_available:
|
| 406 |
+
await self._attempt_reconnect()
|
| 407 |
+
|
| 408 |
+
if not self.is_available:
|
| 409 |
+
raise MCPUnavailableError(
|
| 410 |
+
"MCP server is unavailable",
|
| 411 |
+
reason=self._last_error,
|
| 412 |
+
)
|
| 413 |
+
|
| 414 |
+
try:
|
| 415 |
+
result = await self._client.call_tool(tool_name, arguments)
|
| 416 |
+
self._last_successful_call = datetime.now()
|
| 417 |
+
self._consecutive_failures = 0
|
| 418 |
+
self._circuit_breaker.record_success()
|
| 419 |
+
return result
|
| 420 |
+
|
| 421 |
+
except Exception as e:
|
| 422 |
+
self._consecutive_failures += 1
|
| 423 |
+
self._circuit_breaker.record_failure()
|
| 424 |
+
self._last_error = str(e)
|
| 425 |
+
raise
|
| 426 |
+
|
| 427 |
+
async def execute_with_fallback(
|
| 428 |
+
self,
|
| 429 |
+
tool_name: str,
|
| 430 |
+
arguments: dict[str, Any],
|
| 431 |
+
) -> Any:
|
| 432 |
+
"""
|
| 433 |
+
Execute tool with automatic fallback on failure.
|
| 434 |
+
|
| 435 |
+
If MCP fails and a fallback handler can handle the tool,
|
| 436 |
+
uses the fallback. Otherwise, raises the original error.
|
| 437 |
+
|
| 438 |
+
Args:
|
| 439 |
+
tool_name: Name of the tool to call.
|
| 440 |
+
arguments: Tool arguments.
|
| 441 |
+
|
| 442 |
+
Returns:
|
| 443 |
+
Tool result (from MCP or fallback).
|
| 444 |
+
|
| 445 |
+
Raises:
|
| 446 |
+
MCPUnavailableError: If MCP fails and no fallback available.
|
| 447 |
+
"""
|
| 448 |
+
try:
|
| 449 |
+
return await self.execute_tool(tool_name, arguments)
|
| 450 |
+
|
| 451 |
+
except (MCPUnavailableError, MCPCircuitBreakerOpenError) as e:
|
| 452 |
+
# Try fallback
|
| 453 |
+
if self._fallback_handler.can_handle(tool_name):
|
| 454 |
+
logger.info(f"Using fallback for tool '{tool_name}'")
|
| 455 |
+
return await self._fallback_handler.handle(tool_name, arguments)
|
| 456 |
+
|
| 457 |
+
# No fallback available
|
| 458 |
+
raise MCPUnavailableError(
|
| 459 |
+
f"MCP unavailable and no fallback for '{tool_name}'",
|
| 460 |
+
reason=str(e),
|
| 461 |
+
) from e
|
| 462 |
+
|
| 463 |
+
async def _attempt_reconnect(self) -> bool:
|
| 464 |
+
"""
|
| 465 |
+
Attempt reconnection with exponential backoff.
|
| 466 |
+
|
| 467 |
+
Returns:
|
| 468 |
+
True if reconnection successful, False otherwise.
|
| 469 |
+
"""
|
| 470 |
+
if self._state == ConnectionState.RECONNECTING:
|
| 471 |
+
# Already reconnecting
|
| 472 |
+
return False
|
| 473 |
+
|
| 474 |
+
self._state = ConnectionState.RECONNECTING
|
| 475 |
+
logger.info("Attempting to reconnect to MCP server...")
|
| 476 |
+
|
| 477 |
+
for attempt in range(self._max_retries):
|
| 478 |
+
try:
|
| 479 |
+
await self._client.disconnect()
|
| 480 |
+
await self._client.connect()
|
| 481 |
+
|
| 482 |
+
self._state = ConnectionState.CONNECTED
|
| 483 |
+
self._consecutive_failures = 0
|
| 484 |
+
self._last_successful_call = datetime.now()
|
| 485 |
+
self._circuit_breaker.reset()
|
| 486 |
+
|
| 487 |
+
logger.info("Reconnection successful")
|
| 488 |
+
return True
|
| 489 |
+
|
| 490 |
+
except Exception as e:
|
| 491 |
+
logger.warning(f"Reconnection attempt {attempt + 1} failed: {e}")
|
| 492 |
+
delay = self._calculate_backoff_delay(attempt)
|
| 493 |
+
await asyncio.sleep(delay)
|
| 494 |
+
|
| 495 |
+
self._state = ConnectionState.ERROR
|
| 496 |
+
logger.error("All reconnection attempts failed")
|
| 497 |
+
return False
|
| 498 |
+
|
| 499 |
+
def _calculate_backoff_delay(self, attempt: int) -> float:
|
| 500 |
+
"""
|
| 501 |
+
Calculate delay with exponential backoff and jitter.
|
| 502 |
+
|
| 503 |
+
Args:
|
| 504 |
+
attempt: Current attempt number (0-indexed).
|
| 505 |
+
|
| 506 |
+
Returns:
|
| 507 |
+
Delay in seconds.
|
| 508 |
+
"""
|
| 509 |
+
# Exponential backoff
|
| 510 |
+
delay: float = self._retry_delay * (2**attempt)
|
| 511 |
+
|
| 512 |
+
# Cap at 30 seconds
|
| 513 |
+
delay = min(delay, 30.0)
|
| 514 |
+
|
| 515 |
+
# Add jitter (10% random variation)
|
| 516 |
+
jitter: float = delay * 0.1 * random.random()
|
| 517 |
+
delay += jitter
|
| 518 |
+
|
| 519 |
+
return delay
|
| 520 |
+
|
| 521 |
+
async def start_health_monitoring(self) -> None:
|
| 522 |
+
"""Start background health check monitoring."""
|
| 523 |
+
if self._health_check_task and not self._health_check_task.done():
|
| 524 |
+
logger.warning("Health monitoring already running")
|
| 525 |
+
return
|
| 526 |
+
|
| 527 |
+
self._health_check_task = asyncio.create_task(self._health_check_loop())
|
| 528 |
+
logger.info("Started health check monitoring")
|
| 529 |
+
|
| 530 |
+
async def stop_health_monitoring(self) -> None:
|
| 531 |
+
"""Stop background health check monitoring."""
|
| 532 |
+
if self._health_check_task and not self._health_check_task.done():
|
| 533 |
+
self._health_check_task.cancel()
|
| 534 |
+
with contextlib.suppress(asyncio.CancelledError):
|
| 535 |
+
await self._health_check_task
|
| 536 |
+
logger.info("Stopped health check monitoring")
|
| 537 |
+
|
| 538 |
+
async def _health_check_loop(self) -> None:
|
| 539 |
+
"""Background task for periodic health checks."""
|
| 540 |
+
while True:
|
| 541 |
+
try:
|
| 542 |
+
await asyncio.sleep(self._health_check_interval)
|
| 543 |
+
|
| 544 |
+
if self._state == ConnectionState.CONNECTED:
|
| 545 |
+
healthy = await self.health_check()
|
| 546 |
+
if not healthy:
|
| 547 |
+
logger.warning("Health check failed, attempting reconnection")
|
| 548 |
+
await self._attempt_reconnect()
|
| 549 |
+
|
| 550 |
+
except asyncio.CancelledError:
|
| 551 |
+
break
|
| 552 |
+
except Exception as e:
|
| 553 |
+
logger.error(f"Health check loop error: {e}")
|
| 554 |
+
|
| 555 |
+
def get_unavailable_message(self) -> str:
|
| 556 |
+
"""Get user-friendly message when MCP is unavailable."""
|
| 557 |
+
return self._fallback_handler.get_unavailable_message()
|
| 558 |
+
|
| 559 |
+
def __repr__(self) -> str:
|
| 560 |
+
"""String representation."""
|
| 561 |
+
return (
|
| 562 |
+
f"ConnectionManager(state={self._state.value}, "
|
| 563 |
+
f"available={self.is_available}, "
|
| 564 |
+
f"failures={self._consecutive_failures})"
|
| 565 |
+
)
|
src/mcp_integration/exceptions.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DungeonMaster AI - MCP Integration Exceptions
|
| 3 |
+
|
| 4 |
+
Custom exception hierarchy for clear error handling in MCP operations.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class MCPIntegrationError(Exception):
|
| 11 |
+
"""Base exception for all MCP integration errors."""
|
| 12 |
+
|
| 13 |
+
def __init__(self, message: str = "An MCP integration error occurred") -> None:
|
| 14 |
+
self.message = message
|
| 15 |
+
super().__init__(self.message)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class MCPConnectionError(MCPIntegrationError):
|
| 19 |
+
"""Raised when connection to MCP server fails."""
|
| 20 |
+
|
| 21 |
+
def __init__(
|
| 22 |
+
self,
|
| 23 |
+
message: str = "Failed to connect to MCP server",
|
| 24 |
+
url: str | None = None,
|
| 25 |
+
) -> None:
|
| 26 |
+
self.url = url
|
| 27 |
+
if url:
|
| 28 |
+
message = f"{message}: {url}"
|
| 29 |
+
super().__init__(message)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class MCPTimeoutError(MCPIntegrationError):
|
| 33 |
+
"""Raised when an MCP operation times out."""
|
| 34 |
+
|
| 35 |
+
def __init__(
|
| 36 |
+
self,
|
| 37 |
+
message: str = "MCP operation timed out",
|
| 38 |
+
timeout_seconds: float | None = None,
|
| 39 |
+
operation: str | None = None,
|
| 40 |
+
) -> None:
|
| 41 |
+
self.timeout_seconds = timeout_seconds
|
| 42 |
+
self.operation = operation
|
| 43 |
+
if timeout_seconds and operation:
|
| 44 |
+
message = f"{message}: {operation} after {timeout_seconds}s"
|
| 45 |
+
elif timeout_seconds:
|
| 46 |
+
message = f"{message} after {timeout_seconds}s"
|
| 47 |
+
super().__init__(message)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class MCPToolNotFoundError(MCPIntegrationError):
|
| 51 |
+
"""Raised when a requested tool does not exist."""
|
| 52 |
+
|
| 53 |
+
def __init__(self, tool_name: str) -> None:
|
| 54 |
+
self.tool_name = tool_name
|
| 55 |
+
super().__init__(f"Tool not found: {tool_name}")
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class MCPToolExecutionError(MCPIntegrationError):
|
| 59 |
+
"""Raised when tool execution fails."""
|
| 60 |
+
|
| 61 |
+
def __init__(
|
| 62 |
+
self,
|
| 63 |
+
tool_name: str,
|
| 64 |
+
original_error: Exception | None = None,
|
| 65 |
+
message: str | None = None,
|
| 66 |
+
) -> None:
|
| 67 |
+
self.tool_name = tool_name
|
| 68 |
+
self.original_error = original_error
|
| 69 |
+
if message:
|
| 70 |
+
error_msg = message
|
| 71 |
+
elif original_error:
|
| 72 |
+
error_msg = f"Tool '{tool_name}' execution failed: {original_error}"
|
| 73 |
+
else:
|
| 74 |
+
error_msg = f"Tool '{tool_name}' execution failed"
|
| 75 |
+
super().__init__(error_msg)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
class MCPUnavailableError(MCPIntegrationError):
|
| 79 |
+
"""Raised when the MCP server is unavailable."""
|
| 80 |
+
|
| 81 |
+
def __init__(
|
| 82 |
+
self,
|
| 83 |
+
message: str = "MCP server is currently unavailable",
|
| 84 |
+
reason: str | None = None,
|
| 85 |
+
) -> None:
|
| 86 |
+
self.reason = reason
|
| 87 |
+
if reason:
|
| 88 |
+
message = f"{message}: {reason}"
|
| 89 |
+
super().__init__(message)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
class MCPCircuitBreakerOpenError(MCPIntegrationError):
|
| 93 |
+
"""Raised when the circuit breaker is open and rejecting requests."""
|
| 94 |
+
|
| 95 |
+
def __init__(
|
| 96 |
+
self,
|
| 97 |
+
message: str = "Circuit breaker is open - too many recent failures",
|
| 98 |
+
retry_after_seconds: float | None = None,
|
| 99 |
+
) -> None:
|
| 100 |
+
self.retry_after_seconds = retry_after_seconds
|
| 101 |
+
if retry_after_seconds:
|
| 102 |
+
message = f"{message}. Retry after {retry_after_seconds:.1f}s"
|
| 103 |
+
super().__init__(message)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
class MCPInvalidResponseError(MCPIntegrationError):
|
| 107 |
+
"""Raised when MCP server returns an invalid or unexpected response."""
|
| 108 |
+
|
| 109 |
+
def __init__(
|
| 110 |
+
self,
|
| 111 |
+
message: str = "Invalid response from MCP server",
|
| 112 |
+
tool_name: str | None = None,
|
| 113 |
+
response: object | None = None,
|
| 114 |
+
) -> None:
|
| 115 |
+
self.tool_name = tool_name
|
| 116 |
+
self.response = response
|
| 117 |
+
if tool_name:
|
| 118 |
+
message = f"{message} for tool '{tool_name}'"
|
| 119 |
+
super().__init__(message)
|
src/mcp_integration/fallbacks.py
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DungeonMaster AI - MCP Fallback Handlers
|
| 3 |
+
|
| 4 |
+
Provides fallback functionality when MCP server is unavailable.
|
| 5 |
+
Local dice rolling ensures basic gameplay can continue.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import logging
|
| 11 |
+
import random
|
| 12 |
+
import re
|
| 13 |
+
from datetime import datetime
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class FallbackHandler:
|
| 19 |
+
"""
|
| 20 |
+
Provides fallback functionality when MCP server is unavailable.
|
| 21 |
+
|
| 22 |
+
Currently supports:
|
| 23 |
+
- Basic dice rolling (roll, roll_check)
|
| 24 |
+
|
| 25 |
+
Future fallbacks could include:
|
| 26 |
+
- Cached monster/spell data
|
| 27 |
+
- Basic character stat lookups
|
| 28 |
+
"""
|
| 29 |
+
|
| 30 |
+
# Tools that have fallback implementations
|
| 31 |
+
SUPPORTED_TOOLS: set[str] = {"roll", "roll_check", "mcp_roll", "mcp_roll_check"}
|
| 32 |
+
|
| 33 |
+
# Regex for parsing dice notation
|
| 34 |
+
# Matches: 2d6, 1d20+5, 4d6-2, d8, 2d10+3
|
| 35 |
+
DICE_PATTERN = re.compile(
|
| 36 |
+
r"^(\d*)d(\d+)([+-]\d+)?$",
|
| 37 |
+
re.IGNORECASE,
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
# Advanced dice notation (keep highest/lowest)
|
| 41 |
+
# Matches: 4d6kh3, 2d20kl1
|
| 42 |
+
ADVANCED_DICE_PATTERN = re.compile(
|
| 43 |
+
r"^(\d+)d(\d+)(k[hl])(\d+)?([+-]\d+)?$",
|
| 44 |
+
re.IGNORECASE,
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
def can_handle(self, tool_name: str) -> bool:
|
| 48 |
+
"""Check if a fallback exists for this tool."""
|
| 49 |
+
return tool_name in self.SUPPORTED_TOOLS
|
| 50 |
+
|
| 51 |
+
def get_unavailable_message(self) -> str:
|
| 52 |
+
"""User-friendly message about limited functionality."""
|
| 53 |
+
return (
|
| 54 |
+
"The game server is temporarily unavailable. "
|
| 55 |
+
"Some features like rules lookup and character management are limited, "
|
| 56 |
+
"but basic dice rolling still works."
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
async def handle_roll(
|
| 60 |
+
self,
|
| 61 |
+
notation: str,
|
| 62 |
+
reason: str | None = None,
|
| 63 |
+
secret: bool = False,
|
| 64 |
+
) -> dict[str, object]:
|
| 65 |
+
"""
|
| 66 |
+
Local dice rolling fallback.
|
| 67 |
+
|
| 68 |
+
Supports:
|
| 69 |
+
- Basic: 2d6, 1d20+5, d8
|
| 70 |
+
- Keep highest: 4d6kh3 (roll 4d6, keep highest 3)
|
| 71 |
+
- Keep lowest: 2d20kl1 (disadvantage)
|
| 72 |
+
|
| 73 |
+
Args:
|
| 74 |
+
notation: Dice notation string
|
| 75 |
+
reason: Optional reason for the roll
|
| 76 |
+
secret: Whether this is a secret roll (not shown to players)
|
| 77 |
+
|
| 78 |
+
Returns:
|
| 79 |
+
Result dict compatible with MCP roll tool format
|
| 80 |
+
"""
|
| 81 |
+
notation = notation.strip().lower()
|
| 82 |
+
timestamp = datetime.now().isoformat()
|
| 83 |
+
|
| 84 |
+
# Try advanced notation first (keep highest/lowest)
|
| 85 |
+
advanced_match = self.ADVANCED_DICE_PATTERN.match(notation)
|
| 86 |
+
if advanced_match:
|
| 87 |
+
return self._roll_advanced(
|
| 88 |
+
advanced_match,
|
| 89 |
+
notation,
|
| 90 |
+
reason,
|
| 91 |
+
secret,
|
| 92 |
+
timestamp,
|
| 93 |
+
)
|
| 94 |
+
|
| 95 |
+
# Try basic notation
|
| 96 |
+
basic_match = self.DICE_PATTERN.match(notation)
|
| 97 |
+
if basic_match:
|
| 98 |
+
return self._roll_basic(
|
| 99 |
+
basic_match,
|
| 100 |
+
notation,
|
| 101 |
+
reason,
|
| 102 |
+
secret,
|
| 103 |
+
timestamp,
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
# Invalid notation
|
| 107 |
+
logger.warning(f"Invalid dice notation in fallback: {notation}")
|
| 108 |
+
return {
|
| 109 |
+
"success": False,
|
| 110 |
+
"error": f"Invalid dice notation: {notation}",
|
| 111 |
+
"degraded_mode": True,
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
def _roll_basic(
|
| 115 |
+
self,
|
| 116 |
+
match: re.Match[str],
|
| 117 |
+
notation: str,
|
| 118 |
+
reason: str | None,
|
| 119 |
+
secret: bool,
|
| 120 |
+
timestamp: str,
|
| 121 |
+
) -> dict[str, object]:
|
| 122 |
+
"""Handle basic dice notation like 2d6+3."""
|
| 123 |
+
num_dice = int(match.group(1)) if match.group(1) else 1
|
| 124 |
+
die_size = int(match.group(2))
|
| 125 |
+
modifier = int(match.group(3)) if match.group(3) else 0
|
| 126 |
+
|
| 127 |
+
# Sanity checks
|
| 128 |
+
if num_dice < 1 or num_dice > 100:
|
| 129 |
+
return {
|
| 130 |
+
"success": False,
|
| 131 |
+
"error": f"Invalid number of dice: {num_dice} (must be 1-100)",
|
| 132 |
+
"degraded_mode": True,
|
| 133 |
+
}
|
| 134 |
+
if die_size < 2 or die_size > 100:
|
| 135 |
+
return {
|
| 136 |
+
"success": False,
|
| 137 |
+
"error": f"Invalid die size: d{die_size} (must be d2-d100)",
|
| 138 |
+
"degraded_mode": True,
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
# Roll the dice
|
| 142 |
+
rolls = [random.randint(1, die_size) for _ in range(num_dice)]
|
| 143 |
+
total = sum(rolls) + modifier
|
| 144 |
+
|
| 145 |
+
# Build breakdown string
|
| 146 |
+
if modifier > 0:
|
| 147 |
+
breakdown = f"[{', '.join(map(str, rolls))}] + {modifier}"
|
| 148 |
+
elif modifier < 0:
|
| 149 |
+
breakdown = f"[{', '.join(map(str, rolls))}] - {abs(modifier)}"
|
| 150 |
+
else:
|
| 151 |
+
breakdown = f"[{', '.join(map(str, rolls))}]"
|
| 152 |
+
|
| 153 |
+
return {
|
| 154 |
+
"success": True,
|
| 155 |
+
"notation": notation,
|
| 156 |
+
"total": total,
|
| 157 |
+
"rolls": rolls,
|
| 158 |
+
"dice": rolls, # Alias for compatibility
|
| 159 |
+
"individual_rolls": rolls, # Another alias
|
| 160 |
+
"modifier": modifier,
|
| 161 |
+
"breakdown": breakdown,
|
| 162 |
+
"formatted": f"{notation} = {breakdown} = {total}",
|
| 163 |
+
"reason": reason or "",
|
| 164 |
+
"secret": secret,
|
| 165 |
+
"timestamp": timestamp,
|
| 166 |
+
"degraded_mode": True,
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
def _roll_advanced(
|
| 170 |
+
self,
|
| 171 |
+
match: re.Match[str],
|
| 172 |
+
notation: str,
|
| 173 |
+
reason: str | None,
|
| 174 |
+
secret: bool,
|
| 175 |
+
timestamp: str,
|
| 176 |
+
) -> dict[str, object]:
|
| 177 |
+
"""Handle advanced dice notation like 4d6kh3."""
|
| 178 |
+
num_dice = int(match.group(1))
|
| 179 |
+
die_size = int(match.group(2))
|
| 180 |
+
keep_type = match.group(3).lower() # kh or kl
|
| 181 |
+
keep_count = int(match.group(4)) if match.group(4) else 1
|
| 182 |
+
modifier = int(match.group(5)) if match.group(5) else 0
|
| 183 |
+
|
| 184 |
+
# Sanity checks
|
| 185 |
+
if num_dice < 1 or num_dice > 100:
|
| 186 |
+
return {
|
| 187 |
+
"success": False,
|
| 188 |
+
"error": f"Invalid number of dice: {num_dice}",
|
| 189 |
+
"degraded_mode": True,
|
| 190 |
+
}
|
| 191 |
+
if keep_count > num_dice:
|
| 192 |
+
return {
|
| 193 |
+
"success": False,
|
| 194 |
+
"error": f"Cannot keep {keep_count} dice from {num_dice} rolled",
|
| 195 |
+
"degraded_mode": True,
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
# Roll all dice
|
| 199 |
+
rolls = [random.randint(1, die_size) for _ in range(num_dice)]
|
| 200 |
+
|
| 201 |
+
# Keep highest or lowest
|
| 202 |
+
sorted_rolls = sorted(rolls, reverse=(keep_type == "kh"))
|
| 203 |
+
kept_rolls = sorted_rolls[:keep_count]
|
| 204 |
+
dropped_rolls = sorted_rolls[keep_count:]
|
| 205 |
+
|
| 206 |
+
total = sum(kept_rolls) + modifier
|
| 207 |
+
|
| 208 |
+
# Build breakdown string
|
| 209 |
+
kept_str = f"[{', '.join(map(str, kept_rolls))}]"
|
| 210 |
+
dropped_str = f" (dropped: {', '.join(map(str, dropped_rolls))})" if dropped_rolls else ""
|
| 211 |
+
|
| 212 |
+
if modifier > 0:
|
| 213 |
+
breakdown = f"{kept_str}{dropped_str} + {modifier}"
|
| 214 |
+
elif modifier < 0:
|
| 215 |
+
breakdown = f"{kept_str}{dropped_str} - {abs(modifier)}"
|
| 216 |
+
else:
|
| 217 |
+
breakdown = f"{kept_str}{dropped_str}"
|
| 218 |
+
|
| 219 |
+
return {
|
| 220 |
+
"success": True,
|
| 221 |
+
"notation": notation,
|
| 222 |
+
"total": total,
|
| 223 |
+
"rolls": rolls,
|
| 224 |
+
"dice": rolls,
|
| 225 |
+
"individual_rolls": rolls,
|
| 226 |
+
"kept_rolls": kept_rolls,
|
| 227 |
+
"dropped_rolls": dropped_rolls,
|
| 228 |
+
"modifier": modifier,
|
| 229 |
+
"breakdown": breakdown,
|
| 230 |
+
"formatted": f"{notation} = {breakdown} = {total}",
|
| 231 |
+
"reason": reason or "",
|
| 232 |
+
"secret": secret,
|
| 233 |
+
"timestamp": timestamp,
|
| 234 |
+
"degraded_mode": True,
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
async def handle_roll_check(
|
| 238 |
+
self,
|
| 239 |
+
modifier: int = 0,
|
| 240 |
+
dc: int | None = None,
|
| 241 |
+
advantage: bool = False,
|
| 242 |
+
disadvantage: bool = False,
|
| 243 |
+
skill_name: str | None = None,
|
| 244 |
+
) -> dict[str, object]:
|
| 245 |
+
"""
|
| 246 |
+
Local ability check/save fallback.
|
| 247 |
+
|
| 248 |
+
Rolls 1d20 with modifier, handles advantage/disadvantage.
|
| 249 |
+
|
| 250 |
+
Args:
|
| 251 |
+
modifier: Modifier to add to the roll
|
| 252 |
+
dc: Difficulty class to check against
|
| 253 |
+
advantage: Roll with advantage (2d20 keep highest)
|
| 254 |
+
disadvantage: Roll with disadvantage (2d20 keep lowest)
|
| 255 |
+
skill_name: Name of skill/ability for logging
|
| 256 |
+
|
| 257 |
+
Returns:
|
| 258 |
+
Result dict compatible with MCP roll_check format
|
| 259 |
+
"""
|
| 260 |
+
timestamp = datetime.now().isoformat()
|
| 261 |
+
|
| 262 |
+
# Roll d20(s)
|
| 263 |
+
if advantage and not disadvantage:
|
| 264 |
+
rolls = [random.randint(1, 20), random.randint(1, 20)]
|
| 265 |
+
d20_result = max(rolls)
|
| 266 |
+
roll_type = "advantage"
|
| 267 |
+
elif disadvantage and not advantage:
|
| 268 |
+
rolls = [random.randint(1, 20), random.randint(1, 20)]
|
| 269 |
+
d20_result = min(rolls)
|
| 270 |
+
roll_type = "disadvantage"
|
| 271 |
+
else:
|
| 272 |
+
rolls = [random.randint(1, 20)]
|
| 273 |
+
d20_result = rolls[0]
|
| 274 |
+
roll_type = "normal"
|
| 275 |
+
|
| 276 |
+
total = d20_result + modifier
|
| 277 |
+
|
| 278 |
+
# Check success/failure
|
| 279 |
+
success = None
|
| 280 |
+
result_str = ""
|
| 281 |
+
if dc is not None:
|
| 282 |
+
success = total >= dc
|
| 283 |
+
result_str = "SUCCESS" if success else "FAILURE"
|
| 284 |
+
|
| 285 |
+
# Build breakdown
|
| 286 |
+
if len(rolls) > 1:
|
| 287 |
+
rolls_str = f"[{rolls[0]}, {rolls[1]}] → {d20_result}"
|
| 288 |
+
else:
|
| 289 |
+
rolls_str = str(d20_result)
|
| 290 |
+
|
| 291 |
+
if modifier >= 0:
|
| 292 |
+
breakdown = f"{rolls_str} + {modifier}"
|
| 293 |
+
else:
|
| 294 |
+
breakdown = f"{rolls_str} - {abs(modifier)}"
|
| 295 |
+
|
| 296 |
+
# Build formatted string
|
| 297 |
+
skill_part = f" ({skill_name})" if skill_name else ""
|
| 298 |
+
dc_part = f" vs DC {dc}" if dc is not None else ""
|
| 299 |
+
result_part = f" - {result_str}" if result_str else ""
|
| 300 |
+
formatted = f"d20{skill_part} = {breakdown} = {total}{dc_part}{result_part}"
|
| 301 |
+
|
| 302 |
+
return {
|
| 303 |
+
"success": True,
|
| 304 |
+
"total": total,
|
| 305 |
+
"d20_result": d20_result,
|
| 306 |
+
"rolls": rolls,
|
| 307 |
+
"modifier": modifier,
|
| 308 |
+
"dc": dc,
|
| 309 |
+
"check_success": success,
|
| 310 |
+
"result": result_str.lower() if result_str else None,
|
| 311 |
+
"roll_type": roll_type,
|
| 312 |
+
"is_critical": d20_result == 20,
|
| 313 |
+
"is_fumble": d20_result == 1,
|
| 314 |
+
"skill_name": skill_name,
|
| 315 |
+
"breakdown": breakdown,
|
| 316 |
+
"formatted": formatted,
|
| 317 |
+
"timestamp": timestamp,
|
| 318 |
+
"degraded_mode": True,
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
async def handle(
|
| 322 |
+
self,
|
| 323 |
+
tool_name: str,
|
| 324 |
+
arguments: dict[str, object],
|
| 325 |
+
) -> dict[str, object]:
|
| 326 |
+
"""
|
| 327 |
+
Route a tool call to the appropriate fallback handler.
|
| 328 |
+
|
| 329 |
+
Args:
|
| 330 |
+
tool_name: Name of the tool
|
| 331 |
+
arguments: Tool arguments
|
| 332 |
+
|
| 333 |
+
Returns:
|
| 334 |
+
Fallback result or error dict
|
| 335 |
+
"""
|
| 336 |
+
# Normalize tool name (remove mcp_ prefix if present)
|
| 337 |
+
normalized_name = tool_name.replace("mcp_", "")
|
| 338 |
+
|
| 339 |
+
if normalized_name == "roll":
|
| 340 |
+
reason_val = arguments.get("reason")
|
| 341 |
+
return await self.handle_roll(
|
| 342 |
+
notation=str(arguments.get("notation", "1d20")),
|
| 343 |
+
reason=str(reason_val) if reason_val else None,
|
| 344 |
+
secret=bool(arguments.get("secret", False)),
|
| 345 |
+
)
|
| 346 |
+
elif normalized_name == "roll_check":
|
| 347 |
+
dc_val = arguments.get("dc")
|
| 348 |
+
return await self.handle_roll_check(
|
| 349 |
+
modifier=int(str(arguments.get("modifier", 0))),
|
| 350 |
+
dc=int(str(dc_val)) if dc_val is not None else None,
|
| 351 |
+
advantage=bool(arguments.get("advantage", False)),
|
| 352 |
+
disadvantage=bool(arguments.get("disadvantage", False)),
|
| 353 |
+
skill_name=str(arguments.get("skill_name")) if arguments.get("skill_name") else None,
|
| 354 |
+
)
|
| 355 |
+
else:
|
| 356 |
+
return {
|
| 357 |
+
"success": False,
|
| 358 |
+
"error": f"No fallback available for tool: {tool_name}",
|
| 359 |
+
"degraded_mode": True,
|
| 360 |
+
}
|
src/mcp_integration/models.py
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DungeonMaster AI - MCP Integration Models
|
| 3 |
+
|
| 4 |
+
Pydantic models for enhanced tool results and protocols for game state access.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
from enum import Enum
|
| 11 |
+
from typing import Protocol, runtime_checkable
|
| 12 |
+
|
| 13 |
+
from pydantic import BaseModel, Field
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class ConnectionState(str, Enum):
|
| 17 |
+
"""Connection state for MCP server."""
|
| 18 |
+
|
| 19 |
+
DISCONNECTED = "disconnected"
|
| 20 |
+
CONNECTING = "connecting"
|
| 21 |
+
CONNECTED = "connected"
|
| 22 |
+
ERROR = "error"
|
| 23 |
+
RECONNECTING = "reconnecting"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class CircuitBreakerState(str, Enum):
|
| 27 |
+
"""Circuit breaker states for connection management."""
|
| 28 |
+
|
| 29 |
+
CLOSED = "closed" # Normal operation
|
| 30 |
+
OPEN = "open" # Rejecting requests due to failures
|
| 31 |
+
HALF_OPEN = "half_open" # Testing if service recovered
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class RollType(str, Enum):
|
| 35 |
+
"""Types of dice rolls for formatting."""
|
| 36 |
+
|
| 37 |
+
STANDARD = "standard"
|
| 38 |
+
ATTACK = "attack"
|
| 39 |
+
DAMAGE = "damage"
|
| 40 |
+
SAVE = "save"
|
| 41 |
+
CHECK = "check"
|
| 42 |
+
INITIATIVE = "initiative"
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
# =============================================================================
|
| 46 |
+
# Protocols for loose coupling
|
| 47 |
+
# =============================================================================
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@runtime_checkable
|
| 51 |
+
class GameStateProtocol(Protocol):
|
| 52 |
+
"""
|
| 53 |
+
Protocol for game state access.
|
| 54 |
+
|
| 55 |
+
This interface allows tool wrappers to update game state without
|
| 56 |
+
depending on the full GameState implementation (coming in Phase 4).
|
| 57 |
+
"""
|
| 58 |
+
|
| 59 |
+
session_id: str
|
| 60 |
+
in_combat: bool
|
| 61 |
+
party: list[str]
|
| 62 |
+
recent_events: list[dict[str, object]]
|
| 63 |
+
|
| 64 |
+
def add_event(
|
| 65 |
+
self,
|
| 66 |
+
event_type: str,
|
| 67 |
+
description: str,
|
| 68 |
+
data: dict[str, object],
|
| 69 |
+
) -> None:
|
| 70 |
+
"""Add an event to recent events."""
|
| 71 |
+
...
|
| 72 |
+
|
| 73 |
+
def get_character(self, character_id: str) -> dict[str, object] | None:
|
| 74 |
+
"""Get character data from cache."""
|
| 75 |
+
...
|
| 76 |
+
|
| 77 |
+
def update_character_cache(
|
| 78 |
+
self,
|
| 79 |
+
character_id: str,
|
| 80 |
+
data: dict[str, object],
|
| 81 |
+
) -> None:
|
| 82 |
+
"""Update character data in cache."""
|
| 83 |
+
...
|
| 84 |
+
|
| 85 |
+
def set_combat_state(self, combat_state: dict[str, object] | None) -> None:
|
| 86 |
+
"""Set or clear combat state."""
|
| 87 |
+
...
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
# =============================================================================
|
| 91 |
+
# Base Result Models
|
| 92 |
+
# =============================================================================
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
class FormattedResult(BaseModel):
|
| 96 |
+
"""
|
| 97 |
+
Base class for formatted tool results.
|
| 98 |
+
|
| 99 |
+
All tool wrappers return results in this format to provide
|
| 100 |
+
multiple output formats for different consumers.
|
| 101 |
+
"""
|
| 102 |
+
|
| 103 |
+
raw_result: dict[str, object] = Field(
|
| 104 |
+
default_factory=dict,
|
| 105 |
+
description="Original MCP tool result",
|
| 106 |
+
)
|
| 107 |
+
chat_display: str = Field(
|
| 108 |
+
default="",
|
| 109 |
+
description="Markdown formatted for chat display",
|
| 110 |
+
)
|
| 111 |
+
ui_data: dict[str, object] = Field(
|
| 112 |
+
default_factory=dict,
|
| 113 |
+
description="Structured data for UI components",
|
| 114 |
+
)
|
| 115 |
+
voice_narration: str = Field(
|
| 116 |
+
default="",
|
| 117 |
+
description="TTS-friendly plain text for voice narration",
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
# Side effect tracking
|
| 121 |
+
state_updated: bool = Field(
|
| 122 |
+
default=False,
|
| 123 |
+
description="Whether game state was updated",
|
| 124 |
+
)
|
| 125 |
+
events_logged: list[str] = Field(
|
| 126 |
+
default_factory=list,
|
| 127 |
+
description="List of event types logged to session",
|
| 128 |
+
)
|
| 129 |
+
ui_updates_needed: list[str] = Field(
|
| 130 |
+
default_factory=list,
|
| 131 |
+
description="UI components that need refresh",
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
# =============================================================================
|
| 136 |
+
# Dice Roll Models
|
| 137 |
+
# =============================================================================
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
class DiceRollResult(FormattedResult):
|
| 141 |
+
"""Enhanced dice roll result with game context."""
|
| 142 |
+
|
| 143 |
+
notation: str = Field(
|
| 144 |
+
default="",
|
| 145 |
+
description="Dice notation (e.g., '2d6+3')",
|
| 146 |
+
)
|
| 147 |
+
individual_rolls: list[int] = Field(
|
| 148 |
+
default_factory=list,
|
| 149 |
+
description="Individual dice results",
|
| 150 |
+
)
|
| 151 |
+
modifier: int = Field(
|
| 152 |
+
default=0,
|
| 153 |
+
description="Modifier applied to roll",
|
| 154 |
+
)
|
| 155 |
+
total: int = Field(
|
| 156 |
+
default=0,
|
| 157 |
+
description="Total roll result",
|
| 158 |
+
)
|
| 159 |
+
roll_type: RollType = Field(
|
| 160 |
+
default=RollType.STANDARD,
|
| 161 |
+
description="Type of roll for context",
|
| 162 |
+
)
|
| 163 |
+
is_critical: bool = Field(
|
| 164 |
+
default=False,
|
| 165 |
+
description="Natural 20 on d20",
|
| 166 |
+
)
|
| 167 |
+
is_fumble: bool = Field(
|
| 168 |
+
default=False,
|
| 169 |
+
description="Natural 1 on d20",
|
| 170 |
+
)
|
| 171 |
+
success: bool | None = Field(
|
| 172 |
+
default=None,
|
| 173 |
+
description="Success/failure for checks with DC",
|
| 174 |
+
)
|
| 175 |
+
dc: int | None = Field(
|
| 176 |
+
default=None,
|
| 177 |
+
description="Difficulty class if applicable",
|
| 178 |
+
)
|
| 179 |
+
reason: str = Field(
|
| 180 |
+
default="",
|
| 181 |
+
description="Reason for the roll",
|
| 182 |
+
)
|
| 183 |
+
timestamp: datetime = Field(
|
| 184 |
+
default_factory=datetime.now,
|
| 185 |
+
description="When the roll occurred",
|
| 186 |
+
)
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
# =============================================================================
|
| 190 |
+
# HP Change Models
|
| 191 |
+
# =============================================================================
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
class HPChangeResult(FormattedResult):
|
| 195 |
+
"""Enhanced HP modification result with death handling."""
|
| 196 |
+
|
| 197 |
+
character_id: str = Field(
|
| 198 |
+
default="",
|
| 199 |
+
description="Character ID",
|
| 200 |
+
)
|
| 201 |
+
character_name: str = Field(
|
| 202 |
+
default="Unknown",
|
| 203 |
+
description="Character name for display",
|
| 204 |
+
)
|
| 205 |
+
previous_hp: int = Field(
|
| 206 |
+
default=0,
|
| 207 |
+
description="HP before modification",
|
| 208 |
+
)
|
| 209 |
+
current_hp: int = Field(
|
| 210 |
+
default=0,
|
| 211 |
+
description="HP after modification",
|
| 212 |
+
)
|
| 213 |
+
max_hp: int = Field(
|
| 214 |
+
default=1,
|
| 215 |
+
description="Maximum HP",
|
| 216 |
+
)
|
| 217 |
+
change_amount: int = Field(
|
| 218 |
+
default=0,
|
| 219 |
+
description="Absolute value of HP change",
|
| 220 |
+
)
|
| 221 |
+
is_damage: bool = Field(
|
| 222 |
+
default=False,
|
| 223 |
+
description="True if damage, False if healing",
|
| 224 |
+
)
|
| 225 |
+
damage_type: str | None = Field(
|
| 226 |
+
default=None,
|
| 227 |
+
description="Type of damage if applicable",
|
| 228 |
+
)
|
| 229 |
+
is_unconscious: bool = Field(
|
| 230 |
+
default=False,
|
| 231 |
+
description="Character is at 0 HP or below",
|
| 232 |
+
)
|
| 233 |
+
requires_death_save: bool = Field(
|
| 234 |
+
default=False,
|
| 235 |
+
description="Character needs to make death saves",
|
| 236 |
+
)
|
| 237 |
+
is_dead: bool = Field(
|
| 238 |
+
default=False,
|
| 239 |
+
description="Character died (massive damage)",
|
| 240 |
+
)
|
| 241 |
+
is_bloodied: bool = Field(
|
| 242 |
+
default=False,
|
| 243 |
+
description="Character is at 50% HP or below",
|
| 244 |
+
)
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
# =============================================================================
|
| 248 |
+
# Combat State Models
|
| 249 |
+
# =============================================================================
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
class CombatantInfo(BaseModel):
|
| 253 |
+
"""Information about a combatant in initiative order."""
|
| 254 |
+
|
| 255 |
+
id: str = Field(description="Combatant ID")
|
| 256 |
+
name: str = Field(description="Combatant name")
|
| 257 |
+
initiative: int = Field(description="Initiative roll result")
|
| 258 |
+
is_player: bool = Field(default=False, description="Is this a player character")
|
| 259 |
+
is_current: bool = Field(default=False, description="Is it this combatant's turn")
|
| 260 |
+
hp_current: int = Field(default=0, description="Current HP")
|
| 261 |
+
hp_max: int = Field(default=1, description="Maximum HP")
|
| 262 |
+
hp_percent: float = Field(default=100.0, description="HP percentage")
|
| 263 |
+
conditions: list[str] = Field(default_factory=list, description="Active conditions")
|
| 264 |
+
status: str = Field(default="healthy", description="Status string")
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
class CombatStateResult(FormattedResult):
|
| 268 |
+
"""Enhanced combat state result."""
|
| 269 |
+
|
| 270 |
+
action: str = Field(
|
| 271 |
+
default="",
|
| 272 |
+
description="Combat action: start, end, next_turn, etc.",
|
| 273 |
+
)
|
| 274 |
+
round_number: int | None = Field(
|
| 275 |
+
default=None,
|
| 276 |
+
description="Current combat round",
|
| 277 |
+
)
|
| 278 |
+
current_combatant: str | None = Field(
|
| 279 |
+
default=None,
|
| 280 |
+
description="Name of current combatant",
|
| 281 |
+
)
|
| 282 |
+
current_combatant_is_player: bool = Field(
|
| 283 |
+
default=False,
|
| 284 |
+
description="Whether current combatant is a player",
|
| 285 |
+
)
|
| 286 |
+
turn_order: list[CombatantInfo] = Field(
|
| 287 |
+
default_factory=list,
|
| 288 |
+
description="Initiative order with combatant info",
|
| 289 |
+
)
|
| 290 |
+
combat_ended: bool = Field(
|
| 291 |
+
default=False,
|
| 292 |
+
description="Whether combat has ended",
|
| 293 |
+
)
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
# =============================================================================
|
| 297 |
+
# Connection Status Model
|
| 298 |
+
# =============================================================================
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
class MCPConnectionStatus(BaseModel):
|
| 302 |
+
"""Status information for MCP connection."""
|
| 303 |
+
|
| 304 |
+
state: ConnectionState = Field(
|
| 305 |
+
default=ConnectionState.DISCONNECTED,
|
| 306 |
+
description="Current connection state",
|
| 307 |
+
)
|
| 308 |
+
is_available: bool = Field(
|
| 309 |
+
default=False,
|
| 310 |
+
description="Whether MCP is available for use",
|
| 311 |
+
)
|
| 312 |
+
url: str = Field(
|
| 313 |
+
default="",
|
| 314 |
+
description="MCP server URL",
|
| 315 |
+
)
|
| 316 |
+
last_successful_call: datetime | None = Field(
|
| 317 |
+
default=None,
|
| 318 |
+
description="When the last successful call was made",
|
| 319 |
+
)
|
| 320 |
+
consecutive_failures: int = Field(
|
| 321 |
+
default=0,
|
| 322 |
+
description="Number of consecutive failures",
|
| 323 |
+
)
|
| 324 |
+
circuit_breaker_state: CircuitBreakerState = Field(
|
| 325 |
+
default=CircuitBreakerState.CLOSED,
|
| 326 |
+
description="Circuit breaker state",
|
| 327 |
+
)
|
| 328 |
+
tools_count: int = Field(
|
| 329 |
+
default=0,
|
| 330 |
+
description="Number of available tools",
|
| 331 |
+
)
|
| 332 |
+
error_message: str | None = Field(
|
| 333 |
+
default=None,
|
| 334 |
+
description="Last error message if any",
|
| 335 |
+
)
|
src/mcp_integration/tool_wrappers.py
ADDED
|
@@ -0,0 +1,1003 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DungeonMaster AI - Game-Aware Tool Wrappers
|
| 3 |
+
|
| 4 |
+
Wraps MCP tools with game state awareness and side effects.
|
| 5 |
+
Provides enhanced results formatted for chat, UI, and voice.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import inspect
|
| 11 |
+
import logging
|
| 12 |
+
from collections.abc import Awaitable, Callable, Sequence
|
| 13 |
+
from datetime import datetime
|
| 14 |
+
from functools import wraps
|
| 15 |
+
from typing import TYPE_CHECKING
|
| 16 |
+
|
| 17 |
+
from llama_index.core.tools import FunctionTool
|
| 18 |
+
|
| 19 |
+
from src.utils.formatters import (
|
| 20 |
+
format_dice_roll,
|
| 21 |
+
format_hp_change,
|
| 22 |
+
format_initiative_order,
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
from .models import (
|
| 26 |
+
CombatantInfo,
|
| 27 |
+
CombatStateResult,
|
| 28 |
+
DiceRollResult,
|
| 29 |
+
FormattedResult,
|
| 30 |
+
GameStateProtocol,
|
| 31 |
+
HPChangeResult,
|
| 32 |
+
RollType,
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
if TYPE_CHECKING:
|
| 36 |
+
from typing import Any
|
| 37 |
+
|
| 38 |
+
from .toolkit_client import TTRPGToolkitClient
|
| 39 |
+
|
| 40 |
+
logger = logging.getLogger(__name__)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
# Type aliases for callbacks
|
| 44 |
+
SessionLogger = Callable[[str, str, dict[str, object]], Awaitable[None]]
|
| 45 |
+
UINotifier = Callable[[str, dict[str, object]], None]
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
# =============================================================================
|
| 49 |
+
# Voice Formatting Helpers
|
| 50 |
+
# =============================================================================
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def format_roll_for_voice(
|
| 54 |
+
total: int,
|
| 55 |
+
roll_type: RollType,
|
| 56 |
+
is_critical: bool = False,
|
| 57 |
+
is_fumble: bool = False,
|
| 58 |
+
success: bool | None = None,
|
| 59 |
+
skill_name: str | None = None,
|
| 60 |
+
) -> str:
|
| 61 |
+
"""
|
| 62 |
+
Format dice roll for TTS narration.
|
| 63 |
+
|
| 64 |
+
Args:
|
| 65 |
+
total: Roll total
|
| 66 |
+
roll_type: Type of roll
|
| 67 |
+
is_critical: Natural 20
|
| 68 |
+
is_fumble: Natural 1
|
| 69 |
+
success: Success/failure for checks
|
| 70 |
+
skill_name: Name of skill if applicable
|
| 71 |
+
|
| 72 |
+
Returns:
|
| 73 |
+
TTS-friendly text
|
| 74 |
+
"""
|
| 75 |
+
# Critical/fumble announcements
|
| 76 |
+
if is_critical:
|
| 77 |
+
return f"Natural twenty! Critical success with a total of {total}!"
|
| 78 |
+
if is_fumble:
|
| 79 |
+
return "Natural one. Critical failure."
|
| 80 |
+
|
| 81 |
+
# Base narration by roll type
|
| 82 |
+
skill_part = f" for {skill_name}" if skill_name else ""
|
| 83 |
+
|
| 84 |
+
templates = {
|
| 85 |
+
RollType.ATTACK: f"Attack roll{skill_part}, {total}.",
|
| 86 |
+
RollType.DAMAGE: f"{total} points of damage.",
|
| 87 |
+
RollType.SAVE: f"Saving throw{skill_part}, {total}.",
|
| 88 |
+
RollType.CHECK: f"Ability check{skill_part}, you rolled a {total}.",
|
| 89 |
+
RollType.INITIATIVE: f"Initiative, {total}.",
|
| 90 |
+
RollType.STANDARD: f"You rolled {total}.",
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
base = templates.get(roll_type, f"Result: {total}")
|
| 94 |
+
|
| 95 |
+
# Add success/failure
|
| 96 |
+
if success is not None:
|
| 97 |
+
base += " Success!" if success else " Failed."
|
| 98 |
+
|
| 99 |
+
return base
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def format_hp_for_voice(
|
| 103 |
+
character_name: str,
|
| 104 |
+
change_amount: int,
|
| 105 |
+
current_hp: int,
|
| 106 |
+
max_hp: int,
|
| 107 |
+
is_damage: bool,
|
| 108 |
+
is_unconscious: bool = False,
|
| 109 |
+
is_dead: bool = False,
|
| 110 |
+
) -> str:
|
| 111 |
+
"""
|
| 112 |
+
Format HP change for TTS narration.
|
| 113 |
+
|
| 114 |
+
Args:
|
| 115 |
+
character_name: Name of character
|
| 116 |
+
change_amount: Absolute value of HP change
|
| 117 |
+
current_hp: HP after change
|
| 118 |
+
max_hp: Maximum HP
|
| 119 |
+
is_damage: True if damage, False if healing
|
| 120 |
+
is_unconscious: Character is at 0 HP
|
| 121 |
+
is_dead: Character died from massive damage
|
| 122 |
+
|
| 123 |
+
Returns:
|
| 124 |
+
TTS-friendly text
|
| 125 |
+
"""
|
| 126 |
+
if is_dead:
|
| 127 |
+
return f"{character_name} is slain by massive damage!"
|
| 128 |
+
|
| 129 |
+
if is_unconscious and is_damage:
|
| 130 |
+
return f"{character_name} takes {change_amount} damage and falls unconscious!"
|
| 131 |
+
|
| 132 |
+
if is_damage:
|
| 133 |
+
if current_hp <= max_hp // 4:
|
| 134 |
+
return f"{character_name} takes {change_amount} damage and is badly wounded!"
|
| 135 |
+
return f"{character_name} takes {change_amount} damage."
|
| 136 |
+
|
| 137 |
+
# Healing
|
| 138 |
+
if current_hp >= max_hp:
|
| 139 |
+
return f"{character_name} is fully healed!"
|
| 140 |
+
return f"{character_name} recovers {change_amount} hit points."
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def format_combat_turn_for_voice(
|
| 144 |
+
combatant_name: str,
|
| 145 |
+
is_player: bool,
|
| 146 |
+
round_number: int,
|
| 147 |
+
) -> str:
|
| 148 |
+
"""
|
| 149 |
+
Format combat turn announcement for TTS.
|
| 150 |
+
|
| 151 |
+
Args:
|
| 152 |
+
combatant_name: Name of current combatant
|
| 153 |
+
is_player: Whether combatant is a player character
|
| 154 |
+
round_number: Current round number
|
| 155 |
+
|
| 156 |
+
Returns:
|
| 157 |
+
TTS-friendly text
|
| 158 |
+
"""
|
| 159 |
+
if is_player:
|
| 160 |
+
return f"Round {round_number}. It's your turn, {combatant_name}. What do you do?"
|
| 161 |
+
return f"{combatant_name} takes their turn."
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
# =============================================================================
|
| 165 |
+
# Roll Type Detection
|
| 166 |
+
# =============================================================================
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def detect_roll_type(
|
| 170 |
+
_tool_name: str,
|
| 171 |
+
arguments: dict[str, object],
|
| 172 |
+
result: dict[str, object],
|
| 173 |
+
) -> RollType:
|
| 174 |
+
"""
|
| 175 |
+
Detect the type of roll from context.
|
| 176 |
+
|
| 177 |
+
Args:
|
| 178 |
+
tool_name: Name of the tool called
|
| 179 |
+
arguments: Tool arguments
|
| 180 |
+
result: Tool result
|
| 181 |
+
|
| 182 |
+
Returns:
|
| 183 |
+
Detected RollType
|
| 184 |
+
"""
|
| 185 |
+
# Check explicit roll_type in arguments
|
| 186 |
+
if "roll_type" in arguments:
|
| 187 |
+
try:
|
| 188 |
+
return RollType(arguments["roll_type"])
|
| 189 |
+
except ValueError:
|
| 190 |
+
pass
|
| 191 |
+
|
| 192 |
+
# Check check_type in result (from roll_check)
|
| 193 |
+
check_type = str(result.get("check_type", "")).lower()
|
| 194 |
+
if "attack" in check_type:
|
| 195 |
+
return RollType.ATTACK
|
| 196 |
+
if "save" in check_type or "saving" in check_type:
|
| 197 |
+
return RollType.SAVE
|
| 198 |
+
if "initiative" in check_type:
|
| 199 |
+
return RollType.INITIATIVE
|
| 200 |
+
|
| 201 |
+
# Check reason field for hints
|
| 202 |
+
reason = str(arguments.get("reason", "")).lower()
|
| 203 |
+
if "attack" in reason:
|
| 204 |
+
return RollType.ATTACK
|
| 205 |
+
if "damage" in reason:
|
| 206 |
+
return RollType.DAMAGE
|
| 207 |
+
if "save" in reason or "saving" in reason:
|
| 208 |
+
return RollType.SAVE
|
| 209 |
+
if "check" in reason or "ability" in reason:
|
| 210 |
+
return RollType.CHECK
|
| 211 |
+
if "initiative" in reason:
|
| 212 |
+
return RollType.INITIATIVE
|
| 213 |
+
|
| 214 |
+
return RollType.STANDARD
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
# =============================================================================
|
| 218 |
+
# Game Aware Tools Wrapper Class
|
| 219 |
+
# =============================================================================
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
class GameAwareTools:
|
| 223 |
+
"""
|
| 224 |
+
Wraps MCP tools with game state awareness and side effects.
|
| 225 |
+
|
| 226 |
+
This class enhances raw MCP tools to:
|
| 227 |
+
1. Automatically log actions to the game session
|
| 228 |
+
2. Update game state after tool calls
|
| 229 |
+
3. Format results for chat, UI, and voice
|
| 230 |
+
4. Trigger appropriate UI updates
|
| 231 |
+
5. Handle special game logic (death saves, combat transitions)
|
| 232 |
+
|
| 233 |
+
Example:
|
| 234 |
+
```python
|
| 235 |
+
# Get raw tools from MCP
|
| 236 |
+
raw_tools = await toolkit_client.get_all_tools()
|
| 237 |
+
|
| 238 |
+
# Wrap with game awareness
|
| 239 |
+
wrapper = GameAwareTools(
|
| 240 |
+
game_state=game_state,
|
| 241 |
+
toolkit_client=toolkit_client,
|
| 242 |
+
session_logger=log_session_event,
|
| 243 |
+
ui_notifier=notify_ui,
|
| 244 |
+
)
|
| 245 |
+
enhanced_tools = wrapper.wrap_tools(raw_tools)
|
| 246 |
+
|
| 247 |
+
# Use with LlamaIndex agent
|
| 248 |
+
agent = FunctionAgent(tools=enhanced_tools, ...)
|
| 249 |
+
```
|
| 250 |
+
"""
|
| 251 |
+
|
| 252 |
+
# Tool name sets for category detection
|
| 253 |
+
ROLL_TOOLS = {"roll", "roll_check", "roll_table", "mcp_roll", "mcp_roll_check"}
|
| 254 |
+
HP_TOOLS = {"modify_hp", "mcp_modify_hp"}
|
| 255 |
+
COMBAT_START_TOOLS = {"start_combat", "mcp_start_combat"}
|
| 256 |
+
COMBAT_END_TOOLS = {"end_combat", "mcp_end_combat"}
|
| 257 |
+
COMBAT_TURN_TOOLS = {"next_turn", "mcp_next_turn"}
|
| 258 |
+
|
| 259 |
+
def __init__(
|
| 260 |
+
self,
|
| 261 |
+
game_state: GameStateProtocol | None = None,
|
| 262 |
+
toolkit_client: TTRPGToolkitClient | None = None,
|
| 263 |
+
session_logger: SessionLogger | None = None,
|
| 264 |
+
ui_notifier: UINotifier | None = None,
|
| 265 |
+
) -> None:
|
| 266 |
+
"""
|
| 267 |
+
Initialize GameAwareTools.
|
| 268 |
+
|
| 269 |
+
Args:
|
| 270 |
+
game_state: Reference to game state for updates (optional)
|
| 271 |
+
toolkit_client: MCP toolkit client for additional calls (optional)
|
| 272 |
+
session_logger: Async callback for logging events to session
|
| 273 |
+
ui_notifier: Callback for notifying UI of changes
|
| 274 |
+
"""
|
| 275 |
+
self._game_state = game_state
|
| 276 |
+
self._toolkit_client = toolkit_client
|
| 277 |
+
self._session_logger = session_logger
|
| 278 |
+
self._ui_notifier = ui_notifier
|
| 279 |
+
|
| 280 |
+
async def _safe_call_logger(
|
| 281 |
+
self,
|
| 282 |
+
event_type: str,
|
| 283 |
+
description: str,
|
| 284 |
+
data: dict[str, object],
|
| 285 |
+
) -> None:
|
| 286 |
+
"""
|
| 287 |
+
Safely call session logger, handling both sync and async callbacks.
|
| 288 |
+
|
| 289 |
+
This method ensures the logger is called correctly regardless of whether
|
| 290 |
+
it returns an awaitable or not, preventing "NoneType can't be used in
|
| 291 |
+
'await' expression" errors.
|
| 292 |
+
"""
|
| 293 |
+
if not self._session_logger:
|
| 294 |
+
return
|
| 295 |
+
try:
|
| 296 |
+
result = self._session_logger(event_type, description, data)
|
| 297 |
+
if inspect.isawaitable(result):
|
| 298 |
+
await result
|
| 299 |
+
except Exception as e:
|
| 300 |
+
logger.warning(f"Session logger call failed: {e}")
|
| 301 |
+
|
| 302 |
+
def wrap_tools(
|
| 303 |
+
self,
|
| 304 |
+
tools: Sequence[FunctionTool],
|
| 305 |
+
) -> list[FunctionTool]:
|
| 306 |
+
"""
|
| 307 |
+
Wrap a list of MCP tools with game-aware enhancements.
|
| 308 |
+
|
| 309 |
+
Args:
|
| 310 |
+
tools: List of raw MCP FunctionTools
|
| 311 |
+
|
| 312 |
+
Returns:
|
| 313 |
+
List of enhanced FunctionTools
|
| 314 |
+
"""
|
| 315 |
+
wrapped_tools = []
|
| 316 |
+
|
| 317 |
+
for tool in tools:
|
| 318 |
+
tool_name = tool.metadata.name or ""
|
| 319 |
+
normalized_name = tool_name.replace("mcp_", "")
|
| 320 |
+
|
| 321 |
+
# Select appropriate wrapper based on tool category
|
| 322 |
+
if tool_name in self.ROLL_TOOLS or normalized_name in self.ROLL_TOOLS:
|
| 323 |
+
wrapped = self._wrap_roll_tool(tool)
|
| 324 |
+
elif tool_name in self.HP_TOOLS or normalized_name in self.HP_TOOLS:
|
| 325 |
+
wrapped = self._wrap_hp_tool(tool)
|
| 326 |
+
elif (
|
| 327 |
+
tool_name in self.COMBAT_START_TOOLS
|
| 328 |
+
or normalized_name in self.COMBAT_START_TOOLS
|
| 329 |
+
):
|
| 330 |
+
wrapped = self._wrap_combat_start_tool(tool)
|
| 331 |
+
elif (
|
| 332 |
+
tool_name in self.COMBAT_END_TOOLS
|
| 333 |
+
or normalized_name in self.COMBAT_END_TOOLS
|
| 334 |
+
):
|
| 335 |
+
wrapped = self._wrap_combat_end_tool(tool)
|
| 336 |
+
elif (
|
| 337 |
+
tool_name in self.COMBAT_TURN_TOOLS
|
| 338 |
+
or normalized_name in self.COMBAT_TURN_TOOLS
|
| 339 |
+
):
|
| 340 |
+
wrapped = self._wrap_combat_turn_tool(tool)
|
| 341 |
+
else:
|
| 342 |
+
wrapped = self._wrap_generic(tool)
|
| 343 |
+
|
| 344 |
+
wrapped_tools.append(wrapped)
|
| 345 |
+
|
| 346 |
+
logger.debug(f"Wrapped {len(wrapped_tools)} tools with game awareness")
|
| 347 |
+
return wrapped_tools
|
| 348 |
+
|
| 349 |
+
def _wrap_roll_tool(self, tool: FunctionTool) -> FunctionTool:
|
| 350 |
+
"""Wrap dice rolling tool with game-aware enhancements."""
|
| 351 |
+
original_fn = tool.async_fn if tool.async_fn is not None else tool.fn
|
| 352 |
+
|
| 353 |
+
@wraps(original_fn)
|
| 354 |
+
async def wrapped_roll(**kwargs: Any) -> DiceRollResult:
|
| 355 |
+
# Call original tool
|
| 356 |
+
if tool.async_fn is not None:
|
| 357 |
+
raw_result = await tool.async_fn(**kwargs)
|
| 358 |
+
else:
|
| 359 |
+
raw_result = tool.fn(**kwargs)
|
| 360 |
+
|
| 361 |
+
# Extract result dict
|
| 362 |
+
result_dict = self._extract_result_dict(raw_result)
|
| 363 |
+
|
| 364 |
+
# Detect roll type
|
| 365 |
+
roll_type = detect_roll_type(tool.metadata.name or "", kwargs, result_dict)
|
| 366 |
+
|
| 367 |
+
# Extract roll data
|
| 368 |
+
notation = str(result_dict.get("notation", kwargs.get("notation", "")))
|
| 369 |
+
rolls = result_dict.get("rolls", result_dict.get("dice", []))
|
| 370 |
+
if not isinstance(rolls, list):
|
| 371 |
+
rolls = []
|
| 372 |
+
modifier = int(str(result_dict.get("modifier", 0)))
|
| 373 |
+
total = int(str(result_dict.get("total", 0)))
|
| 374 |
+
dc = result_dict.get("dc")
|
| 375 |
+
success = result_dict.get("success", result_dict.get("check_success"))
|
| 376 |
+
|
| 377 |
+
# Check for crits (d20 only)
|
| 378 |
+
is_d20 = "d20" in notation.lower() or roll_type in (
|
| 379 |
+
RollType.ATTACK,
|
| 380 |
+
RollType.SAVE,
|
| 381 |
+
RollType.CHECK,
|
| 382 |
+
)
|
| 383 |
+
is_critical = is_d20 and len(rolls) >= 1 and 20 in rolls
|
| 384 |
+
is_fumble = is_d20 and len(rolls) >= 1 and 1 in rolls and 20 not in rolls
|
| 385 |
+
|
| 386 |
+
# Format for display
|
| 387 |
+
chat_display = format_dice_roll(
|
| 388 |
+
notation=notation,
|
| 389 |
+
individual_rolls=rolls,
|
| 390 |
+
modifier=modifier,
|
| 391 |
+
total=total,
|
| 392 |
+
is_check=roll_type in (RollType.CHECK, RollType.SAVE),
|
| 393 |
+
dc=int(str(dc)) if dc is not None else None,
|
| 394 |
+
success=bool(success) if success is not None else None,
|
| 395 |
+
)
|
| 396 |
+
|
| 397 |
+
# Format for UI
|
| 398 |
+
ui_data = {
|
| 399 |
+
"notation": notation,
|
| 400 |
+
"rolls": rolls,
|
| 401 |
+
"modifier": modifier,
|
| 402 |
+
"total": total,
|
| 403 |
+
"roll_type": roll_type.value,
|
| 404 |
+
"is_critical": is_critical,
|
| 405 |
+
"is_fumble": is_fumble,
|
| 406 |
+
"success": success,
|
| 407 |
+
"dc": dc,
|
| 408 |
+
"timestamp": datetime.now().isoformat(),
|
| 409 |
+
}
|
| 410 |
+
|
| 411 |
+
# Format for voice
|
| 412 |
+
voice_narration = format_roll_for_voice(
|
| 413 |
+
total=total,
|
| 414 |
+
roll_type=roll_type,
|
| 415 |
+
is_critical=is_critical,
|
| 416 |
+
is_fumble=is_fumble,
|
| 417 |
+
success=bool(success) if success is not None else None,
|
| 418 |
+
skill_name=kwargs.get("skill_name"),
|
| 419 |
+
)
|
| 420 |
+
|
| 421 |
+
# Create enhanced result
|
| 422 |
+
enhanced_result = DiceRollResult(
|
| 423 |
+
raw_result=result_dict,
|
| 424 |
+
chat_display=chat_display,
|
| 425 |
+
ui_data=ui_data,
|
| 426 |
+
voice_narration=voice_narration,
|
| 427 |
+
notation=notation,
|
| 428 |
+
individual_rolls=rolls,
|
| 429 |
+
modifier=modifier,
|
| 430 |
+
total=total,
|
| 431 |
+
roll_type=roll_type,
|
| 432 |
+
is_critical=is_critical,
|
| 433 |
+
is_fumble=is_fumble,
|
| 434 |
+
success=bool(success) if success is not None else None,
|
| 435 |
+
dc=int(str(dc)) if dc is not None else None,
|
| 436 |
+
reason=str(kwargs.get("reason", "")),
|
| 437 |
+
ui_updates_needed=["dice_panel", "roll_history"],
|
| 438 |
+
)
|
| 439 |
+
|
| 440 |
+
# Side effects
|
| 441 |
+
await self._log_roll_event(enhanced_result, kwargs)
|
| 442 |
+
self._add_roll_to_game_state(enhanced_result)
|
| 443 |
+
|
| 444 |
+
return enhanced_result
|
| 445 |
+
|
| 446 |
+
return FunctionTool.from_defaults(
|
| 447 |
+
async_fn=wrapped_roll,
|
| 448 |
+
name=tool.metadata.name,
|
| 449 |
+
description=tool.metadata.description,
|
| 450 |
+
)
|
| 451 |
+
|
| 452 |
+
def _wrap_hp_tool(self, tool: FunctionTool) -> FunctionTool:
|
| 453 |
+
"""Wrap HP modification tool with death handling."""
|
| 454 |
+
original_fn = tool.async_fn if tool.async_fn is not None else tool.fn
|
| 455 |
+
|
| 456 |
+
@wraps(original_fn)
|
| 457 |
+
async def wrapped_modify_hp(**kwargs: Any) -> HPChangeResult:
|
| 458 |
+
# Get character ID and amount
|
| 459 |
+
character_id = str(kwargs.get("character_id", ""))
|
| 460 |
+
amount = int(kwargs.get("amount", 0))
|
| 461 |
+
damage_type = kwargs.get("damage_type")
|
| 462 |
+
|
| 463 |
+
# Get previous HP from cache if available
|
| 464 |
+
prev_hp = 0
|
| 465 |
+
max_hp = 1
|
| 466 |
+
char_name = "Unknown"
|
| 467 |
+
|
| 468 |
+
if self._game_state:
|
| 469 |
+
char_data = self._game_state.get_character(character_id)
|
| 470 |
+
if char_data:
|
| 471 |
+
hp_data = char_data.get("hit_points")
|
| 472 |
+
if isinstance(hp_data, dict):
|
| 473 |
+
prev_hp = int(str(hp_data.get("current", 0)))
|
| 474 |
+
max_hp = int(str(hp_data.get("maximum", 1)))
|
| 475 |
+
char_name = str(char_data.get("name", "Unknown"))
|
| 476 |
+
|
| 477 |
+
# Call original tool
|
| 478 |
+
if tool.async_fn is not None:
|
| 479 |
+
raw_result = await tool.async_fn(**kwargs)
|
| 480 |
+
else:
|
| 481 |
+
raw_result = tool.fn(**kwargs)
|
| 482 |
+
|
| 483 |
+
result_dict = self._extract_result_dict(raw_result)
|
| 484 |
+
|
| 485 |
+
# Get new HP from result
|
| 486 |
+
current_hp_val = result_dict.get(
|
| 487 |
+
"current_hp", result_dict.get("new_hp", prev_hp + amount)
|
| 488 |
+
)
|
| 489 |
+
current_hp = int(str(current_hp_val))
|
| 490 |
+
if "max_hp" in result_dict:
|
| 491 |
+
max_hp = int(str(result_dict["max_hp"]))
|
| 492 |
+
if "character_name" in result_dict:
|
| 493 |
+
char_name = str(result_dict["character_name"])
|
| 494 |
+
|
| 495 |
+
is_damage = amount < 0
|
| 496 |
+
change_amount = abs(amount)
|
| 497 |
+
|
| 498 |
+
# Death checks
|
| 499 |
+
is_unconscious = current_hp <= 0
|
| 500 |
+
requires_death_save = is_unconscious and prev_hp > 0 and is_damage
|
| 501 |
+
is_dead = is_damage and current_hp <= -max_hp # Massive damage
|
| 502 |
+
|
| 503 |
+
# Bloodied check (50% HP)
|
| 504 |
+
is_bloodied = 0 < current_hp <= max_hp // 2
|
| 505 |
+
|
| 506 |
+
# Format for display
|
| 507 |
+
chat_display = format_hp_change(
|
| 508 |
+
character_name=char_name,
|
| 509 |
+
previous_hp=prev_hp,
|
| 510 |
+
current_hp=max(0, current_hp),
|
| 511 |
+
max_hp=max_hp,
|
| 512 |
+
is_damage=is_damage,
|
| 513 |
+
)
|
| 514 |
+
|
| 515 |
+
if is_dead:
|
| 516 |
+
chat_display += (
|
| 517 |
+
"\n**DEATH!** Massive damage has killed the character instantly!"
|
| 518 |
+
)
|
| 519 |
+
elif requires_death_save:
|
| 520 |
+
chat_display += "\n*Death saving throws begin next turn.*"
|
| 521 |
+
|
| 522 |
+
# Format for UI
|
| 523 |
+
ui_data = {
|
| 524 |
+
"character_id": character_id,
|
| 525 |
+
"character_name": char_name,
|
| 526 |
+
"hp_previous": prev_hp,
|
| 527 |
+
"hp_current": current_hp,
|
| 528 |
+
"hp_max": max_hp,
|
| 529 |
+
"hp_percent": (max(0, current_hp) / max_hp * 100) if max_hp > 0 else 0,
|
| 530 |
+
"is_unconscious": is_unconscious,
|
| 531 |
+
"is_bloodied": is_bloodied,
|
| 532 |
+
"is_critical": 0 < current_hp <= max_hp // 4,
|
| 533 |
+
"is_dead": is_dead,
|
| 534 |
+
}
|
| 535 |
+
|
| 536 |
+
# Format for voice
|
| 537 |
+
voice_narration = format_hp_for_voice(
|
| 538 |
+
character_name=char_name,
|
| 539 |
+
change_amount=change_amount,
|
| 540 |
+
current_hp=max(0, current_hp),
|
| 541 |
+
max_hp=max_hp,
|
| 542 |
+
is_damage=is_damage,
|
| 543 |
+
is_unconscious=is_unconscious,
|
| 544 |
+
is_dead=is_dead,
|
| 545 |
+
)
|
| 546 |
+
|
| 547 |
+
# Create enhanced result
|
| 548 |
+
ui_updates = ["character_sheet"]
|
| 549 |
+
if self._game_state and self._game_state.in_combat:
|
| 550 |
+
ui_updates.append("combat_tracker")
|
| 551 |
+
|
| 552 |
+
enhanced_result = HPChangeResult(
|
| 553 |
+
raw_result=result_dict,
|
| 554 |
+
chat_display=chat_display,
|
| 555 |
+
ui_data=ui_data,
|
| 556 |
+
voice_narration=voice_narration,
|
| 557 |
+
character_id=character_id,
|
| 558 |
+
character_name=char_name,
|
| 559 |
+
previous_hp=prev_hp,
|
| 560 |
+
current_hp=current_hp,
|
| 561 |
+
max_hp=max_hp,
|
| 562 |
+
change_amount=change_amount,
|
| 563 |
+
is_damage=is_damage,
|
| 564 |
+
damage_type=str(damage_type) if damage_type else None,
|
| 565 |
+
is_unconscious=is_unconscious,
|
| 566 |
+
requires_death_save=requires_death_save,
|
| 567 |
+
is_dead=is_dead,
|
| 568 |
+
is_bloodied=is_bloodied,
|
| 569 |
+
ui_updates_needed=ui_updates,
|
| 570 |
+
)
|
| 571 |
+
|
| 572 |
+
# Side effects
|
| 573 |
+
await self._log_hp_event(enhanced_result)
|
| 574 |
+
self._update_character_hp(enhanced_result)
|
| 575 |
+
|
| 576 |
+
return enhanced_result
|
| 577 |
+
|
| 578 |
+
return FunctionTool.from_defaults(
|
| 579 |
+
async_fn=wrapped_modify_hp,
|
| 580 |
+
name=tool.metadata.name,
|
| 581 |
+
description=tool.metadata.description,
|
| 582 |
+
)
|
| 583 |
+
|
| 584 |
+
def _wrap_combat_start_tool(self, tool: FunctionTool) -> FunctionTool:
|
| 585 |
+
"""Wrap combat start tool."""
|
| 586 |
+
original_fn = tool.async_fn if tool.async_fn is not None else tool.fn
|
| 587 |
+
|
| 588 |
+
@wraps(original_fn)
|
| 589 |
+
async def wrapped_start_combat(**kwargs: Any) -> CombatStateResult:
|
| 590 |
+
# Call original tool
|
| 591 |
+
if tool.async_fn is not None:
|
| 592 |
+
raw_result = await tool.async_fn(**kwargs)
|
| 593 |
+
else:
|
| 594 |
+
raw_result = tool.fn(**kwargs)
|
| 595 |
+
|
| 596 |
+
result_dict = self._extract_result_dict(raw_result)
|
| 597 |
+
|
| 598 |
+
# Extract combat state
|
| 599 |
+
turn_order_raw = result_dict.get("turn_order", [])
|
| 600 |
+
turn_order: list[object] = list(turn_order_raw) if isinstance(turn_order_raw, list) else []
|
| 601 |
+
current_idx = int(str(result_dict.get("current_turn_index", 0)))
|
| 602 |
+
round_num = int(str(result_dict.get("round", 1)))
|
| 603 |
+
|
| 604 |
+
# Build combatant info list
|
| 605 |
+
combatants: list[CombatantInfo] = []
|
| 606 |
+
for i, combatant in enumerate(turn_order):
|
| 607 |
+
if isinstance(combatant, dict):
|
| 608 |
+
hp_curr = int(str(combatant.get("hp_current", 0)))
|
| 609 |
+
hp_max = int(str(combatant.get("hp_max", 1)))
|
| 610 |
+
combatants.append(
|
| 611 |
+
CombatantInfo(
|
| 612 |
+
id=str(combatant.get("id", f"combatant_{i}")),
|
| 613 |
+
name=str(combatant.get("name", f"Combatant {i + 1}")),
|
| 614 |
+
initiative=int(str(combatant.get("initiative", 0))),
|
| 615 |
+
is_player=bool(combatant.get("is_player", False)),
|
| 616 |
+
is_current=i == current_idx,
|
| 617 |
+
hp_current=hp_curr,
|
| 618 |
+
hp_max=hp_max,
|
| 619 |
+
hp_percent=(hp_curr / hp_max * 100) if hp_max > 0 else 0,
|
| 620 |
+
conditions=list(combatant.get("conditions", [])),
|
| 621 |
+
status=self._get_hp_status(hp_curr, hp_max),
|
| 622 |
+
)
|
| 623 |
+
)
|
| 624 |
+
|
| 625 |
+
current_combatant = (
|
| 626 |
+
combatants[current_idx].name if combatants else "Unknown"
|
| 627 |
+
)
|
| 628 |
+
is_player = combatants[current_idx].is_player if combatants else False
|
| 629 |
+
|
| 630 |
+
# Format for display
|
| 631 |
+
chat_display = "**Combat Begins!**\n\n"
|
| 632 |
+
if combatants:
|
| 633 |
+
chat_display += format_initiative_order(
|
| 634 |
+
[c.model_dump() for c in combatants],
|
| 635 |
+
current_idx,
|
| 636 |
+
)
|
| 637 |
+
|
| 638 |
+
# Format for UI
|
| 639 |
+
ui_data = {
|
| 640 |
+
"round": round_num,
|
| 641 |
+
"combatants": [c.model_dump() for c in combatants],
|
| 642 |
+
"current_combatant_name": current_combatant,
|
| 643 |
+
"combat_started": True,
|
| 644 |
+
}
|
| 645 |
+
|
| 646 |
+
# Format for voice
|
| 647 |
+
voice_narration = (
|
| 648 |
+
f"Roll for initiative! Combat begins. "
|
| 649 |
+
f"{current_combatant} goes first."
|
| 650 |
+
)
|
| 651 |
+
|
| 652 |
+
enhanced_result = CombatStateResult(
|
| 653 |
+
raw_result=result_dict,
|
| 654 |
+
chat_display=chat_display,
|
| 655 |
+
ui_data=ui_data,
|
| 656 |
+
voice_narration=voice_narration,
|
| 657 |
+
action="start",
|
| 658 |
+
round_number=round_num,
|
| 659 |
+
current_combatant=current_combatant,
|
| 660 |
+
current_combatant_is_player=is_player,
|
| 661 |
+
turn_order=combatants,
|
| 662 |
+
ui_updates_needed=["combat_tracker"],
|
| 663 |
+
state_updated=True,
|
| 664 |
+
)
|
| 665 |
+
|
| 666 |
+
# Side effects
|
| 667 |
+
if self._game_state:
|
| 668 |
+
self._game_state.set_combat_state(result_dict)
|
| 669 |
+
|
| 670 |
+
if self._ui_notifier:
|
| 671 |
+
self._ui_notifier("show_combat_tracker", ui_data)
|
| 672 |
+
|
| 673 |
+
await self._log_combat_event("combat_start", result_dict)
|
| 674 |
+
|
| 675 |
+
return enhanced_result
|
| 676 |
+
|
| 677 |
+
return FunctionTool.from_defaults(
|
| 678 |
+
async_fn=wrapped_start_combat,
|
| 679 |
+
name=tool.metadata.name,
|
| 680 |
+
description=tool.metadata.description,
|
| 681 |
+
)
|
| 682 |
+
|
| 683 |
+
def _wrap_combat_end_tool(self, tool: FunctionTool) -> FunctionTool:
|
| 684 |
+
"""Wrap combat end tool."""
|
| 685 |
+
original_fn = tool.async_fn if tool.async_fn is not None else tool.fn
|
| 686 |
+
|
| 687 |
+
@wraps(original_fn)
|
| 688 |
+
async def wrapped_end_combat(**kwargs: Any) -> CombatStateResult:
|
| 689 |
+
# Call original tool
|
| 690 |
+
if tool.async_fn is not None:
|
| 691 |
+
raw_result = await tool.async_fn(**kwargs)
|
| 692 |
+
else:
|
| 693 |
+
raw_result = tool.fn(**kwargs)
|
| 694 |
+
|
| 695 |
+
result_dict = self._extract_result_dict(raw_result)
|
| 696 |
+
|
| 697 |
+
# Format for display
|
| 698 |
+
chat_display = "**Combat Ends!**\n\n*The dust settles...*"
|
| 699 |
+
|
| 700 |
+
# Format for UI
|
| 701 |
+
ui_data: dict[str, object] = {"combat_ended": True}
|
| 702 |
+
|
| 703 |
+
# Format for voice
|
| 704 |
+
voice_narration = "The battle is over."
|
| 705 |
+
|
| 706 |
+
enhanced_result = CombatStateResult(
|
| 707 |
+
raw_result=result_dict,
|
| 708 |
+
chat_display=chat_display,
|
| 709 |
+
ui_data=ui_data,
|
| 710 |
+
voice_narration=voice_narration,
|
| 711 |
+
action="end",
|
| 712 |
+
combat_ended=True,
|
| 713 |
+
ui_updates_needed=["combat_tracker"],
|
| 714 |
+
state_updated=True,
|
| 715 |
+
)
|
| 716 |
+
|
| 717 |
+
# Side effects
|
| 718 |
+
if self._game_state:
|
| 719 |
+
self._game_state.set_combat_state(None)
|
| 720 |
+
|
| 721 |
+
if self._ui_notifier:
|
| 722 |
+
self._ui_notifier("hide_combat_tracker", {})
|
| 723 |
+
|
| 724 |
+
await self._log_combat_event("combat_end", result_dict)
|
| 725 |
+
|
| 726 |
+
return enhanced_result
|
| 727 |
+
|
| 728 |
+
return FunctionTool.from_defaults(
|
| 729 |
+
async_fn=wrapped_end_combat,
|
| 730 |
+
name=tool.metadata.name,
|
| 731 |
+
description=tool.metadata.description,
|
| 732 |
+
)
|
| 733 |
+
|
| 734 |
+
def _wrap_combat_turn_tool(self, tool: FunctionTool) -> FunctionTool:
|
| 735 |
+
"""Wrap next turn tool."""
|
| 736 |
+
original_fn = tool.async_fn if tool.async_fn is not None else tool.fn
|
| 737 |
+
|
| 738 |
+
@wraps(original_fn)
|
| 739 |
+
async def wrapped_next_turn(**kwargs: Any) -> CombatStateResult:
|
| 740 |
+
# Call original tool
|
| 741 |
+
if tool.async_fn is not None:
|
| 742 |
+
raw_result = await tool.async_fn(**kwargs)
|
| 743 |
+
else:
|
| 744 |
+
raw_result = tool.fn(**kwargs)
|
| 745 |
+
|
| 746 |
+
result_dict = self._extract_result_dict(raw_result)
|
| 747 |
+
|
| 748 |
+
# Extract updated combat state
|
| 749 |
+
turn_order_raw = result_dict.get("turn_order", [])
|
| 750 |
+
turn_order: list[object] = list(turn_order_raw) if isinstance(turn_order_raw, list) else []
|
| 751 |
+
current_idx = int(str(result_dict.get("current_turn_index", 0)))
|
| 752 |
+
round_num = int(str(result_dict.get("round", 1)))
|
| 753 |
+
|
| 754 |
+
# Build combatant info
|
| 755 |
+
combatants: list[CombatantInfo] = []
|
| 756 |
+
for i, combatant in enumerate(turn_order):
|
| 757 |
+
if isinstance(combatant, dict):
|
| 758 |
+
hp_curr = int(str(combatant.get("hp_current", 0)))
|
| 759 |
+
hp_max = int(str(combatant.get("hp_max", 1)))
|
| 760 |
+
combatants.append(
|
| 761 |
+
CombatantInfo(
|
| 762 |
+
id=str(combatant.get("id", f"combatant_{i}")),
|
| 763 |
+
name=str(combatant.get("name", f"Combatant {i + 1}")),
|
| 764 |
+
initiative=int(str(combatant.get("initiative", 0))),
|
| 765 |
+
is_player=bool(combatant.get("is_player", False)),
|
| 766 |
+
is_current=i == current_idx,
|
| 767 |
+
hp_current=hp_curr,
|
| 768 |
+
hp_max=hp_max,
|
| 769 |
+
hp_percent=(hp_curr / hp_max * 100) if hp_max > 0 else 0,
|
| 770 |
+
conditions=list(combatant.get("conditions", [])),
|
| 771 |
+
status=self._get_hp_status(hp_curr, hp_max),
|
| 772 |
+
)
|
| 773 |
+
)
|
| 774 |
+
|
| 775 |
+
current_combatant = (
|
| 776 |
+
combatants[current_idx].name if combatants else "Unknown"
|
| 777 |
+
)
|
| 778 |
+
is_player = combatants[current_idx].is_player if combatants else False
|
| 779 |
+
|
| 780 |
+
# Format for display
|
| 781 |
+
chat_display = format_initiative_order(
|
| 782 |
+
[c.model_dump() for c in combatants],
|
| 783 |
+
current_idx,
|
| 784 |
+
)
|
| 785 |
+
|
| 786 |
+
# Format for UI
|
| 787 |
+
ui_data = {
|
| 788 |
+
"round": round_num,
|
| 789 |
+
"combatants": [c.model_dump() for c in combatants],
|
| 790 |
+
"current_combatant_name": current_combatant,
|
| 791 |
+
}
|
| 792 |
+
|
| 793 |
+
# Format for voice
|
| 794 |
+
voice_narration = format_combat_turn_for_voice(
|
| 795 |
+
combatant_name=current_combatant,
|
| 796 |
+
is_player=is_player,
|
| 797 |
+
round_number=round_num,
|
| 798 |
+
)
|
| 799 |
+
|
| 800 |
+
enhanced_result = CombatStateResult(
|
| 801 |
+
raw_result=result_dict,
|
| 802 |
+
chat_display=chat_display,
|
| 803 |
+
ui_data=ui_data,
|
| 804 |
+
voice_narration=voice_narration,
|
| 805 |
+
action="next_turn",
|
| 806 |
+
round_number=round_num,
|
| 807 |
+
current_combatant=current_combatant,
|
| 808 |
+
current_combatant_is_player=is_player,
|
| 809 |
+
turn_order=combatants,
|
| 810 |
+
ui_updates_needed=["combat_tracker"],
|
| 811 |
+
state_updated=True,
|
| 812 |
+
)
|
| 813 |
+
|
| 814 |
+
# Side effects
|
| 815 |
+
if self._game_state:
|
| 816 |
+
self._game_state.set_combat_state(result_dict)
|
| 817 |
+
|
| 818 |
+
return enhanced_result
|
| 819 |
+
|
| 820 |
+
return FunctionTool.from_defaults(
|
| 821 |
+
async_fn=wrapped_next_turn,
|
| 822 |
+
name=tool.metadata.name,
|
| 823 |
+
description=tool.metadata.description,
|
| 824 |
+
)
|
| 825 |
+
|
| 826 |
+
def _wrap_generic(self, tool: FunctionTool) -> FunctionTool:
|
| 827 |
+
"""
|
| 828 |
+
Generic wrapper for tools that don't need special handling.
|
| 829 |
+
Still adds basic logging.
|
| 830 |
+
"""
|
| 831 |
+
original_fn = tool.async_fn if tool.async_fn is not None else tool.fn
|
| 832 |
+
|
| 833 |
+
@wraps(original_fn)
|
| 834 |
+
async def wrapped_generic(**kwargs: Any) -> FormattedResult:
|
| 835 |
+
# Call original tool
|
| 836 |
+
if tool.async_fn is not None:
|
| 837 |
+
raw_result = await tool.async_fn(**kwargs)
|
| 838 |
+
else:
|
| 839 |
+
raw_result = tool.fn(**kwargs)
|
| 840 |
+
|
| 841 |
+
result_dict = self._extract_result_dict(raw_result)
|
| 842 |
+
|
| 843 |
+
# Create basic formatted result
|
| 844 |
+
result = FormattedResult(
|
| 845 |
+
raw_result=result_dict,
|
| 846 |
+
chat_display=str(result_dict.get("formatted", str(raw_result))),
|
| 847 |
+
ui_data=result_dict,
|
| 848 |
+
voice_narration="",
|
| 849 |
+
)
|
| 850 |
+
|
| 851 |
+
# Log generic tool call
|
| 852 |
+
await self._safe_call_logger(
|
| 853 |
+
"tool_call",
|
| 854 |
+
f"Called {tool.metadata.name}",
|
| 855 |
+
{"tool": tool.metadata.name, "args": kwargs},
|
| 856 |
+
)
|
| 857 |
+
|
| 858 |
+
return result
|
| 859 |
+
|
| 860 |
+
return FunctionTool.from_defaults(
|
| 861 |
+
async_fn=wrapped_generic,
|
| 862 |
+
name=tool.metadata.name,
|
| 863 |
+
description=tool.metadata.description,
|
| 864 |
+
)
|
| 865 |
+
|
| 866 |
+
# =============================================================================
|
| 867 |
+
# Helper Methods
|
| 868 |
+
# =============================================================================
|
| 869 |
+
|
| 870 |
+
def _extract_result_dict(self, raw_result: Any) -> dict[str, object]:
|
| 871 |
+
"""Extract dict from various result types."""
|
| 872 |
+
if isinstance(raw_result, dict):
|
| 873 |
+
return raw_result
|
| 874 |
+
|
| 875 |
+
# Handle ToolOutput
|
| 876 |
+
if hasattr(raw_result, "raw_output"):
|
| 877 |
+
output = raw_result.raw_output
|
| 878 |
+
if isinstance(output, dict):
|
| 879 |
+
return output
|
| 880 |
+
return {"result": output}
|
| 881 |
+
|
| 882 |
+
# Handle Pydantic models
|
| 883 |
+
if hasattr(raw_result, "model_dump"):
|
| 884 |
+
dumped = raw_result.model_dump()
|
| 885 |
+
if isinstance(dumped, dict):
|
| 886 |
+
return dumped
|
| 887 |
+
return {"result": dumped}
|
| 888 |
+
|
| 889 |
+
return {"result": raw_result}
|
| 890 |
+
|
| 891 |
+
def _get_hp_status(self, hp_current: int, hp_max: int) -> str:
|
| 892 |
+
"""Get status string from HP values."""
|
| 893 |
+
if hp_current <= 0:
|
| 894 |
+
return "down"
|
| 895 |
+
if hp_current <= hp_max // 4:
|
| 896 |
+
return "critical"
|
| 897 |
+
if hp_current <= hp_max // 2:
|
| 898 |
+
return "wounded"
|
| 899 |
+
return "healthy"
|
| 900 |
+
|
| 901 |
+
async def _log_roll_event(
|
| 902 |
+
self,
|
| 903 |
+
result: DiceRollResult,
|
| 904 |
+
kwargs: dict[str, Any],
|
| 905 |
+
) -> None:
|
| 906 |
+
"""Log roll event to session."""
|
| 907 |
+
event_data = {
|
| 908 |
+
"notation": result.notation,
|
| 909 |
+
"total": result.total,
|
| 910 |
+
"rolls": result.individual_rolls,
|
| 911 |
+
"roll_type": result.roll_type.value,
|
| 912 |
+
"reason": kwargs.get("reason", ""),
|
| 913 |
+
}
|
| 914 |
+
if result.is_critical:
|
| 915 |
+
event_data["critical"] = True
|
| 916 |
+
if result.is_fumble:
|
| 917 |
+
event_data["fumble"] = True
|
| 918 |
+
|
| 919 |
+
await self._safe_call_logger(
|
| 920 |
+
"dice_roll",
|
| 921 |
+
f"Rolled {result.notation} = {result.total}",
|
| 922 |
+
event_data,
|
| 923 |
+
)
|
| 924 |
+
result.events_logged.append("dice_roll")
|
| 925 |
+
|
| 926 |
+
def _add_roll_to_game_state(self, result: DiceRollResult) -> None:
|
| 927 |
+
"""Add roll to game state recent events."""
|
| 928 |
+
if not self._game_state:
|
| 929 |
+
return
|
| 930 |
+
|
| 931 |
+
self._game_state.add_event(
|
| 932 |
+
event_type="roll",
|
| 933 |
+
description=f"Roll: {result.notation} = {result.total}",
|
| 934 |
+
data={
|
| 935 |
+
"notation": result.notation,
|
| 936 |
+
"total": result.total,
|
| 937 |
+
"roll_type": result.roll_type.value,
|
| 938 |
+
"is_critical": result.is_critical,
|
| 939 |
+
"is_fumble": result.is_fumble,
|
| 940 |
+
},
|
| 941 |
+
)
|
| 942 |
+
result.state_updated = True
|
| 943 |
+
|
| 944 |
+
async def _log_hp_event(self, result: HPChangeResult) -> None:
|
| 945 |
+
"""Log HP change event to session."""
|
| 946 |
+
event_type = "damage" if result.is_damage else "healing"
|
| 947 |
+
description = (
|
| 948 |
+
f"{result.character_name}: "
|
| 949 |
+
f"{result.previous_hp} -> {result.current_hp} HP"
|
| 950 |
+
)
|
| 951 |
+
|
| 952 |
+
await self._safe_call_logger(
|
| 953 |
+
event_type,
|
| 954 |
+
description,
|
| 955 |
+
{
|
| 956 |
+
"character_id": result.character_id,
|
| 957 |
+
"change": -result.change_amount
|
| 958 |
+
if result.is_damage
|
| 959 |
+
else result.change_amount,
|
| 960 |
+
"is_unconscious": result.is_unconscious,
|
| 961 |
+
"is_dead": result.is_dead,
|
| 962 |
+
},
|
| 963 |
+
)
|
| 964 |
+
result.events_logged.append(event_type)
|
| 965 |
+
|
| 966 |
+
def _update_character_hp(self, result: HPChangeResult) -> None:
|
| 967 |
+
"""Update character HP in game state cache."""
|
| 968 |
+
if not self._game_state:
|
| 969 |
+
return
|
| 970 |
+
|
| 971 |
+
# Update cache
|
| 972 |
+
char_data = self._game_state.get_character(result.character_id)
|
| 973 |
+
if char_data:
|
| 974 |
+
hp_data = char_data.get("hit_points")
|
| 975 |
+
if not isinstance(hp_data, dict):
|
| 976 |
+
hp_data = {}
|
| 977 |
+
char_data["hit_points"] = hp_data
|
| 978 |
+
hp_data["current"] = result.current_hp
|
| 979 |
+
self._game_state.update_character_cache(result.character_id, char_data)
|
| 980 |
+
|
| 981 |
+
# Log event
|
| 982 |
+
self._game_state.add_event(
|
| 983 |
+
event_type="hp_change",
|
| 984 |
+
description=(
|
| 985 |
+
f"{result.character_name} "
|
| 986 |
+
f"{'takes' if result.is_damage else 'heals'} "
|
| 987 |
+
f"{result.change_amount}"
|
| 988 |
+
),
|
| 989 |
+
data=result.ui_data,
|
| 990 |
+
)
|
| 991 |
+
result.state_updated = True
|
| 992 |
+
|
| 993 |
+
async def _log_combat_event(
|
| 994 |
+
self,
|
| 995 |
+
event_type: str,
|
| 996 |
+
data: dict[str, object],
|
| 997 |
+
) -> None:
|
| 998 |
+
"""Log combat event to session."""
|
| 999 |
+
await self._safe_call_logger(
|
| 1000 |
+
event_type,
|
| 1001 |
+
f"Combat {event_type.replace('combat_', '')}",
|
| 1002 |
+
data,
|
| 1003 |
+
)
|
src/mcp_integration/toolkit_client.py
ADDED
|
@@ -0,0 +1,520 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DungeonMaster AI - TTRPG Toolkit MCP Client
|
| 3 |
+
|
| 4 |
+
Core client for connecting to the TTRPG-Toolkit MCP server.
|
| 5 |
+
Uses llama-index-tools-mcp for connection management and tool conversion.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import logging
|
| 11 |
+
from collections.abc import Sequence
|
| 12 |
+
from typing import TYPE_CHECKING
|
| 13 |
+
|
| 14 |
+
from llama_index.core.tools import FunctionTool
|
| 15 |
+
from llama_index.tools.mcp import BasicMCPClient, McpToolSpec
|
| 16 |
+
|
| 17 |
+
from src.config.settings import get_settings
|
| 18 |
+
|
| 19 |
+
from .exceptions import (
|
| 20 |
+
MCPConnectionError,
|
| 21 |
+
MCPToolExecutionError,
|
| 22 |
+
MCPToolNotFoundError,
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
if TYPE_CHECKING:
|
| 26 |
+
from typing import Any
|
| 27 |
+
|
| 28 |
+
logger = logging.getLogger(__name__)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
# =============================================================================
|
| 32 |
+
# Tool Category Mapping
|
| 33 |
+
# =============================================================================
|
| 34 |
+
|
| 35 |
+
TOOL_CATEGORIES: dict[str, list[str]] = {
|
| 36 |
+
"dice": [
|
| 37 |
+
"roll",
|
| 38 |
+
"roll_check",
|
| 39 |
+
"roll_table",
|
| 40 |
+
"get_roll_statistics",
|
| 41 |
+
# MCP prefixed versions
|
| 42 |
+
"mcp_roll",
|
| 43 |
+
"mcp_roll_check",
|
| 44 |
+
"mcp_roll_table",
|
| 45 |
+
"mcp_get_roll_statistics",
|
| 46 |
+
],
|
| 47 |
+
"character": [
|
| 48 |
+
"create_character",
|
| 49 |
+
"get_character",
|
| 50 |
+
"modify_hp",
|
| 51 |
+
"rest",
|
| 52 |
+
"generate_ability_scores",
|
| 53 |
+
"add_condition",
|
| 54 |
+
"remove_condition",
|
| 55 |
+
"update_character",
|
| 56 |
+
"level_up",
|
| 57 |
+
"add_item",
|
| 58 |
+
"remove_item",
|
| 59 |
+
# MCP prefixed versions
|
| 60 |
+
"mcp_create_character",
|
| 61 |
+
"mcp_get_character",
|
| 62 |
+
"mcp_modify_hp",
|
| 63 |
+
"mcp_rest",
|
| 64 |
+
"mcp_generate_ability_scores",
|
| 65 |
+
"mcp_add_condition",
|
| 66 |
+
"mcp_remove_condition",
|
| 67 |
+
],
|
| 68 |
+
"rules": [
|
| 69 |
+
"search_rules",
|
| 70 |
+
"get_monster",
|
| 71 |
+
"search_monsters",
|
| 72 |
+
"get_spell",
|
| 73 |
+
"search_spells",
|
| 74 |
+
"get_class_info",
|
| 75 |
+
"get_race_info",
|
| 76 |
+
"get_item",
|
| 77 |
+
"get_condition",
|
| 78 |
+
# MCP prefixed versions
|
| 79 |
+
"mcp_search_rules",
|
| 80 |
+
"mcp_get_monster",
|
| 81 |
+
"mcp_search_monsters",
|
| 82 |
+
"mcp_get_spell",
|
| 83 |
+
"mcp_search_spells",
|
| 84 |
+
"mcp_get_class_info",
|
| 85 |
+
"mcp_get_race_info",
|
| 86 |
+
],
|
| 87 |
+
"generators": [
|
| 88 |
+
"generate_name",
|
| 89 |
+
"generate_npc",
|
| 90 |
+
"generate_encounter",
|
| 91 |
+
"generate_loot",
|
| 92 |
+
"generate_location",
|
| 93 |
+
# MCP prefixed versions
|
| 94 |
+
"mcp_generate_name",
|
| 95 |
+
"mcp_generate_npc",
|
| 96 |
+
"mcp_generate_encounter",
|
| 97 |
+
"mcp_generate_loot",
|
| 98 |
+
"mcp_generate_location",
|
| 99 |
+
],
|
| 100 |
+
"combat": [
|
| 101 |
+
"start_combat",
|
| 102 |
+
"roll_all_initiatives",
|
| 103 |
+
"get_turn_order",
|
| 104 |
+
"next_turn",
|
| 105 |
+
"apply_damage",
|
| 106 |
+
"apply_condition",
|
| 107 |
+
"remove_combat_condition",
|
| 108 |
+
"end_combat",
|
| 109 |
+
"get_combat_status",
|
| 110 |
+
# MCP prefixed versions
|
| 111 |
+
"mcp_start_combat",
|
| 112 |
+
"mcp_roll_all_initiatives",
|
| 113 |
+
"mcp_get_turn_order",
|
| 114 |
+
"mcp_next_turn",
|
| 115 |
+
"mcp_apply_damage",
|
| 116 |
+
"mcp_end_combat",
|
| 117 |
+
"mcp_get_combat_status",
|
| 118 |
+
],
|
| 119 |
+
"session": [
|
| 120 |
+
"start_session",
|
| 121 |
+
"end_session",
|
| 122 |
+
"log_event",
|
| 123 |
+
"get_session_summary",
|
| 124 |
+
"add_session_note",
|
| 125 |
+
"get_session_history",
|
| 126 |
+
"list_sessions",
|
| 127 |
+
# MCP prefixed versions
|
| 128 |
+
"mcp_start_session",
|
| 129 |
+
"mcp_end_session",
|
| 130 |
+
"mcp_log_event",
|
| 131 |
+
"mcp_get_session_summary",
|
| 132 |
+
"mcp_add_session_note",
|
| 133 |
+
],
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
class TTRPGToolkitClient:
|
| 138 |
+
"""
|
| 139 |
+
Client for connecting to TTRPG-Toolkit MCP server.
|
| 140 |
+
|
| 141 |
+
Uses llama-index-tools-mcp's BasicMCPClient for connection
|
| 142 |
+
and McpToolSpec for converting MCP tools to LlamaIndex FunctionTool objects.
|
| 143 |
+
|
| 144 |
+
Example:
|
| 145 |
+
```python
|
| 146 |
+
client = TTRPGToolkitClient()
|
| 147 |
+
await client.connect()
|
| 148 |
+
|
| 149 |
+
# Get all tools for DM agent
|
| 150 |
+
all_tools = await client.get_all_tools()
|
| 151 |
+
|
| 152 |
+
# Get only rules tools for Rules agent
|
| 153 |
+
rules_tools = await client.get_tools_by_category(["rules"])
|
| 154 |
+
|
| 155 |
+
# Direct tool call
|
| 156 |
+
result = await client.call_tool("roll", {"notation": "2d6+3"})
|
| 157 |
+
```
|
| 158 |
+
"""
|
| 159 |
+
|
| 160 |
+
def __init__(
|
| 161 |
+
self,
|
| 162 |
+
mcp_url: str | None = None,
|
| 163 |
+
timeout: int | None = None,
|
| 164 |
+
) -> None:
|
| 165 |
+
"""
|
| 166 |
+
Initialize the TTRPG Toolkit client.
|
| 167 |
+
|
| 168 |
+
Args:
|
| 169 |
+
mcp_url: MCP server URL. Defaults to settings.mcp.ttrpg_toolkit_mcp_url
|
| 170 |
+
timeout: Connection timeout in seconds. Defaults to settings.mcp.mcp_connection_timeout
|
| 171 |
+
"""
|
| 172 |
+
settings = get_settings()
|
| 173 |
+
|
| 174 |
+
self._url = mcp_url or settings.mcp.ttrpg_toolkit_mcp_url
|
| 175 |
+
self._timeout = timeout or settings.mcp.mcp_connection_timeout
|
| 176 |
+
|
| 177 |
+
# Internal state
|
| 178 |
+
self._client: BasicMCPClient | None = None
|
| 179 |
+
self._tool_spec: McpToolSpec | None = None
|
| 180 |
+
self._tools_cache: list[FunctionTool] | None = None
|
| 181 |
+
self._tools_by_name: dict[str, FunctionTool] | None = None
|
| 182 |
+
self._connected = False
|
| 183 |
+
|
| 184 |
+
logger.debug(f"TTRPGToolkitClient initialized with URL: {self._url}")
|
| 185 |
+
|
| 186 |
+
@property
|
| 187 |
+
def url(self) -> str:
|
| 188 |
+
"""Get the MCP server URL."""
|
| 189 |
+
return self._url
|
| 190 |
+
|
| 191 |
+
@property
|
| 192 |
+
def is_connected(self) -> bool:
|
| 193 |
+
"""Check if client is connected."""
|
| 194 |
+
return self._connected
|
| 195 |
+
|
| 196 |
+
@property
|
| 197 |
+
def tools_count(self) -> int:
|
| 198 |
+
"""Get number of cached tools."""
|
| 199 |
+
return len(self._tools_cache) if self._tools_cache else 0
|
| 200 |
+
|
| 201 |
+
async def connect(self) -> TTRPGToolkitClient:
|
| 202 |
+
"""
|
| 203 |
+
Establish connection to MCP server.
|
| 204 |
+
|
| 205 |
+
Creates BasicMCPClient and McpToolSpec, then fetches and caches
|
| 206 |
+
all available tools to verify the connection.
|
| 207 |
+
|
| 208 |
+
Returns:
|
| 209 |
+
Self for method chaining.
|
| 210 |
+
|
| 211 |
+
Raises:
|
| 212 |
+
MCPConnectionError: If connection fails.
|
| 213 |
+
"""
|
| 214 |
+
# Return early if already connected
|
| 215 |
+
if self._connected and self._client is not None:
|
| 216 |
+
logger.debug("Already connected to MCP server, skipping reconnection")
|
| 217 |
+
return self
|
| 218 |
+
|
| 219 |
+
logger.info(f"Connecting to MCP server at {self._url}...")
|
| 220 |
+
|
| 221 |
+
try:
|
| 222 |
+
# Create the MCP client
|
| 223 |
+
# BasicMCPClient auto-detects SSE vs streamable-http based on URL
|
| 224 |
+
self._client = BasicMCPClient(
|
| 225 |
+
command_or_url=self._url,
|
| 226 |
+
timeout=self._timeout,
|
| 227 |
+
)
|
| 228 |
+
|
| 229 |
+
# Create the tool spec for tool conversion
|
| 230 |
+
self._tool_spec = McpToolSpec(
|
| 231 |
+
client=self._client,
|
| 232 |
+
allowed_tools=None, # Get all tools
|
| 233 |
+
)
|
| 234 |
+
|
| 235 |
+
# Fetch tools to verify connection
|
| 236 |
+
# This also caches the tools
|
| 237 |
+
self._tools_cache = await self._tool_spec.to_tool_list_async()
|
| 238 |
+
|
| 239 |
+
# Build name lookup dict
|
| 240 |
+
self._tools_by_name = {
|
| 241 |
+
tool.metadata.name: tool for tool in self._tools_cache
|
| 242 |
+
if tool.metadata.name is not None
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
self._connected = True
|
| 246 |
+
logger.info(
|
| 247 |
+
f"Connected to MCP server with {len(self._tools_cache)} tools available"
|
| 248 |
+
)
|
| 249 |
+
|
| 250 |
+
return self
|
| 251 |
+
|
| 252 |
+
except Exception as e:
|
| 253 |
+
self._connected = False
|
| 254 |
+
self._client = None
|
| 255 |
+
self._tool_spec = None
|
| 256 |
+
self._tools_cache = None
|
| 257 |
+
self._tools_by_name = None
|
| 258 |
+
|
| 259 |
+
logger.error(f"Failed to connect to MCP server: {e}")
|
| 260 |
+
raise MCPConnectionError(
|
| 261 |
+
f"Failed to connect to MCP server: {e}",
|
| 262 |
+
url=self._url,
|
| 263 |
+
) from e
|
| 264 |
+
|
| 265 |
+
async def disconnect(self) -> None:
|
| 266 |
+
"""
|
| 267 |
+
Gracefully close connection and clear cache.
|
| 268 |
+
|
| 269 |
+
Safe to call even if not connected.
|
| 270 |
+
"""
|
| 271 |
+
logger.info("Disconnecting from MCP server...")
|
| 272 |
+
|
| 273 |
+
self._client = None
|
| 274 |
+
self._tool_spec = None
|
| 275 |
+
self._tools_cache = None
|
| 276 |
+
self._tools_by_name = None
|
| 277 |
+
self._connected = False
|
| 278 |
+
|
| 279 |
+
logger.info("Disconnected from MCP server")
|
| 280 |
+
|
| 281 |
+
async def get_all_tools(self) -> Sequence[FunctionTool]:
|
| 282 |
+
"""
|
| 283 |
+
Get all available tools as LlamaIndex FunctionTool objects.
|
| 284 |
+
|
| 285 |
+
Uses cached tools if available.
|
| 286 |
+
|
| 287 |
+
Returns:
|
| 288 |
+
Sequence of FunctionTool objects.
|
| 289 |
+
|
| 290 |
+
Raises:
|
| 291 |
+
MCPConnectionError: If not connected.
|
| 292 |
+
"""
|
| 293 |
+
if not self._connected or self._tools_cache is None:
|
| 294 |
+
raise MCPConnectionError(
|
| 295 |
+
"Not connected to MCP server. Call connect() first."
|
| 296 |
+
)
|
| 297 |
+
|
| 298 |
+
return self._tools_cache
|
| 299 |
+
|
| 300 |
+
async def get_tools_by_category(
|
| 301 |
+
self,
|
| 302 |
+
categories: Sequence[str],
|
| 303 |
+
) -> list[FunctionTool]:
|
| 304 |
+
"""
|
| 305 |
+
Get tools filtered by category names.
|
| 306 |
+
|
| 307 |
+
Args:
|
| 308 |
+
categories: List of category names from TOOL_CATEGORIES.
|
| 309 |
+
Valid categories: dice, character, rules, generators, combat, session
|
| 310 |
+
|
| 311 |
+
Returns:
|
| 312 |
+
Filtered list of FunctionTool objects.
|
| 313 |
+
|
| 314 |
+
Raises:
|
| 315 |
+
MCPConnectionError: If not connected.
|
| 316 |
+
"""
|
| 317 |
+
if not self._connected or self._tools_cache is None:
|
| 318 |
+
raise MCPConnectionError(
|
| 319 |
+
"Not connected to MCP server. Call connect() first."
|
| 320 |
+
)
|
| 321 |
+
|
| 322 |
+
# Build set of allowed tool names from categories
|
| 323 |
+
allowed_names: set[str] = set()
|
| 324 |
+
for category in categories:
|
| 325 |
+
if category in TOOL_CATEGORIES:
|
| 326 |
+
allowed_names.update(TOOL_CATEGORIES[category])
|
| 327 |
+
else:
|
| 328 |
+
logger.warning(f"Unknown tool category: {category}")
|
| 329 |
+
|
| 330 |
+
# Filter tools by name
|
| 331 |
+
filtered_tools = [
|
| 332 |
+
tool
|
| 333 |
+
for tool in self._tools_cache
|
| 334 |
+
if tool.metadata.name is not None and (
|
| 335 |
+
tool.metadata.name in allowed_names
|
| 336 |
+
or tool.metadata.name.replace("mcp_", "") in allowed_names
|
| 337 |
+
)
|
| 338 |
+
]
|
| 339 |
+
|
| 340 |
+
logger.debug(
|
| 341 |
+
f"Filtered {len(filtered_tools)} tools from categories: {categories}"
|
| 342 |
+
)
|
| 343 |
+
|
| 344 |
+
return filtered_tools
|
| 345 |
+
|
| 346 |
+
async def get_tool_by_name(self, tool_name: str) -> FunctionTool | None:
|
| 347 |
+
"""
|
| 348 |
+
Get a specific tool by name.
|
| 349 |
+
|
| 350 |
+
Args:
|
| 351 |
+
tool_name: Name of the tool to find.
|
| 352 |
+
|
| 353 |
+
Returns:
|
| 354 |
+
FunctionTool if found, None otherwise.
|
| 355 |
+
"""
|
| 356 |
+
if not self._connected or self._tools_by_name is None:
|
| 357 |
+
return None
|
| 358 |
+
|
| 359 |
+
# Try exact match first
|
| 360 |
+
tool = self._tools_by_name.get(tool_name)
|
| 361 |
+
if tool:
|
| 362 |
+
return tool
|
| 363 |
+
|
| 364 |
+
# Try with mcp_ prefix
|
| 365 |
+
tool = self._tools_by_name.get(f"mcp_{tool_name}")
|
| 366 |
+
if tool:
|
| 367 |
+
return tool
|
| 368 |
+
|
| 369 |
+
# Try without mcp_ prefix
|
| 370 |
+
if tool_name.startswith("mcp_"):
|
| 371 |
+
tool = self._tools_by_name.get(tool_name[4:])
|
| 372 |
+
|
| 373 |
+
return tool
|
| 374 |
+
|
| 375 |
+
async def call_tool(
|
| 376 |
+
self,
|
| 377 |
+
tool_name: str,
|
| 378 |
+
arguments: dict[str, Any],
|
| 379 |
+
) -> Any:
|
| 380 |
+
"""
|
| 381 |
+
Call a tool directly without going through an agent.
|
| 382 |
+
|
| 383 |
+
Useful for direct API access and internal operations.
|
| 384 |
+
|
| 385 |
+
Args:
|
| 386 |
+
tool_name: Name of the tool to call.
|
| 387 |
+
arguments: Arguments to pass to the tool.
|
| 388 |
+
|
| 389 |
+
Returns:
|
| 390 |
+
Tool result (type varies by tool).
|
| 391 |
+
|
| 392 |
+
Raises:
|
| 393 |
+
MCPConnectionError: If not connected.
|
| 394 |
+
MCPToolNotFoundError: If tool doesn't exist.
|
| 395 |
+
MCPToolExecutionError: If tool execution fails.
|
| 396 |
+
"""
|
| 397 |
+
if not self._connected:
|
| 398 |
+
raise MCPConnectionError(
|
| 399 |
+
"Not connected to MCP server. Call connect() first."
|
| 400 |
+
)
|
| 401 |
+
|
| 402 |
+
tool = await self.get_tool_by_name(tool_name)
|
| 403 |
+
if tool is None:
|
| 404 |
+
raise MCPToolNotFoundError(tool_name)
|
| 405 |
+
|
| 406 |
+
try:
|
| 407 |
+
logger.debug(f"Calling tool '{tool_name}' with args: {arguments}")
|
| 408 |
+
result = await tool.acall(**arguments)
|
| 409 |
+
|
| 410 |
+
# FunctionTool.acall returns ToolOutput, extract raw_output
|
| 411 |
+
if hasattr(result, "raw_output"):
|
| 412 |
+
return result.raw_output
|
| 413 |
+
return result
|
| 414 |
+
|
| 415 |
+
except Exception as e:
|
| 416 |
+
logger.error(f"Tool '{tool_name}' execution failed: {e}")
|
| 417 |
+
raise MCPToolExecutionError(tool_name, e) from e
|
| 418 |
+
|
| 419 |
+
def call_tool_sync(
|
| 420 |
+
self,
|
| 421 |
+
tool_name: str,
|
| 422 |
+
arguments: dict[str, Any],
|
| 423 |
+
) -> Any:
|
| 424 |
+
"""
|
| 425 |
+
Synchronous wrapper for call_tool.
|
| 426 |
+
|
| 427 |
+
Uses asyncio.run() for synchronous contexts.
|
| 428 |
+
Prefer call_tool() in async code.
|
| 429 |
+
|
| 430 |
+
Args:
|
| 431 |
+
tool_name: Name of the tool to call.
|
| 432 |
+
arguments: Arguments to pass to the tool.
|
| 433 |
+
|
| 434 |
+
Returns:
|
| 435 |
+
Tool result.
|
| 436 |
+
"""
|
| 437 |
+
import asyncio
|
| 438 |
+
|
| 439 |
+
return asyncio.run(self.call_tool(tool_name, arguments))
|
| 440 |
+
|
| 441 |
+
async def list_tool_names(self) -> list[str]:
|
| 442 |
+
"""
|
| 443 |
+
Get list of all available tool names.
|
| 444 |
+
|
| 445 |
+
Returns:
|
| 446 |
+
List of tool names.
|
| 447 |
+
|
| 448 |
+
Raises:
|
| 449 |
+
MCPConnectionError: If not connected.
|
| 450 |
+
"""
|
| 451 |
+
if not self._connected or self._tools_by_name is None:
|
| 452 |
+
raise MCPConnectionError("Not connected to MCP server.")
|
| 453 |
+
|
| 454 |
+
return list(self._tools_by_name.keys())
|
| 455 |
+
|
| 456 |
+
async def has_tool(self, tool_name: str) -> bool:
|
| 457 |
+
"""
|
| 458 |
+
Check if a tool is available.
|
| 459 |
+
|
| 460 |
+
Args:
|
| 461 |
+
tool_name: Name of the tool to check.
|
| 462 |
+
|
| 463 |
+
Returns:
|
| 464 |
+
True if tool exists, False otherwise.
|
| 465 |
+
"""
|
| 466 |
+
tool = await self.get_tool_by_name(tool_name)
|
| 467 |
+
return tool is not None
|
| 468 |
+
|
| 469 |
+
def get_category_for_tool(self, tool_name: str) -> str | None:
|
| 470 |
+
"""
|
| 471 |
+
Get the category for a tool name.
|
| 472 |
+
|
| 473 |
+
Args:
|
| 474 |
+
tool_name: Name of the tool.
|
| 475 |
+
|
| 476 |
+
Returns:
|
| 477 |
+
Category name or None if not found.
|
| 478 |
+
"""
|
| 479 |
+
# Normalize name (remove mcp_ prefix)
|
| 480 |
+
normalized = tool_name.replace("mcp_", "")
|
| 481 |
+
|
| 482 |
+
for category, tools in TOOL_CATEGORIES.items():
|
| 483 |
+
if normalized in tools or tool_name in tools:
|
| 484 |
+
return category
|
| 485 |
+
|
| 486 |
+
return None
|
| 487 |
+
|
| 488 |
+
async def refresh_tools(self) -> int:
|
| 489 |
+
"""
|
| 490 |
+
Refresh the cached tool list from the server.
|
| 491 |
+
|
| 492 |
+
Useful after server updates or if tools might have changed.
|
| 493 |
+
|
| 494 |
+
Returns:
|
| 495 |
+
Number of tools now available.
|
| 496 |
+
|
| 497 |
+
Raises:
|
| 498 |
+
MCPConnectionError: If not connected or refresh fails.
|
| 499 |
+
"""
|
| 500 |
+
if not self._connected or self._tool_spec is None:
|
| 501 |
+
raise MCPConnectionError("Not connected to MCP server.")
|
| 502 |
+
|
| 503 |
+
try:
|
| 504 |
+
self._tools_cache = await self._tool_spec.to_tool_list_async()
|
| 505 |
+
self._tools_by_name = {
|
| 506 |
+
tool.metadata.name: tool for tool in self._tools_cache
|
| 507 |
+
if tool.metadata.name is not None
|
| 508 |
+
}
|
| 509 |
+
logger.info(f"Refreshed tool cache: {len(self._tools_cache)} tools")
|
| 510 |
+
return len(self._tools_cache)
|
| 511 |
+
|
| 512 |
+
except Exception as e:
|
| 513 |
+
logger.error(f"Failed to refresh tools: {e}")
|
| 514 |
+
raise MCPConnectionError(f"Failed to refresh tools: {e}") from e
|
| 515 |
+
|
| 516 |
+
def __repr__(self) -> str:
|
| 517 |
+
"""String representation."""
|
| 518 |
+
status = "connected" if self._connected else "disconnected"
|
| 519 |
+
tools = self.tools_count
|
| 520 |
+
return f"TTRPGToolkitClient(url={self._url!r}, status={status}, tools={tools})"
|
src/utils/__init__.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DungeonMaster AI - Utilities Package
|
| 3 |
+
|
| 4 |
+
Helper functions for formatting, validation, and common operations.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from src.utils.formatters import (
|
| 8 |
+
format_ability_modifier,
|
| 9 |
+
format_adventure_intro,
|
| 10 |
+
format_character_summary,
|
| 11 |
+
format_combat_turn,
|
| 12 |
+
format_condition_list,
|
| 13 |
+
format_currency,
|
| 14 |
+
format_dice_roll,
|
| 15 |
+
format_hp_change,
|
| 16 |
+
format_initiative_order,
|
| 17 |
+
)
|
| 18 |
+
from src.utils.validators import (
|
| 19 |
+
ValidationError,
|
| 20 |
+
sanitize_for_tts,
|
| 21 |
+
validate_ability_score,
|
| 22 |
+
validate_adventure_data,
|
| 23 |
+
validate_character_name,
|
| 24 |
+
validate_dice_notation,
|
| 25 |
+
validate_hp,
|
| 26 |
+
validate_level,
|
| 27 |
+
validate_player_input,
|
| 28 |
+
validate_session_data,
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
__all__ = [
|
| 32 |
+
# Formatters
|
| 33 |
+
"format_dice_roll",
|
| 34 |
+
"format_hp_change",
|
| 35 |
+
"format_combat_turn",
|
| 36 |
+
"format_ability_modifier",
|
| 37 |
+
"format_currency",
|
| 38 |
+
"format_condition_list",
|
| 39 |
+
"format_initiative_order",
|
| 40 |
+
"format_character_summary",
|
| 41 |
+
"format_adventure_intro",
|
| 42 |
+
# Validators
|
| 43 |
+
"ValidationError",
|
| 44 |
+
"validate_dice_notation",
|
| 45 |
+
"validate_character_name",
|
| 46 |
+
"validate_ability_score",
|
| 47 |
+
"validate_level",
|
| 48 |
+
"validate_hp",
|
| 49 |
+
"validate_player_input",
|
| 50 |
+
"validate_session_data",
|
| 51 |
+
"validate_adventure_data",
|
| 52 |
+
"sanitize_for_tts",
|
| 53 |
+
]
|
src/utils/formatters.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DungeonMaster AI - Output Formatters
|
| 3 |
+
|
| 4 |
+
Utilities for formatting game data for display in chat and UI components.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def format_dice_roll(
|
| 11 |
+
notation: str,
|
| 12 |
+
individual_rolls: list[int],
|
| 13 |
+
modifier: int,
|
| 14 |
+
total: int,
|
| 15 |
+
is_check: bool = False,
|
| 16 |
+
dc: int | None = None,
|
| 17 |
+
success: bool | None = None,
|
| 18 |
+
) -> str:
|
| 19 |
+
"""
|
| 20 |
+
Format a dice roll result for display.
|
| 21 |
+
|
| 22 |
+
Args:
|
| 23 |
+
notation: The dice notation (e.g., "2d6+3")
|
| 24 |
+
individual_rolls: List of individual die results
|
| 25 |
+
modifier: The modifier applied to the roll
|
| 26 |
+
total: The final total
|
| 27 |
+
is_check: Whether this was an ability check/save
|
| 28 |
+
dc: Difficulty class if this was a check
|
| 29 |
+
success: Whether the check succeeded (if applicable)
|
| 30 |
+
|
| 31 |
+
Returns:
|
| 32 |
+
Formatted string for display
|
| 33 |
+
"""
|
| 34 |
+
# Format individual dice
|
| 35 |
+
dice_str = ", ".join(str(r) for r in individual_rolls)
|
| 36 |
+
|
| 37 |
+
# Build the breakdown
|
| 38 |
+
if modifier > 0:
|
| 39 |
+
breakdown = f"[{dice_str}] + {modifier}"
|
| 40 |
+
elif modifier < 0:
|
| 41 |
+
breakdown = f"[{dice_str}] - {abs(modifier)}"
|
| 42 |
+
else:
|
| 43 |
+
breakdown = f"[{dice_str}]"
|
| 44 |
+
|
| 45 |
+
# Check for critical (d20 rolls)
|
| 46 |
+
is_critical = False
|
| 47 |
+
is_fumble = False
|
| 48 |
+
if len(individual_rolls) == 1 and individual_rolls[0] == 20:
|
| 49 |
+
is_critical = "d20" in notation.lower()
|
| 50 |
+
if len(individual_rolls) == 1 and individual_rolls[0] == 1:
|
| 51 |
+
is_fumble = "d20" in notation.lower()
|
| 52 |
+
|
| 53 |
+
# Build result string
|
| 54 |
+
result_parts = [f"{notation} = {breakdown} = **{total}**"]
|
| 55 |
+
|
| 56 |
+
if is_critical:
|
| 57 |
+
result_parts.append("**CRITICAL!**")
|
| 58 |
+
elif is_fumble:
|
| 59 |
+
result_parts.append("*Critical Failure!*")
|
| 60 |
+
|
| 61 |
+
if is_check and dc is not None:
|
| 62 |
+
if success:
|
| 63 |
+
result_parts.append(f"vs DC {dc}: **Success!**")
|
| 64 |
+
else:
|
| 65 |
+
result_parts.append(f"vs DC {dc}: *Failure*")
|
| 66 |
+
|
| 67 |
+
return " ".join(result_parts)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def format_hp_change(
|
| 71 |
+
character_name: str,
|
| 72 |
+
previous_hp: int,
|
| 73 |
+
current_hp: int,
|
| 74 |
+
max_hp: int,
|
| 75 |
+
is_damage: bool,
|
| 76 |
+
) -> str:
|
| 77 |
+
"""
|
| 78 |
+
Format an HP change for display.
|
| 79 |
+
|
| 80 |
+
Args:
|
| 81 |
+
character_name: Name of the character
|
| 82 |
+
previous_hp: HP before the change
|
| 83 |
+
current_hp: HP after the change
|
| 84 |
+
max_hp: Maximum HP
|
| 85 |
+
is_damage: True if damage, False if healing
|
| 86 |
+
|
| 87 |
+
Returns:
|
| 88 |
+
Formatted string for display
|
| 89 |
+
"""
|
| 90 |
+
change = abs(current_hp - previous_hp)
|
| 91 |
+
|
| 92 |
+
if is_damage:
|
| 93 |
+
if current_hp <= 0:
|
| 94 |
+
return f"**{character_name}** takes {change} damage and falls unconscious! (0/{max_hp} HP)"
|
| 95 |
+
elif current_hp <= max_hp // 4:
|
| 96 |
+
return f"**{character_name}** takes {change} damage and is badly wounded! ({current_hp}/{max_hp} HP)"
|
| 97 |
+
else:
|
| 98 |
+
return f"**{character_name}** takes {change} damage. ({current_hp}/{max_hp} HP)"
|
| 99 |
+
else:
|
| 100 |
+
if current_hp >= max_hp:
|
| 101 |
+
return f"**{character_name}** is fully healed! ({max_hp}/{max_hp} HP)"
|
| 102 |
+
else:
|
| 103 |
+
return f"**{character_name}** heals {change} HP. ({current_hp}/{max_hp} HP)"
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def format_combat_turn(
|
| 107 |
+
combatant_name: str,
|
| 108 |
+
initiative: int,
|
| 109 |
+
is_player: bool,
|
| 110 |
+
round_number: int,
|
| 111 |
+
) -> str:
|
| 112 |
+
"""
|
| 113 |
+
Format a combat turn announcement.
|
| 114 |
+
|
| 115 |
+
Args:
|
| 116 |
+
combatant_name: Name of the combatant whose turn it is
|
| 117 |
+
initiative: Their initiative value
|
| 118 |
+
is_player: Whether this is a player character
|
| 119 |
+
round_number: Current combat round
|
| 120 |
+
|
| 121 |
+
Returns:
|
| 122 |
+
Formatted string for display
|
| 123 |
+
"""
|
| 124 |
+
if is_player:
|
| 125 |
+
return f"**Round {round_number}** - It's your turn, **{combatant_name}**! (Initiative: {initiative})"
|
| 126 |
+
else:
|
| 127 |
+
return f"**Round {round_number}** - **{combatant_name}** takes their turn. (Initiative: {initiative})"
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def format_ability_modifier(score: int) -> str:
|
| 131 |
+
"""
|
| 132 |
+
Format an ability score modifier for display.
|
| 133 |
+
|
| 134 |
+
Args:
|
| 135 |
+
score: The ability score (1-30)
|
| 136 |
+
|
| 137 |
+
Returns:
|
| 138 |
+
Formatted modifier string (e.g., "+2" or "-1")
|
| 139 |
+
"""
|
| 140 |
+
modifier = (score - 10) // 2
|
| 141 |
+
if modifier >= 0:
|
| 142 |
+
return f"+{modifier}"
|
| 143 |
+
return str(modifier)
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def format_currency(gp: int = 0, sp: int = 0, cp: int = 0) -> str:
|
| 147 |
+
"""
|
| 148 |
+
Format currency for display.
|
| 149 |
+
|
| 150 |
+
Args:
|
| 151 |
+
gp: Gold pieces
|
| 152 |
+
sp: Silver pieces
|
| 153 |
+
cp: Copper pieces
|
| 154 |
+
|
| 155 |
+
Returns:
|
| 156 |
+
Formatted currency string
|
| 157 |
+
"""
|
| 158 |
+
parts = []
|
| 159 |
+
if gp > 0:
|
| 160 |
+
parts.append(f"{gp} gp")
|
| 161 |
+
if sp > 0:
|
| 162 |
+
parts.append(f"{sp} sp")
|
| 163 |
+
if cp > 0:
|
| 164 |
+
parts.append(f"{cp} cp")
|
| 165 |
+
|
| 166 |
+
if not parts:
|
| 167 |
+
return "0 gp"
|
| 168 |
+
return ", ".join(parts)
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def format_condition_list(conditions: list[str]) -> str:
|
| 172 |
+
"""
|
| 173 |
+
Format a list of conditions for display.
|
| 174 |
+
|
| 175 |
+
Args:
|
| 176 |
+
conditions: List of condition names
|
| 177 |
+
|
| 178 |
+
Returns:
|
| 179 |
+
Formatted string or empty message
|
| 180 |
+
"""
|
| 181 |
+
if not conditions:
|
| 182 |
+
return "None"
|
| 183 |
+
return ", ".join(conditions)
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def format_initiative_order(
|
| 187 |
+
combatants: list[dict[str, Any]],
|
| 188 |
+
current_turn_index: int,
|
| 189 |
+
) -> str:
|
| 190 |
+
"""
|
| 191 |
+
Format the initiative order for display.
|
| 192 |
+
|
| 193 |
+
Args:
|
| 194 |
+
combatants: List of combatant dictionaries with name, initiative, hp info
|
| 195 |
+
current_turn_index: Index of current turn combatant
|
| 196 |
+
|
| 197 |
+
Returns:
|
| 198 |
+
Formatted initiative order string
|
| 199 |
+
"""
|
| 200 |
+
lines = ["**Initiative Order:**"]
|
| 201 |
+
|
| 202 |
+
for i, combatant in enumerate(combatants):
|
| 203 |
+
marker = ">" if i == current_turn_index else " "
|
| 204 |
+
name = combatant.get("name", "Unknown")
|
| 205 |
+
init = combatant.get("initiative", 0)
|
| 206 |
+
hp_current = combatant.get("hp_current", 0)
|
| 207 |
+
hp_max = combatant.get("hp_max", 1)
|
| 208 |
+
|
| 209 |
+
hp_percent = (hp_current / hp_max) * 100 if hp_max > 0 else 0
|
| 210 |
+
if hp_percent > 50:
|
| 211 |
+
status = "Healthy"
|
| 212 |
+
elif hp_percent > 25:
|
| 213 |
+
status = "Wounded"
|
| 214 |
+
elif hp_percent > 0:
|
| 215 |
+
status = "Critical"
|
| 216 |
+
else:
|
| 217 |
+
status = "Down"
|
| 218 |
+
|
| 219 |
+
lines.append(f"{marker} {init:2d} | {name} ({status})")
|
| 220 |
+
|
| 221 |
+
return "\n".join(lines)
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def format_character_summary(character: dict[str, Any]) -> str:
|
| 225 |
+
"""
|
| 226 |
+
Format a character summary for context.
|
| 227 |
+
|
| 228 |
+
Args:
|
| 229 |
+
character: Character data dictionary
|
| 230 |
+
|
| 231 |
+
Returns:
|
| 232 |
+
Formatted character summary
|
| 233 |
+
"""
|
| 234 |
+
name = character.get("name", "Unknown")
|
| 235 |
+
race = character.get("race", "Unknown")
|
| 236 |
+
char_class = character.get("class", "Unknown")
|
| 237 |
+
level = character.get("level", 1)
|
| 238 |
+
|
| 239 |
+
hp = character.get("hit_points", {})
|
| 240 |
+
hp_current = hp.get("current", 0)
|
| 241 |
+
hp_max = hp.get("maximum", 1)
|
| 242 |
+
|
| 243 |
+
ac = character.get("armor_class", 10)
|
| 244 |
+
conditions = character.get("conditions", [])
|
| 245 |
+
|
| 246 |
+
summary = f"**{name}** - Level {level} {race} {char_class}\n"
|
| 247 |
+
summary += f"HP: {hp_current}/{hp_max} | AC: {ac}\n"
|
| 248 |
+
|
| 249 |
+
if conditions:
|
| 250 |
+
summary += f"Conditions: {format_condition_list(conditions)}"
|
| 251 |
+
|
| 252 |
+
return summary
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def format_adventure_intro(adventure_data: dict[str, Any]) -> str:
|
| 256 |
+
"""
|
| 257 |
+
Format an adventure introduction for the opening narration.
|
| 258 |
+
|
| 259 |
+
Args:
|
| 260 |
+
adventure_data: Adventure JSON data
|
| 261 |
+
|
| 262 |
+
Returns:
|
| 263 |
+
Formatted introduction text
|
| 264 |
+
"""
|
| 265 |
+
metadata = adventure_data.get("metadata", {})
|
| 266 |
+
starting_scene = adventure_data.get("starting_scene", {})
|
| 267 |
+
|
| 268 |
+
name = metadata.get("name", "Unnamed Adventure")
|
| 269 |
+
description = metadata.get("description", "")
|
| 270 |
+
narrative = starting_scene.get("narrative", "Your adventure begins...")
|
| 271 |
+
|
| 272 |
+
intro = f"# {name}\n\n"
|
| 273 |
+
if description:
|
| 274 |
+
intro += f"*{description}*\n\n"
|
| 275 |
+
intro += "---\n\n"
|
| 276 |
+
intro += str(narrative)
|
| 277 |
+
|
| 278 |
+
return intro
|
src/utils/validators.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DungeonMaster AI - Input Validators
|
| 3 |
+
|
| 4 |
+
Utilities for validating user input and game data.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import re
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class ValidationError(Exception):
|
| 12 |
+
"""Custom exception for validation errors."""
|
| 13 |
+
|
| 14 |
+
def __init__(self, message: str, field: str | None = None):
|
| 15 |
+
self.message = message
|
| 16 |
+
self.field = field
|
| 17 |
+
super().__init__(self.message)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def validate_dice_notation(notation: str) -> bool:
|
| 21 |
+
"""
|
| 22 |
+
Validate dice notation format.
|
| 23 |
+
|
| 24 |
+
Valid formats:
|
| 25 |
+
- d20, D20
|
| 26 |
+
- 2d6, 2D6
|
| 27 |
+
- 1d8+5, 1d8-2
|
| 28 |
+
- 4d6kh3 (keep highest 3)
|
| 29 |
+
- 2d20kl1 (keep lowest 1, disadvantage)
|
| 30 |
+
|
| 31 |
+
Args:
|
| 32 |
+
notation: The dice notation string
|
| 33 |
+
|
| 34 |
+
Returns:
|
| 35 |
+
True if valid, False otherwise
|
| 36 |
+
"""
|
| 37 |
+
# Pattern for standard dice notation with optional modifiers and keep syntax
|
| 38 |
+
pattern = r"^(\d+)?[dD](\d+)(kh\d+|kl\d+)?([+-]\d+)?$"
|
| 39 |
+
return bool(re.match(pattern, notation.strip()))
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def validate_character_name(name: str) -> tuple[bool, str]:
|
| 43 |
+
"""
|
| 44 |
+
Validate a character name.
|
| 45 |
+
|
| 46 |
+
Args:
|
| 47 |
+
name: The proposed character name
|
| 48 |
+
|
| 49 |
+
Returns:
|
| 50 |
+
Tuple of (is_valid, error_message or empty string)
|
| 51 |
+
"""
|
| 52 |
+
if not name or not name.strip():
|
| 53 |
+
return False, "Character name cannot be empty"
|
| 54 |
+
|
| 55 |
+
name = name.strip()
|
| 56 |
+
|
| 57 |
+
if len(name) < 2:
|
| 58 |
+
return False, "Character name must be at least 2 characters"
|
| 59 |
+
|
| 60 |
+
if len(name) > 50:
|
| 61 |
+
return False, "Character name must be 50 characters or less"
|
| 62 |
+
|
| 63 |
+
# Allow letters, spaces, apostrophes, and hyphens
|
| 64 |
+
if not re.match(r"^[a-zA-Z][a-zA-Z\s'\-]*$", name):
|
| 65 |
+
return False, "Character name must start with a letter and contain only letters, spaces, apostrophes, and hyphens"
|
| 66 |
+
|
| 67 |
+
return True, ""
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def validate_ability_score(score: int, method: str = "standard") -> tuple[bool, str]:
|
| 71 |
+
"""
|
| 72 |
+
Validate an ability score.
|
| 73 |
+
|
| 74 |
+
Args:
|
| 75 |
+
score: The ability score value
|
| 76 |
+
method: The generation method ("standard", "point_buy", "rolled")
|
| 77 |
+
|
| 78 |
+
Returns:
|
| 79 |
+
Tuple of (is_valid, error_message or empty string)
|
| 80 |
+
"""
|
| 81 |
+
if not isinstance(score, int):
|
| 82 |
+
return False, "Ability score must be an integer"
|
| 83 |
+
|
| 84 |
+
if method == "point_buy":
|
| 85 |
+
if score < 8 or score > 15:
|
| 86 |
+
return False, "Point buy scores must be between 8 and 15"
|
| 87 |
+
elif method == "standard":
|
| 88 |
+
valid_scores = [8, 10, 12, 13, 14, 15]
|
| 89 |
+
if score not in valid_scores:
|
| 90 |
+
return False, f"Standard array scores must be one of {valid_scores}"
|
| 91 |
+
else: # rolled or other
|
| 92 |
+
if score < 3 or score > 18:
|
| 93 |
+
return False, "Rolled scores must be between 3 and 18"
|
| 94 |
+
|
| 95 |
+
return True, ""
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def validate_level(level: int) -> tuple[bool, str]:
|
| 99 |
+
"""
|
| 100 |
+
Validate a character level.
|
| 101 |
+
|
| 102 |
+
Args:
|
| 103 |
+
level: The character level
|
| 104 |
+
|
| 105 |
+
Returns:
|
| 106 |
+
Tuple of (is_valid, error_message or empty string)
|
| 107 |
+
"""
|
| 108 |
+
if not isinstance(level, int):
|
| 109 |
+
return False, "Level must be an integer"
|
| 110 |
+
|
| 111 |
+
if level < 1 or level > 20:
|
| 112 |
+
return False, "Level must be between 1 and 20"
|
| 113 |
+
|
| 114 |
+
return True, ""
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def validate_hp(
|
| 118 |
+
current: int, maximum: int, allow_negative: bool = False
|
| 119 |
+
) -> tuple[bool, str]:
|
| 120 |
+
"""
|
| 121 |
+
Validate HP values.
|
| 122 |
+
|
| 123 |
+
Args:
|
| 124 |
+
current: Current HP
|
| 125 |
+
maximum: Maximum HP
|
| 126 |
+
allow_negative: Whether to allow negative current HP (for massive damage)
|
| 127 |
+
|
| 128 |
+
Returns:
|
| 129 |
+
Tuple of (is_valid, error_message or empty string)
|
| 130 |
+
"""
|
| 131 |
+
if not isinstance(current, int) or not isinstance(maximum, int):
|
| 132 |
+
return False, "HP values must be integers"
|
| 133 |
+
|
| 134 |
+
if maximum < 1:
|
| 135 |
+
return False, "Maximum HP must be at least 1"
|
| 136 |
+
|
| 137 |
+
if not allow_negative and current < 0:
|
| 138 |
+
return False, "Current HP cannot be negative"
|
| 139 |
+
|
| 140 |
+
if allow_negative and current < -maximum:
|
| 141 |
+
return False, "Current HP cannot be less than negative maximum HP"
|
| 142 |
+
|
| 143 |
+
return True, ""
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def validate_player_input(
|
| 147 |
+
message: str, max_length: int = 1000
|
| 148 |
+
) -> tuple[bool, str, str]:
|
| 149 |
+
"""
|
| 150 |
+
Validate and sanitize player input.
|
| 151 |
+
|
| 152 |
+
Args:
|
| 153 |
+
message: The player's input message
|
| 154 |
+
max_length: Maximum allowed length
|
| 155 |
+
|
| 156 |
+
Returns:
|
| 157 |
+
Tuple of (is_valid, sanitized_message, error_message)
|
| 158 |
+
"""
|
| 159 |
+
if not message:
|
| 160 |
+
return False, "", "Please enter an action or message"
|
| 161 |
+
|
| 162 |
+
# Strip and normalize whitespace
|
| 163 |
+
sanitized = " ".join(message.split())
|
| 164 |
+
|
| 165 |
+
if len(sanitized) > max_length:
|
| 166 |
+
return False, "", f"Message is too long (max {max_length} characters)"
|
| 167 |
+
|
| 168 |
+
# Check for potentially problematic content
|
| 169 |
+
# (In a real app, might want more sophisticated content filtering)
|
| 170 |
+
if sanitized.count("```") > 4:
|
| 171 |
+
return False, sanitized, "Message contains too many code blocks"
|
| 172 |
+
|
| 173 |
+
return True, sanitized, ""
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def validate_session_data(data: dict[str, Any]) -> tuple[bool, str]:
|
| 177 |
+
"""
|
| 178 |
+
Validate session/save data structure.
|
| 179 |
+
|
| 180 |
+
Args:
|
| 181 |
+
data: The session data dictionary
|
| 182 |
+
|
| 183 |
+
Returns:
|
| 184 |
+
Tuple of (is_valid, error_message or empty string)
|
| 185 |
+
"""
|
| 186 |
+
required_fields = ["session_id", "started_at", "system"]
|
| 187 |
+
|
| 188 |
+
for field in required_fields:
|
| 189 |
+
if field not in data:
|
| 190 |
+
return False, f"Missing required field: {field}"
|
| 191 |
+
|
| 192 |
+
if data.get("system") not in ["dnd5e", "pathfinder2e", "call_of_cthulhu", "fate"]:
|
| 193 |
+
return False, "Invalid game system"
|
| 194 |
+
|
| 195 |
+
if "party" in data and not isinstance(data["party"], list):
|
| 196 |
+
return False, "Party must be a list"
|
| 197 |
+
|
| 198 |
+
if "game_state" in data:
|
| 199 |
+
state = data["game_state"]
|
| 200 |
+
if "in_combat" in state and not isinstance(state["in_combat"], bool):
|
| 201 |
+
return False, "in_combat must be a boolean"
|
| 202 |
+
|
| 203 |
+
return True, ""
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def validate_adventure_data(data: dict[str, Any]) -> tuple[bool, str]:
|
| 207 |
+
"""
|
| 208 |
+
Validate adventure JSON structure.
|
| 209 |
+
|
| 210 |
+
Args:
|
| 211 |
+
data: The adventure data dictionary
|
| 212 |
+
|
| 213 |
+
Returns:
|
| 214 |
+
Tuple of (is_valid, error_message or empty string)
|
| 215 |
+
"""
|
| 216 |
+
if "metadata" not in data:
|
| 217 |
+
return False, "Adventure must have metadata"
|
| 218 |
+
|
| 219 |
+
metadata = data["metadata"]
|
| 220 |
+
required_metadata = ["name", "description", "difficulty"]
|
| 221 |
+
for field in required_metadata:
|
| 222 |
+
if field not in metadata:
|
| 223 |
+
return False, f"Metadata missing required field: {field}"
|
| 224 |
+
|
| 225 |
+
if "starting_scene" not in data:
|
| 226 |
+
return False, "Adventure must have a starting_scene"
|
| 227 |
+
|
| 228 |
+
if "scenes" not in data or not isinstance(data["scenes"], list):
|
| 229 |
+
return False, "Adventure must have a scenes array"
|
| 230 |
+
|
| 231 |
+
if len(data["scenes"]) == 0:
|
| 232 |
+
return False, "Adventure must have at least one scene"
|
| 233 |
+
|
| 234 |
+
# Validate scene references
|
| 235 |
+
scene_ids = {scene.get("scene_id") for scene in data["scenes"]}
|
| 236 |
+
starting_id = data["starting_scene"].get("scene_id")
|
| 237 |
+
if starting_id not in scene_ids:
|
| 238 |
+
return False, f"Starting scene '{starting_id}' not found in scenes"
|
| 239 |
+
|
| 240 |
+
return True, ""
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def sanitize_for_tts(text: str) -> str:
|
| 244 |
+
"""
|
| 245 |
+
Sanitize text for text-to-speech processing.
|
| 246 |
+
|
| 247 |
+
Removes or converts elements that might cause TTS issues.
|
| 248 |
+
|
| 249 |
+
Args:
|
| 250 |
+
text: The raw text
|
| 251 |
+
|
| 252 |
+
Returns:
|
| 253 |
+
Sanitized text suitable for TTS
|
| 254 |
+
"""
|
| 255 |
+
# Remove markdown formatting
|
| 256 |
+
text = re.sub(r"\*\*(.+?)\*\*", r"\1", text) # Bold
|
| 257 |
+
text = re.sub(r"\*(.+?)\*", r"\1", text) # Italic
|
| 258 |
+
text = re.sub(r"~~(.+?)~~", r"\1", text) # Strikethrough
|
| 259 |
+
text = re.sub(r"`(.+?)`", r"\1", text) # Inline code
|
| 260 |
+
text = re.sub(r"```[\s\S]*?```", "", text) # Code blocks
|
| 261 |
+
text = re.sub(r"^#{1,6}\s+", "", text, flags=re.MULTILINE) # Headers
|
| 262 |
+
|
| 263 |
+
# Remove links, keep text
|
| 264 |
+
text = re.sub(r"\[(.+?)\]\(.+?\)", r"\1", text)
|
| 265 |
+
|
| 266 |
+
# Remove emojis (basic pattern)
|
| 267 |
+
text = re.sub(
|
| 268 |
+
r"[\U0001F600-\U0001F64F\U0001F300-\U0001F5FF\U0001F680-\U0001F6FF\U0001F1E0-\U0001F1FF]",
|
| 269 |
+
"",
|
| 270 |
+
text,
|
| 271 |
+
)
|
| 272 |
+
|
| 273 |
+
# Normalize whitespace
|
| 274 |
+
text = " ".join(text.split())
|
| 275 |
+
|
| 276 |
+
return text.strip()
|
src/voice/__init__.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
DungeonMaster AI - Voice Package
|
| 3 |
+
|
| 4 |
+
ElevenLabs integration for voice synthesis and narration.
|
| 5 |
+
|
| 6 |
+
This module provides:
|
| 7 |
+
- VoiceClient: Production-ready ElevenLabs client with circuit breaker
|
| 8 |
+
- NarrationProcessor: Text preprocessing for optimal TTS
|
| 9 |
+
- Voice profiles: Pre-configured voices for DM, NPCs, and monsters
|
| 10 |
+
- Comprehensive error handling and graceful degradation
|
| 11 |
+
|
| 12 |
+
Example Usage:
|
| 13 |
+
```python
|
| 14 |
+
from src.voice import VoiceClient, NarrationProcessor, VoiceType
|
| 15 |
+
|
| 16 |
+
# Initialize client
|
| 17 |
+
client = VoiceClient()
|
| 18 |
+
await client.initialize()
|
| 19 |
+
|
| 20 |
+
# Process and synthesize
|
| 21 |
+
processor = NarrationProcessor()
|
| 22 |
+
processed = processor.process_for_tts("Roll a 1d20 for HP!")
|
| 23 |
+
|
| 24 |
+
if client.is_available:
|
| 25 |
+
audio = await client.synthesize(processed, VoiceType.DM)
|
| 26 |
+
```
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
# Exceptions
|
| 30 |
+
from src.voice.exceptions import (
|
| 31 |
+
VoiceAPIError,
|
| 32 |
+
VoiceAuthenticationError,
|
| 33 |
+
VoiceCircuitBreakerOpenError,
|
| 34 |
+
VoiceConfigurationError,
|
| 35 |
+
VoiceIntegrationError,
|
| 36 |
+
VoiceNotFoundError,
|
| 37 |
+
VoiceQuotaExhaustedError,
|
| 38 |
+
VoiceRateLimitError,
|
| 39 |
+
VoiceSynthesisError,
|
| 40 |
+
VoiceUnavailableError,
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
# Models
|
| 44 |
+
from src.voice.models import (
|
| 45 |
+
NarrationResult,
|
| 46 |
+
ProcessedNarration,
|
| 47 |
+
SynthesisRequest,
|
| 48 |
+
SynthesisResult,
|
| 49 |
+
TextSegment,
|
| 50 |
+
VoiceCircuitState,
|
| 51 |
+
VoiceModelType,
|
| 52 |
+
VoiceProfile,
|
| 53 |
+
VoiceServiceState,
|
| 54 |
+
VoiceServiceStatus,
|
| 55 |
+
VoiceSynthesisSettings,
|
| 56 |
+
VoiceType,
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
# Voice Profiles
|
| 60 |
+
from src.voice.voice_profiles import (
|
| 61 |
+
VOICE_PROFILES,
|
| 62 |
+
get_all_profiles,
|
| 63 |
+
get_voice_id,
|
| 64 |
+
get_voice_profile,
|
| 65 |
+
get_voice_profile_by_name,
|
| 66 |
+
get_voice_settings,
|
| 67 |
+
reset_profiles_cache,
|
| 68 |
+
select_voice_for_context,
|
| 69 |
+
select_voice_from_game_context,
|
| 70 |
+
select_voice_from_npc_data,
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
# Text Processing
|
| 74 |
+
from src.voice.text_processor import NarrationProcessor
|
| 75 |
+
|
| 76 |
+
# Voice Client
|
| 77 |
+
from src.voice.elevenlabs_client import (
|
| 78 |
+
AudioCache,
|
| 79 |
+
RateLimiter,
|
| 80 |
+
VoiceCircuitBreaker,
|
| 81 |
+
VoiceClient,
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
__all__ = [
|
| 85 |
+
# Exceptions
|
| 86 |
+
"VoiceIntegrationError",
|
| 87 |
+
"VoiceAPIError",
|
| 88 |
+
"VoiceRateLimitError",
|
| 89 |
+
"VoiceQuotaExhaustedError",
|
| 90 |
+
"VoiceAuthenticationError",
|
| 91 |
+
"VoiceUnavailableError",
|
| 92 |
+
"VoiceCircuitBreakerOpenError",
|
| 93 |
+
"VoiceNotFoundError",
|
| 94 |
+
"VoiceSynthesisError",
|
| 95 |
+
"VoiceConfigurationError",
|
| 96 |
+
# Models - Enums
|
| 97 |
+
"VoiceType",
|
| 98 |
+
"VoiceCircuitState",
|
| 99 |
+
"VoiceServiceState",
|
| 100 |
+
"VoiceModelType",
|
| 101 |
+
# Models - Settings
|
| 102 |
+
"VoiceSynthesisSettings",
|
| 103 |
+
"VoiceProfile",
|
| 104 |
+
# Models - Requests/Results
|
| 105 |
+
"SynthesisRequest",
|
| 106 |
+
"SynthesisResult",
|
| 107 |
+
"TextSegment",
|
| 108 |
+
"ProcessedNarration",
|
| 109 |
+
"NarrationResult",
|
| 110 |
+
# Models - Status
|
| 111 |
+
"VoiceServiceStatus",
|
| 112 |
+
# Voice Profiles
|
| 113 |
+
"VOICE_PROFILES",
|
| 114 |
+
"get_voice_profile",
|
| 115 |
+
"get_voice_profile_by_name",
|
| 116 |
+
"get_voice_id",
|
| 117 |
+
"get_voice_settings",
|
| 118 |
+
"get_all_profiles",
|
| 119 |
+
"reset_profiles_cache",
|
| 120 |
+
"select_voice_for_context",
|
| 121 |
+
"select_voice_from_npc_data",
|
| 122 |
+
"select_voice_from_game_context",
|
| 123 |
+
# Text Processing
|
| 124 |
+
"NarrationProcessor",
|
| 125 |
+
# Voice Client
|
| 126 |
+
"VoiceClient",
|
| 127 |
+
"VoiceCircuitBreaker",
|
| 128 |
+
"RateLimiter",
|
| 129 |
+
"AudioCache",
|
| 130 |
+
]
|