bhupesh-sf commited on
Commit
f8ba6bf
·
verified ·
1 Parent(s): e5f75d9

first commit

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .env +65 -0
  2. .env.example +65 -0
  3. .gitattributes +4 -0
  4. .gitignore +170 -0
  5. README.md +289 -6
  6. adventures/sample_characters/fighter_template.json +147 -0
  7. adventures/sample_characters/rogue_template.json +178 -0
  8. adventures/sample_characters/wizard_template.json +255 -0
  9. adventures/tavern_start.json +266 -0
  10. adventures/tutorial_goblin_cave.json +280 -0
  11. app.py +19 -5
  12. pyproject.toml +136 -0
  13. requirements.txt +527 -0
  14. src/__init__.py +8 -0
  15. src/agents/__init__.py +125 -0
  16. src/agents/dungeon_master.py +675 -0
  17. src/agents/exceptions.py +314 -0
  18. src/agents/llm_provider.py +437 -0
  19. src/agents/models.py +516 -0
  20. src/agents/orchestrator.py +911 -0
  21. src/agents/pacing_controller.py +271 -0
  22. src/agents/rules_arbiter.py +446 -0
  23. src/agents/special_moments.py +474 -0
  24. src/agents/voice_narrator.py +449 -0
  25. src/config/__init__.py +25 -0
  26. src/config/prompts/dm_combat.txt +46 -0
  27. src/config/prompts/dm_exploration.txt +56 -0
  28. src/config/prompts/dm_social.txt +69 -0
  29. src/config/prompts/dm_system.txt +88 -0
  30. src/config/prompts/narrator_system.txt +60 -0
  31. src/config/prompts/rules_system.txt +64 -0
  32. src/config/settings.py +289 -0
  33. src/game/__init__.py +86 -0
  34. src/game/adventure_loader.py +530 -0
  35. src/game/event_logger.py +724 -0
  36. src/game/game_state.py +367 -0
  37. src/game/game_state_manager.py +992 -0
  38. src/game/models.py +774 -0
  39. src/game/story_context.py +601 -0
  40. src/mcp_integration/__init__.py +86 -0
  41. src/mcp_integration/connection_manager.py +565 -0
  42. src/mcp_integration/exceptions.py +119 -0
  43. src/mcp_integration/fallbacks.py +360 -0
  44. src/mcp_integration/models.py +335 -0
  45. src/mcp_integration/tool_wrappers.py +1003 -0
  46. src/mcp_integration/toolkit_client.py +520 -0
  47. src/utils/__init__.py +53 -0
  48. src/utils/formatters.py +278 -0
  49. src/utils/validators.py +276 -0
  50. 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: gray
6
  sdk: gradio
7
- sdk_version: 6.0.1
8
  app_file: app.py
9
- pinned: false
 
 
 
 
 
 
 
 
 
10
  license: mit
11
- short_description: AI powered D&D game master
12
  ---
13
 
14
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ [![DungeonMaster AI Demo](https://img.shields.io/badge/Watch-Demo%20Video-red?style=for-the-badge&logo=youtube)](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
- import gradio as gr
 
2
 
3
- def greet(name):
4
- return "Hello " + name + "!!"
 
5
 
6
- demo = gr.Interface(fn=greet, inputs="text", outputs="text")
7
- demo.launch()
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ ]