official.ghost.logic commited on
Commit
71b378e
Β·
1 Parent(s): 6a9c45b

Deploy D&D Campaign Manager v2

Browse files

- Complete AI-powered D&D 5e character creator
- Autonomous campaign synthesis from party composition
- Session auto-generation with context from uploaded notes
- MCP server with 7 D&D 5e rules tools
- Campaign and session tracking
- Character export to formatted sheets
- Support for Claude, GPT-4, and Gemini

Features:
✨ Campaign synthesis (autonomous AI)
πŸ“– Session auto-generation
🎯 Encounter balancing (MCP tools)
πŸ§™ Character creation with AI backstories
πŸ“ Session notes parsing and context integration

Files changed (48) hide show
  1. .gitignore +42 -0
  2. DEPLOY_HF.md +403 -0
  3. LICENSE +21 -0
  4. README.md +102 -8
  5. app_v2.py +32 -0
  6. mcp_server/README.md +335 -0
  7. mcp_server/__init__.py +7 -0
  8. mcp_server/dnd_mcp_server.py +514 -0
  9. mcp_server/mcp_config.json +20 -0
  10. requirements.txt +37 -0
  11. setup_secrets.md +56 -0
  12. src/__init__.py +8 -0
  13. src/agents/__init__.py +9 -0
  14. src/agents/campaign_agent.py +990 -0
  15. src/agents/character_agent.py +854 -0
  16. src/config.py +157 -0
  17. src/models/__init__.py +23 -0
  18. src/models/campaign.py +275 -0
  19. src/models/character.py +285 -0
  20. src/models/game_objects.py +195 -0
  21. src/models/npc.py +246 -0
  22. src/models/session_notes.py +31 -0
  23. src/ui/__init__.py +29 -0
  24. src/ui/app.py +183 -0
  25. src/ui/character_creator_ui.py +1842 -0
  26. src/ui/components/__init__.py +7 -0
  27. src/ui/components/dropdown_manager.py +120 -0
  28. src/ui/tabs/__init__.py +29 -0
  29. src/ui/tabs/about_tab.py +52 -0
  30. src/ui/tabs/campaign_add_chars_tab.py +106 -0
  31. src/ui/tabs/campaign_create_tab.py +156 -0
  32. src/ui/tabs/campaign_manage_tab.py +155 -0
  33. src/ui/tabs/campaign_synthesize_tab.py +197 -0
  34. src/ui/tabs/character_create_tab.py +348 -0
  35. src/ui/tabs/character_export_tab.py +187 -0
  36. src/ui/tabs/character_load_tab.py +109 -0
  37. src/ui/tabs/character_manage_tab.py +112 -0
  38. src/ui/tabs/character_portrait_tab.py +156 -0
  39. src/ui/tabs/session_tracking_tab.py +599 -0
  40. src/ui/utils/__init__.py +5 -0
  41. src/utils/__init__.py +21 -0
  42. src/utils/ai_client.py +294 -0
  43. src/utils/character_sheet_exporter.py +567 -0
  44. src/utils/database.py +227 -0
  45. src/utils/dice.py +151 -0
  46. src/utils/file_parsers.py +125 -0
  47. src/utils/image_generator.py +590 -0
  48. src/utils/validators.py +130 -0
.gitignore ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ *.egg-info/
8
+ dist/
9
+ build/
10
+
11
+ # Virtual environments
12
+ venv/
13
+ env/
14
+ ENV/
15
+
16
+ # Database
17
+ *.db
18
+ *.sqlite
19
+ *.sqlite3
20
+
21
+ # IDE
22
+ .vscode/
23
+ .idea/
24
+ *.swp
25
+ *.swo
26
+
27
+ # OS
28
+ .DS_Store
29
+ Thumbs.db
30
+
31
+ # Logs
32
+ *.log
33
+
34
+ # Environment
35
+ .env
36
+ *.env
37
+ !.env.example
38
+
39
+ # Testing
40
+ .pytest_cache/
41
+ .coverage
42
+ htmlcov/
DEPLOY_HF.md ADDED
@@ -0,0 +1,403 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # πŸš€ Deploying to Hugging Face Spaces
2
+
3
+ Complete guide to deploying your D&D Campaign Manager to Hugging Face Spaces.
4
+
5
+ ## πŸ“‹ Pre-Deployment Checklist
6
+
7
+ Before deploying, ensure you have:
8
+
9
+ - [ ] Hugging Face account (free at https://huggingface.co)
10
+ - [ ] API keys for at least one AI provider:
11
+ - Anthropic (recommended): https://console.anthropic.com
12
+ - OpenAI: https://platform.openai.com
13
+ - Google AI: https://makersuite.google.com
14
+
15
+ ## 🎯 Quick Deploy (Recommended)
16
+
17
+ ### Option 1: Upload Files to New Space
18
+
19
+ 1. **Create a New Space**
20
+ - Go to https://huggingface.co/new-space
21
+ - Space name: `dnd-campaign-manager` (or your choice)
22
+ - SDK: **Gradio**
23
+ - Visibility: **Public** (or Private)
24
+ - Click **Create Space**
25
+
26
+ 2. **Upload Files**
27
+
28
+ Upload these files from your project:
29
+
30
+ **Required Files:**
31
+ ```
32
+ app_v2.py # Main application
33
+ README_HF.md # Space description (rename to README.md)
34
+ requirements_hf.txt # Dependencies (rename to requirements.txt)
35
+ .env.example # Environment variable template
36
+
37
+ src/ # Entire src directory
38
+ mcp_server/ # Entire mcp_server directory
39
+ data/ # Create empty folder for database
40
+ ```
41
+
42
+ **File Operations:**
43
+ - Rename `README_HF.md` β†’ `README.md`
44
+ - Rename `requirements_hf.txt` β†’ `requirements.txt`
45
+
46
+ 3. **Set Environment Variables (Secrets)**
47
+
48
+ In your Space settings β†’ **Variables and secrets**:
49
+
50
+ Add at least ONE of these:
51
+ ```
52
+ ANTHROPIC_API_KEY = sk-ant-...
53
+ OPENAI_API_KEY = sk-...
54
+ GOOGLE_API_KEY = AIza...
55
+ ```
56
+
57
+ Optional variables:
58
+ ```
59
+ HUGGINGFACE_API_KEY = hf_...
60
+ PRIMARY_MODEL_PROVIDER = anthropic
61
+ PRIMARY_MODEL = claude-3-5-sonnet-20241022
62
+ ```
63
+
64
+ 4. **Wait for Build**
65
+ - HF Spaces will automatically install dependencies
66
+ - Build takes ~3-5 minutes
67
+ - Your app will be live at: `https://huggingface.co/spaces/YOUR_USERNAME/dnd-campaign-manager`
68
+
69
+ ## πŸ”§ Option 2: Git Push (Advanced)
70
+
71
+ ### Step 1: Clone Your Space
72
+
73
+ ```bash
74
+ # Install git-lfs if you haven't
75
+ git lfs install
76
+
77
+ # Clone your space
78
+ git clone https://huggingface.co/spaces/YOUR_USERNAME/dnd-campaign-manager
79
+ cd dnd-campaign-manager
80
+ ```
81
+
82
+ ### Step 2: Copy Files
83
+
84
+ ```bash
85
+ # From your dungeon-smasher-pro directory:
86
+ cp app_v2.py path/to/dnd-campaign-manager/
87
+ cp README_HF.md path/to/dnd-campaign-manager/README.md
88
+ cp requirements_hf.txt path/to/dnd-campaign-manager/requirements.txt
89
+ cp .env.example path/to/dnd-campaign-manager/
90
+
91
+ # Copy directories
92
+ cp -r src path/to/dnd-campaign-manager/
93
+ cp -r mcp_server path/to/dnd-campaign-manager/
94
+ mkdir -p path/to/dnd-campaign-manager/data
95
+ ```
96
+
97
+ ### Step 3: Commit and Push
98
+
99
+ ```bash
100
+ cd path/to/dnd-campaign-manager
101
+
102
+ git add .
103
+ git commit -m "Initial deployment of D&D Campaign Manager"
104
+ git push
105
+ ```
106
+
107
+ ### Step 4: Configure Secrets
108
+
109
+ Go to your Space settings and add API keys as secrets (see Option 1, step 3).
110
+
111
+ ## πŸ“ Final Directory Structure on HF Space
112
+
113
+ ```
114
+ dnd-campaign-manager/ # Your HF Space root
115
+ β”œβ”€β”€ README.md # Renamed from README_HF.md
116
+ β”œβ”€β”€ requirements.txt # Renamed from requirements_hf.txt
117
+ β”œβ”€β”€ app_v2.py # Main app (HF will run this)
118
+ β”œβ”€β”€ .env.example # Environment variable template
119
+ β”‚
120
+ β”œβ”€β”€ src/ # Application source
121
+ β”‚ β”œβ”€β”€ agents/
122
+ β”‚ β”‚ β”œβ”€β”€ campaign_agent.py
123
+ β”‚ β”‚ └── character_agent.py
124
+ β”‚ β”œβ”€β”€ models/
125
+ β”‚ β”‚ β”œβ”€β”€ campaign.py
126
+ β”‚ β”‚ β”œβ”€β”€ character.py
127
+ β”‚ β”‚ └── session_notes.py
128
+ β”‚ β”œβ”€β”€ ui/
129
+ β”‚ β”‚ β”œβ”€β”€ app.py
130
+ β”‚ β”‚ β”œβ”€β”€ components/
131
+ β”‚ β”‚ └── tabs/
132
+ β”‚ └── utils/
133
+ β”‚ β”œβ”€β”€ ai_client.py
134
+ β”‚ └── file_parsers.py
135
+ β”‚
136
+ β”œβ”€β”€ mcp_server/ # MCP tools
137
+ β”‚ β”œβ”€β”€ dnd_mcp_server.py
138
+ β”‚ β”œβ”€β”€ mcp_config.json
139
+ β”‚ └── README.md
140
+ β”‚
141
+ └── data/ # Database (auto-created)
142
+ └── dnd_campaign_manager.db
143
+ ```
144
+
145
+ ## βš™οΈ Configuration Tips
146
+
147
+ ### Memory Management
148
+
149
+ HF Spaces free tier has 16GB RAM. If you encounter memory issues:
150
+
151
+ 1. **In your Space settings:**
152
+ - Hardware: Start with **CPU Basic** (free)
153
+ - Upgrade to **CPU Upgrade** or **T4 small** if needed
154
+
155
+ 2. **Optimize database:**
156
+ ```python
157
+ # Already implemented in campaign_agent.py
158
+ # Uses SQLite with limited result sets
159
+ ```
160
+
161
+ ### Persistent Storage
162
+
163
+ **Important:** HF Spaces free tier has ephemeral storage. Your database will reset on Space restart.
164
+
165
+ **Solutions:**
166
+
167
+ 1. **Use HF Datasets for persistence (recommended):**
168
+ ```python
169
+ # Add to requirements.txt:
170
+ # datasets>=2.0.0
171
+
172
+ # Save to HF Dataset periodically
173
+ from datasets import Dataset
174
+ # Implementation in src/utils/persistence.py
175
+ ```
176
+
177
+ 2. **Upgrade to Persistent Storage:**
178
+ - Go to Space Settings β†’ Storage
179
+ - Enable persistent storage ($5/month)
180
+
181
+ 3. **Accept ephemeral storage:**
182
+ - Good for demos/testing
183
+ - Users can export their data
184
+
185
+ ### API Rate Limits
186
+
187
+ To avoid hitting rate limits:
188
+
189
+ 1. **Set rate limit warnings** (already implemented in AI client)
190
+ 2. **Add caching** for repeated requests
191
+ 3. **Consider adding Redis** for session management
192
+
193
+ ## πŸ§ͺ Testing Your Deployment
194
+
195
+ Once deployed, test these features:
196
+
197
+ 1. **Character Creation**
198
+ - Create a character
199
+ - Generate backstory
200
+ - Export character sheet
201
+
202
+ 2. **Campaign Synthesis**
203
+ - Create 3-4 characters
204
+ - Synthesize campaign
205
+ - Verify campaign loads
206
+
207
+ 3. **Session Generation**
208
+ - Auto-generate a session
209
+ - Upload session notes
210
+ - Generate next session
211
+
212
+ 4. **MCP Tools** (if using MCP server)
213
+ - Character validation
214
+ - Encounter CR calculation
215
+
216
+ ## πŸ› Troubleshooting
217
+
218
+ ### Build Fails
219
+
220
+ **Error:** `Could not find a version that satisfies the requirement...`
221
+
222
+ **Fix:** Check `requirements.txt` version constraints:
223
+ ```txt
224
+ # Too strict (may fail):
225
+ gradio==5.0.0
226
+
227
+ # Better (flexible):
228
+ gradio>=5.0.0
229
+ ```
230
+
231
+ ### App Doesn't Start
232
+
233
+ **Check logs:**
234
+ 1. Go to your Space
235
+ 2. Click **Logs** tab
236
+ 3. Look for errors
237
+
238
+ **Common issues:**
239
+ - Missing API key β†’ Add in Secrets
240
+ - Import error β†’ Check all files uploaded
241
+ - Database path β†’ Ensure `data/` directory exists
242
+
243
+ ### Slow Performance
244
+
245
+ **Solutions:**
246
+ 1. Upgrade hardware tier
247
+ 2. Enable caching
248
+ 3. Use faster model (e.g., Gemini Flash)
249
+ 4. Reduce session history lookback
250
+
251
+ ### Database Resets
252
+
253
+ **This is normal on free tier.**
254
+
255
+ **Options:**
256
+ 1. Enable persistent storage ($5/month)
257
+ 2. Use HF Datasets for backup
258
+ 3. Allow users to export/import data
259
+
260
+ ## 🎨 Customizing Your Space
261
+
262
+ ### Update README Header
263
+
264
+ Edit `README.md` frontmatter:
265
+
266
+ ```yaml
267
+ ---
268
+ title: My D&D Campaign Manager
269
+ emoji: βš”οΈ
270
+ colorFrom: blue
271
+ colorTo: purple
272
+ sdk: gradio
273
+ sdk_version: 5.0.0
274
+ app_file: app_v2.py
275
+ pinned: true
276
+ license: mit
277
+ tags:
278
+ - dnd
279
+ - ttrpg
280
+ - ai
281
+ - campaign-manager
282
+ - mcp
283
+ ---
284
+ ```
285
+
286
+ ### Add Custom Domain (Pro)
287
+
288
+ HF Spaces Pro allows custom domains:
289
+ 1. Settings β†’ Custom Domain
290
+ 2. Point your DNS to HF
291
+ 3. Enable HTTPS
292
+
293
+ ### Enable Authentication
294
+
295
+ Restrict access to your Space:
296
+
297
+ ```python
298
+ # In app_v2.py
299
+ from src.ui import launch_ui
300
+
301
+ if __name__ == "__main__":
302
+ launch_ui(auth=("admin", "your_password"))
303
+ ```
304
+
305
+ Or use HF's built-in auth:
306
+ - Settings β†’ Access Control
307
+ - Set to "Private" or "Token-gated"
308
+
309
+ ## πŸ“Š Monitoring
310
+
311
+ ### View Analytics
312
+
313
+ HF Spaces provides:
314
+ - **App traffic** (visits, unique users)
315
+ - **Resource usage** (CPU, memory)
316
+ - **Build history**
317
+
318
+ Access: Space Settings β†’ Analytics
319
+
320
+ ### Add Custom Analytics
321
+
322
+ ```python
323
+ # Add to app.py
324
+ import gradio as gr
325
+
326
+ def track_event(event_name):
327
+ # Log to your analytics service
328
+ pass
329
+
330
+ # Add to Gradio components
331
+ gr.Button("Create Character").click(
332
+ fn=lambda: track_event("character_created"),
333
+ ...
334
+ )
335
+ ```
336
+
337
+ ## πŸš€ Going to Production
338
+
339
+ For serious production use:
340
+
341
+ 1. **Persistent Storage:** Enable in Space settings
342
+ 2. **Upgrade Hardware:** T4 Small GPU or better
343
+ 3. **Add Monitoring:** Sentry, LogRocket, etc.
344
+ 4. **Rate Limiting:** Implement per-user limits
345
+ 5. **Backups:** Daily exports to HF Dataset
346
+ 6. **Custom Domain:** For branding
347
+ 7. **Load Testing:** Test with multiple users
348
+
349
+ ## πŸ’° Cost Estimates
350
+
351
+ **Free Tier:**
352
+ - Hosting: Free
353
+ - Storage: Ephemeral (resets on restart)
354
+ - Hardware: CPU Basic
355
+ - **Good for:** Demos, testing, low traffic
356
+
357
+ **Paid Tiers:**
358
+ - **Persistent Storage:** $5/month
359
+ - **CPU Upgrade:** $0/hour (included)
360
+ - **T4 Small (GPU):** $0.60/hour
361
+ - **A10G Small:** $1.50/hour
362
+
363
+ **API Costs (separate):**
364
+ - Anthropic Claude: ~$3-15 per 1M tokens
365
+ - OpenAI GPT-4: ~$10-30 per 1M tokens
366
+ - Google Gemini: Free tier available
367
+
368
+ ## πŸ“ Checklist: Ready to Deploy?
369
+
370
+ - [ ] All files ready (app_v2.py, src/, mcp_server/)
371
+ - [ ] requirements_hf.txt renamed to requirements.txt
372
+ - [ ] README_HF.md renamed to README.md
373
+ - [ ] .env.example included (for local dev reference)
374
+ - [ ] API keys ready to add as Secrets
375
+ - [ ] HF Space created
376
+ - [ ] Files uploaded or pushed
377
+ - [ ] Secrets configured
378
+ - [ ] Build succeeded
379
+ - [ ] App tested
380
+
381
+ ## πŸŽ‰ You're Live!
382
+
383
+ Once deployed, share your Space:
384
+
385
+ ```
386
+ 🎲 Check out my D&D Campaign Manager!
387
+ https://huggingface.co/spaces/YOUR_USERNAME/dnd-campaign-manager
388
+
389
+ Features:
390
+ ✨ AI-powered character creation
391
+ 🎯 Autonomous campaign synthesis
392
+ πŸ“– Auto-generated session plans
393
+ βš”οΈ MCP-powered encounter balancing
394
+ ```
395
+
396
+ ---
397
+
398
+ **Need help?**
399
+ - HF Spaces Docs: https://huggingface.co/docs/hub/spaces
400
+ - Gradio Docs: https://gradio.app/docs/
401
+ - Open an issue on GitHub
402
+
403
+ **Happy deploying!** πŸš€
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Dungeon Smasher Pro
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md CHANGED
@@ -1,14 +1,108 @@
1
  ---
2
- title: DnD Campaign Manager
3
- emoji: πŸ“š
4
- colorFrom: gray
5
- colorTo: indigo
6
  sdk: gradio
7
- sdk_version: 6.0.1
8
  app_file: app.py
9
  pinned: false
10
- license: apache-2.0
11
- short_description: Create characters, images and custom campaigns
12
  ---
13
 
14
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: D'n'D Campaign Manager
3
+ emoji: 🎲
4
+ colorFrom: purple
5
+ colorTo: red
6
  sdk: gradio
7
+ sdk_version: 5.38.2
8
  app_file: app.py
9
  pinned: false
10
+ license: mit
 
11
  ---
12
 
13
+ # 🎲 D'n'D Campaign Manager
14
+
15
+ A complete AI-powered D&D 5e character creator and campaign management system built with Gradio.
16
+
17
+ ## Features
18
+
19
+ ### Character Creation
20
+ - **AI-Powered Generation**: Create fully-detailed D&D 5e characters with backstories, personality traits, and equipment
21
+ - **Race & Class Support**: All standard D&D 5e races and classes
22
+ - **Background System**: Rich backgrounds with personality traits, ideals, bonds, and flaws
23
+ - **Portrait Generation**: AI-generated character portraits using DALL-E 3 or HuggingFace
24
+ - **Character Export**: Export to Markdown, JSON, or HTML character sheets
25
+
26
+ ### Campaign Management
27
+ - **Campaign Synthesis**: AI creates complete campaigns tailored to your party composition
28
+ - **Session Tracking**: Track campaign sessions and important events
29
+ - **Character Integration**: Automatically weaves character backstories into campaign narrative
30
+ - **Campaign Notes**: Detailed NPCs, locations, adventure hooks, and session outlines
31
+
32
+ ### What Makes This Special
33
+
34
+ The **Campaign Synthesis** feature analyzes your entire party (backstories, alignments, classes, personalities) and generates:
35
+ - Custom adventure hooks that tie into each character's background
36
+ - NPCs connected to party members
37
+ - Locations relevant to the party's story
38
+ - Session-by-session progression outlines
39
+ - Character connections explaining how the party knows each other
40
+ - Villains with personal stakes for each PC
41
+
42
+ ## Configuration
43
+
44
+ This app requires API keys for AI features. Set them as Hugging Face Space secrets:
45
+
46
+ ### Required (choose one):
47
+ - `OPENAI_API_KEY` - For OpenAI GPT-4 (recommended for best results)
48
+ - `ANTHROPIC_API_KEY` - For Claude models
49
+
50
+ ### Optional:
51
+ - `HUGGINGFACE_TOKEN` - For HuggingFace image generation (fallback)
52
+ - `GEMINI_API_KEY` - For Google Gemini models (optional alternative)
53
+
54
+ ## How to Use
55
+
56
+ 1. **Create Characters**: Go to "Create Character" tab and fill in the basic details
57
+ 2. **Select Party**: In "Campaign Management" β†’ "Synthesize from Characters", select multiple characters
58
+ 3. **Generate Campaign**: Click "Synthesize Campaign" to create a custom campaign
59
+ 4. **Track Progress**: Use "Session Tracking" to record events and progress
60
+
61
+ ## Local Development
62
+
63
+ ```bash
64
+ # Clone the repository
65
+ git clone <your-repo-url>
66
+ cd dungeon-smasher-pro
67
+
68
+ # Install dependencies
69
+ pip install -r requirements.txt
70
+
71
+ # Set up environment variables
72
+ cp .env.example .env
73
+ # Edit .env with your API keys
74
+
75
+ # Run the app
76
+ python3 app_v2.py
77
+ ```
78
+
79
+ ## Tech Stack
80
+
81
+ - **Gradio 5.38.2** - Web UI framework
82
+ - **OpenAI GPT-4** - Character and campaign generation
83
+ - **DALL-E 3** - Portrait generation
84
+ - **SQLite** - Character and campaign storage
85
+ - **Pydantic** - Data validation
86
+
87
+ ## Architecture
88
+
89
+ ```
90
+ src/
91
+ β”œβ”€β”€ agents/ # Business logic
92
+ β”‚ β”œβ”€β”€ character_agent.py
93
+ β”‚ └── campaign_agent.py
94
+ β”œβ”€β”€ models/ # Pydantic data models
95
+ β”‚ β”œβ”€β”€ character.py
96
+ β”‚ └── campaign.py
97
+ β”œβ”€β”€ ui/ # Gradio interface
98
+ β”‚ └── character_creator_ui.py
99
+ └── utils/ # AI client utilities
100
+ └── ai_client.py
101
+ ```
102
+
103
+ ## Credits
104
+
105
+ Built with ❀️ for D&D players and Dungeon Masters everywhere.
106
+
107
+ ## License
108
+
app_v2.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ D'n'D Campaign Manager - Main Application
4
+ Launch the Gradio UI for character creation
5
+ """
6
+
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ # Add src to path
11
+ sys.path.insert(0, str(Path(__file__).parent))
12
+
13
+ from src.ui import launch_ui
14
+
15
+ if __name__ == "__main__":
16
+ print("""
17
+ ╔═══════════════════════════════════════════════╗
18
+ β•‘ 🎲 D'n'D Campaign Manager β•‘
19
+ β•‘ Complete D&D Character Creator β•‘
20
+ β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•
21
+
22
+ Starting Gradio interface...
23
+ """)
24
+
25
+ try:
26
+ launch_ui()
27
+ except KeyboardInterrupt:
28
+ print("\n\nπŸ‘‹ Shutting down gracefully...")
29
+ except Exception as e:
30
+ print(f"\n❌ Error: {e}")
31
+ import traceback
32
+ traceback.print_exc()
mcp_server/README.md ADDED
@@ -0,0 +1,335 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # D&D Campaign Manager MCP Server
2
+
3
+ A Model Context Protocol (MCP) server providing D&D 5e tools for AI agents and LLMs.
4
+
5
+ ## 🎲 What This MCP Server Provides
6
+
7
+ This MCP server extends LLM capabilities with specialized D&D 5e tools for:
8
+ - Character validation and rules compliance
9
+ - Encounter difficulty calculation
10
+ - XP award computation
11
+ - Multiclass requirement checking
12
+ - NPC stat block generation
13
+ - Ability modifier calculations
14
+
15
+ ## πŸš€ Quick Start
16
+
17
+ ### Installation
18
+
19
+ ```bash
20
+ # Install FastMCP
21
+ pip install fastmcp
22
+
23
+ # Run the MCP server
24
+ python3 mcp_server/dnd_mcp_server.py
25
+ ```
26
+
27
+ ### Usage with Claude Desktop
28
+
29
+ Add to your Claude Desktop config (`~/Library/Application Support/Claude/claude_desktop_config.json`):
30
+
31
+ ```json
32
+ {
33
+ "mcpServers": {
34
+ "dnd-campaign-manager": {
35
+ "command": "python3",
36
+ "args": [
37
+ "/path/to/dungeon-smasher-pro/mcp_server/dnd_mcp_server.py"
38
+ ]
39
+ }
40
+ }
41
+ }
42
+ ```
43
+
44
+ ## πŸ“š Available Tools
45
+
46
+ ### `validate_character`
47
+ Validates D&D 5e character builds for rules compliance.
48
+
49
+ **Parameters:**
50
+ - `character_class` (string): Character class (e.g., "fighter")
51
+ - `level` (int): Character level (1-20)
52
+ - `ability_scores` (dict): Ability scores (e.g., `{"strength": 16, "dexterity": 14, ...}`)
53
+ - `multiclass` (list, optional): List of multiclass names
54
+
55
+ **Example:**
56
+ ```python
57
+ validate_character(
58
+ character_class="fighter",
59
+ level=5,
60
+ ability_scores={"strength": 16, "dexterity": 14, "constitution": 15, "intelligence": 10, "wisdom": 12, "charisma": 8}
61
+ )
62
+ ```
63
+
64
+ **Returns:**
65
+ ```json
66
+ {
67
+ "valid": true,
68
+ "errors": [],
69
+ "warnings": ["Low charisma (8) for fighter - recommend 13+"],
70
+ "hp_estimate": 49,
71
+ "hit_die": "d10",
72
+ "constitution_modifier": 2
73
+ }
74
+ ```
75
+
76
+ ### `calculate_encounter_cr`
77
+ Calculates encounter difficulty using D&D 5e rules.
78
+
79
+ **Parameters:**
80
+ - `party_levels` (list): Party member levels (e.g., `[5, 5, 4, 6]`)
81
+ - `monster_crs` (list): Monster challenge ratings (e.g., `[2, 2, 0.5]`)
82
+ - `difficulty_target` (string): Target difficulty ("easy", "medium", "hard", "deadly")
83
+
84
+ **Example:**
85
+ ```python
86
+ calculate_encounter_cr(
87
+ party_levels=[5, 5, 4, 6],
88
+ monster_crs=[2, 2, 0.5],
89
+ difficulty_target="medium"
90
+ )
91
+ ```
92
+
93
+ **Returns:**
94
+ ```json
95
+ {
96
+ "difficulty": "hard",
97
+ "adjusted_xp": 2100,
98
+ "raw_xp": 1050,
99
+ "multiplier": 2.0,
100
+ "thresholds": {"easy": 1000, "medium": 2000, "hard": 3000, "deadly": 4400},
101
+ "target_met": false,
102
+ "recommendations": [],
103
+ "party_size": 4,
104
+ "monster_count": 3
105
+ }
106
+ ```
107
+
108
+ ### `calculate_xp_award`
109
+ Calculates XP rewards for defeated monsters.
110
+
111
+ **Parameters:**
112
+ - `party_size` (int): Number of players
113
+ - `monster_crs` (list): CRs of defeated monsters
114
+
115
+ **Example:**
116
+ ```python
117
+ calculate_xp_award(party_size=4, monster_crs=[2, 2, 0.5])
118
+ ```
119
+
120
+ **Returns:**
121
+ ```json
122
+ {
123
+ "total_xp": 1150,
124
+ "xp_per_player": 287,
125
+ "party_size": 4,
126
+ "monsters_defeated": 3
127
+ }
128
+ ```
129
+
130
+ ### `validate_multiclass`
131
+ Checks multiclass requirements.
132
+
133
+ **Parameters:**
134
+ - `current_class` (string): Current primary class
135
+ - `target_class` (string): Class to multiclass into
136
+ - `ability_scores` (dict): Character ability scores
137
+
138
+ **Example:**
139
+ ```python
140
+ validate_multiclass(
141
+ current_class="fighter",
142
+ target_class="wizard",
143
+ ability_scores={"intelligence": 14, "strength": 16, ...}
144
+ )
145
+ ```
146
+
147
+ **Returns:**
148
+ ```json
149
+ {
150
+ "valid": true,
151
+ "requirements_met": true,
152
+ "required": {"intelligence": 13},
153
+ "missing": [],
154
+ "can_multiclass": true
155
+ }
156
+ ```
157
+
158
+ ### `generate_npc_stats`
159
+ Generates balanced NPC stat blocks.
160
+
161
+ **Parameters:**
162
+ - `npc_name` (string): NPC name
163
+ - `character_class` (string): NPC class
164
+ - `level` (int): NPC level
165
+ - `role` (string): Combat role ("tank", "damage", "support", "standard")
166
+
167
+ **Example:**
168
+ ```python
169
+ generate_npc_stats(
170
+ npc_name="Guard Captain",
171
+ character_class="fighter",
172
+ level=5,
173
+ role="tank"
174
+ )
175
+ ```
176
+
177
+ **Returns:**
178
+ ```json
179
+ {
180
+ "name": "Guard Captain",
181
+ "class": "fighter",
182
+ "level": 5,
183
+ "role": "tank",
184
+ "hp": 49,
185
+ "hp_formula": "5d10 + 10",
186
+ "ac": 18,
187
+ "ability_scores": {"strength": 16, "dexterity": 12, "constitution": 16, ...},
188
+ "proficiency_bonus": 3,
189
+ "primary_ability": "strength",
190
+ "hit_die": "d10"
191
+ }
192
+ ```
193
+
194
+ ### `get_ability_modifier`
195
+ Calculates ability modifier from ability score.
196
+
197
+ **Parameters:**
198
+ - `ability_score` (int): Ability score (1-30)
199
+
200
+ **Example:**
201
+ ```python
202
+ get_ability_modifier(16)
203
+ ```
204
+
205
+ **Returns:** `3`
206
+
207
+ ### `get_cr_for_solo_monster`
208
+ Recommends CR for solo monster encounters.
209
+
210
+ **Parameters:**
211
+ - `party_level` (int): Average party level
212
+ - `party_size` (int): Number of party members
213
+ - `difficulty` (string): Desired difficulty
214
+
215
+ **Example:**
216
+ ```python
217
+ get_cr_for_solo_monster(party_level=5, party_size=4, difficulty="hard")
218
+ ```
219
+
220
+ **Returns:**
221
+ ```json
222
+ {
223
+ "recommended_cr": 7.0,
224
+ "target_xp": 3000,
225
+ "actual_xp": 2900,
226
+ "party_level": 5,
227
+ "party_size": 4,
228
+ "difficulty": "hard"
229
+ }
230
+ ```
231
+
232
+ ## πŸ—οΈ Architecture
233
+
234
+ ```
235
+ mcp_server/
236
+ β”œβ”€β”€ __init__.py # Package initialization
237
+ β”œβ”€β”€ dnd_mcp_server.py # Main MCP server with tools
238
+ β”œβ”€β”€ mcp_config.json # MCP configuration
239
+ └── README.md # Documentation
240
+ ```
241
+
242
+ ## 🎯 Use Cases
243
+
244
+ ### For DMs
245
+ - Validate player character builds during session 0
246
+ - Balance encounters on-the-fly
247
+ - Generate quick NPC stats for improvised encounters
248
+ - Calculate XP rewards accurately
249
+
250
+ ### For AI Agents
251
+ - Autonomous character creation with rules compliance
252
+ - Intelligent encounter design
253
+ - Campaign balance analysis
254
+ - NPC generation for story development
255
+
256
+ ### For Developers
257
+ - Integrate D&D rules into LLM applications
258
+ - Build D&D assistants and tools
259
+ - Create automated campaign managers
260
+ - Develop character builders with validation
261
+
262
+ ## πŸ“Š Example Integration
263
+
264
+ ```python
265
+ # Example: AI agent using MCP tools to balance an encounter
266
+
267
+ # 1. Validate party
268
+ for character in party:
269
+ validation = validate_character(
270
+ character_class=character.class,
271
+ level=character.level,
272
+ ability_scores=character.abilities
273
+ )
274
+ if not validation["valid"]:
275
+ print(f"Error: {validation['errors']}")
276
+
277
+ # 2. Design encounter
278
+ party_levels = [char.level for char in party]
279
+ encounter = calculate_encounter_cr(
280
+ party_levels=party_levels,
281
+ monster_crs=[2, 2, 1, 0.5],
282
+ difficulty_target="medium"
283
+ )
284
+
285
+ # 3. Adjust if needed
286
+ if encounter["difficulty"] != "medium":
287
+ print(f"Encounter is {encounter['difficulty']}, adjusting...")
288
+ # AI can now reason about how to adjust
289
+
290
+ # 4. Calculate rewards
291
+ xp = calculate_xp_award(
292
+ party_size=len(party),
293
+ monster_crs=[2, 2, 1, 0.5]
294
+ )
295
+ print(f"Party earns {xp['xp_per_player']} XP each")
296
+ ```
297
+
298
+ ## πŸ”§ Development
299
+
300
+ ### Adding New Tools
301
+
302
+ 1. Add tool function with `@mcp.tool()` decorator
303
+ 2. Document parameters and return values
304
+ 3. Add to tool list in `__main__`
305
+ 4. Update this README
306
+
307
+ ### Testing
308
+
309
+ ```bash
310
+ # Test individual tools
311
+ python3 -c "from mcp_server.dnd_mcp_server import *; print(validate_character('fighter', 5, {'strength': 16, 'dexterity': 14, 'constitution': 15, 'intelligence': 10, 'wisdom': 12, 'charisma': 8}))"
312
+ ```
313
+
314
+ ## πŸ“ License
315
+
316
+ MIT License - See main project LICENSE
317
+
318
+ ## 🀝 Contributing
319
+
320
+ Contributions welcome! Please:
321
+ 1. Follow D&D 5e SRD rules
322
+ 2. Add tests for new tools
323
+ 3. Update documentation
324
+ 4. Ensure backward compatibility
325
+
326
+ ## 🎲 D&D 5e SRD Compliance
327
+
328
+ This MCP server implements rules from the D&D 5e System Reference Document (SRD).
329
+ All content is from open game content under the OGL 1.0a.
330
+
331
+ ## πŸ”— Links
332
+
333
+ - [FastMCP Documentation](https://github.com/anthropics/anthropic-tools)
334
+ - [Model Context Protocol](https://modelcontextprotocol.io)
335
+ - [D&D 5e SRD](https://dnd.wizards.com/resources/systems-reference-document)
mcp_server/__init__.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ """
2
+ D&D Campaign Manager MCP Server
3
+
4
+ Provides Model Context Protocol tools for D&D 5e campaign management.
5
+ """
6
+
7
+ __version__ = "1.0.0"
mcp_server/dnd_mcp_server.py ADDED
@@ -0,0 +1,514 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ D&D Campaign Manager MCP Server
4
+
5
+ Provides tools for D&D 5e rules, character validation, encounter design,
6
+ and campaign management through the Model Context Protocol.
7
+
8
+ Tools:
9
+ - validate_character: Validate D&D 5e character builds
10
+ - calculate_encounter_cr: Calculate encounter difficulty
11
+ - lookup_spell: Get spell details and rules
12
+ - lookup_monster: Get monster stat blocks
13
+ - generate_npc: Create balanced NPC stat blocks
14
+ - calculate_xp: Calculate XP awards for encounters
15
+ - validate_multiclass: Check multiclass requirements
16
+ - get_ability_modifier: Calculate ability modifiers
17
+ """
18
+
19
+ import json
20
+ import sys
21
+ from typing import Any, Dict, List, Optional
22
+ from pathlib import Path
23
+
24
+ # Add parent directory to path for imports
25
+ sys.path.insert(0, str(Path(__file__).parent.parent))
26
+
27
+ try:
28
+ from mcp.server.fastmcp import FastMCP
29
+ except ImportError:
30
+ print("Installing FastMCP...")
31
+ import subprocess
32
+ subprocess.check_call([sys.executable, "-m", "pip", "install", "fastmcp"])
33
+ from mcp.server.fastmcp import FastMCP
34
+
35
+ # Initialize MCP server
36
+ mcp = FastMCP("dnd-campaign-manager")
37
+
38
+
39
+ # ============================================================================
40
+ # D&D 5E RULES DATA
41
+ # ============================================================================
42
+
43
+ ABILITY_SCORES = ["strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma"]
44
+
45
+ CLASSES = {
46
+ "barbarian": {"hit_die": 12, "primary_ability": ["strength"]},
47
+ "bard": {"hit_die": 8, "primary_ability": ["charisma"]},
48
+ "cleric": {"hit_die": 8, "primary_ability": ["wisdom"]},
49
+ "druid": {"hit_die": 8, "primary_ability": ["wisdom"]},
50
+ "fighter": {"hit_die": 10, "primary_ability": ["strength", "dexterity"]},
51
+ "monk": {"hit_die": 8, "primary_ability": ["dexterity", "wisdom"]},
52
+ "paladin": {"hit_die": 10, "primary_ability": ["strength", "charisma"]},
53
+ "ranger": {"hit_die": 10, "primary_ability": ["dexterity", "wisdom"]},
54
+ "rogue": {"hit_die": 8, "primary_ability": ["dexterity"]},
55
+ "sorcerer": {"hit_die": 6, "primary_ability": ["charisma"]},
56
+ "warlock": {"hit_die": 8, "primary_ability": ["charisma"]},
57
+ "wizard": {"hit_die": 6, "primary_ability": ["intelligence"]}
58
+ }
59
+
60
+ MULTICLASS_REQUIREMENTS = {
61
+ "barbarian": {"strength": 13},
62
+ "bard": {"charisma": 13},
63
+ "cleric": {"wisdom": 13},
64
+ "druid": {"wisdom": 13},
65
+ "fighter": {"strength": 13, "dexterity": 13}, # OR condition
66
+ "monk": {"dexterity": 13, "wisdom": 13},
67
+ "paladin": {"strength": 13, "charisma": 13},
68
+ "ranger": {"dexterity": 13, "wisdom": 13},
69
+ "rogue": {"dexterity": 13},
70
+ "sorcerer": {"charisma": 13},
71
+ "warlock": {"charisma": 13},
72
+ "wizard": {"intelligence": 13}
73
+ }
74
+
75
+ XP_THRESHOLDS = {
76
+ 1: {"easy": 25, "medium": 50, "hard": 75, "deadly": 100},
77
+ 2: {"easy": 50, "medium": 100, "hard": 150, "deadly": 200},
78
+ 3: {"easy": 75, "medium": 150, "hard": 225, "deadly": 400},
79
+ 4: {"easy": 125, "medium": 250, "hard": 375, "deadly": 500},
80
+ 5: {"easy": 250, "medium": 500, "hard": 750, "deadly": 1100},
81
+ 6: {"easy": 300, "medium": 600, "hard": 900, "deadly": 1400},
82
+ 7: {"easy": 350, "medium": 750, "hard": 1100, "deadly": 1700},
83
+ 8: {"easy": 450, "medium": 900, "hard": 1400, "deadly": 2100},
84
+ 9: {"easy": 550, "medium": 1100, "hard": 1600, "deadly": 2400},
85
+ 10: {"easy": 600, "medium": 1200, "hard": 1900, "deadly": 2800},
86
+ 11: {"easy": 800, "medium": 1600, "hard": 2400, "deadly": 3600},
87
+ 12: {"easy": 1000, "medium": 2000, "hard": 3000, "deadly": 4500},
88
+ 13: {"easy": 1100, "medium": 2200, "hard": 3400, "deadly": 5100},
89
+ 14: {"easy": 1250, "medium": 2500, "hard": 3800, "deadly": 5700},
90
+ 15: {"easy": 1400, "medium": 2800, "hard": 4300, "deadly": 6400},
91
+ 16: {"easy": 1600, "medium": 3200, "hard": 4800, "deadly": 7200},
92
+ 17: {"easy": 2000, "medium": 3900, "hard": 5900, "deadly": 8800},
93
+ 18: {"easy": 2100, "medium": 4200, "hard": 6300, "deadly": 9500},
94
+ 19: {"easy": 2400, "medium": 4900, "hard": 7300, "deadly": 10900},
95
+ 20: {"easy": 2800, "medium": 5700, "hard": 8500, "deadly": 12700}
96
+ }
97
+
98
+ CR_TO_XP = {
99
+ 0: 10, 0.125: 25, 0.25: 50, 0.5: 100,
100
+ 1: 200, 2: 450, 3: 700, 4: 1100, 5: 1800,
101
+ 6: 2300, 7: 2900, 8: 3900, 9: 5000, 10: 5900,
102
+ 11: 7200, 12: 8400, 13: 10000, 14: 11500, 15: 13000,
103
+ 16: 15000, 17: 18000, 18: 20000, 19: 22000, 20: 25000,
104
+ 21: 33000, 22: 41000, 23: 50000, 24: 62000, 25: 75000,
105
+ 26: 90000, 27: 105000, 28: 120000, 29: 135000, 30: 155000
106
+ }
107
+
108
+ ENCOUNTER_MULTIPLIERS = {
109
+ 1: 1.0,
110
+ 2: 1.5,
111
+ (3, 6): 2.0,
112
+ (7, 10): 2.5,
113
+ (11, 14): 3.0,
114
+ 15: 4.0
115
+ }
116
+
117
+
118
+ # ============================================================================
119
+ # MCP TOOLS
120
+ # ============================================================================
121
+
122
+ @mcp.tool()
123
+ def get_ability_modifier(ability_score: int) -> int:
124
+ """
125
+ Calculate ability modifier from ability score.
126
+
127
+ Args:
128
+ ability_score: The ability score (1-30)
129
+
130
+ Returns:
131
+ The ability modifier
132
+
133
+ Example:
134
+ >>> get_ability_modifier(16)
135
+ 3
136
+ """
137
+ return (ability_score - 10) // 2
138
+
139
+
140
+ @mcp.tool()
141
+ def validate_character(
142
+ character_class: str,
143
+ level: int,
144
+ ability_scores: Dict[str, int],
145
+ multiclass: Optional[List[str]] = None
146
+ ) -> Dict[str, Any]:
147
+ """
148
+ Validate a D&D 5e character build for rules compliance.
149
+
150
+ Args:
151
+ character_class: Primary class name (e.g., "fighter")
152
+ level: Character level (1-20)
153
+ ability_scores: Dict of ability scores (e.g., {"strength": 16, "dexterity": 14, ...})
154
+ multiclass: Optional list of multiclass names
155
+
156
+ Returns:
157
+ Validation result with errors and warnings
158
+
159
+ Example:
160
+ >>> validate_character("fighter", 5, {"strength": 16, "dexterity": 14, "constitution": 15, "intelligence": 10, "wisdom": 12, "charisma": 8})
161
+ {"valid": True, "errors": [], "warnings": [], "hp_estimate": 49}
162
+ """
163
+ errors = []
164
+ warnings = []
165
+
166
+ # Validate class
167
+ character_class = character_class.lower()
168
+ if character_class not in CLASSES:
169
+ errors.append(f"Invalid class: {character_class}")
170
+ return {"valid": False, "errors": errors, "warnings": warnings}
171
+
172
+ # Validate level
173
+ if level < 1 or level > 20:
174
+ errors.append(f"Level must be between 1 and 20, got {level}")
175
+
176
+ # Validate ability scores
177
+ for ability in ABILITY_SCORES:
178
+ if ability not in ability_scores:
179
+ errors.append(f"Missing ability score: {ability}")
180
+ elif ability_scores[ability] < 1 or ability_scores[ability] > 20:
181
+ warnings.append(f"{ability.title()} score {ability_scores[ability]} is unusual (normally 1-20)")
182
+
183
+ # Check primary ability scores
184
+ class_info = CLASSES[character_class]
185
+ for primary in class_info["primary_ability"]:
186
+ if primary in ability_scores and ability_scores[primary] < 13:
187
+ warnings.append(f"Low {primary} ({ability_scores[primary]}) for {character_class} - recommend 13+")
188
+
189
+ # Validate multiclassing
190
+ if multiclass:
191
+ for mc_class in multiclass:
192
+ mc_class = mc_class.lower()
193
+ if mc_class not in CLASSES:
194
+ errors.append(f"Invalid multiclass: {mc_class}")
195
+ continue
196
+
197
+ requirements = MULTICLASS_REQUIREMENTS[mc_class]
198
+ for ability, min_score in requirements.items():
199
+ if ability_scores.get(ability, 0) < min_score:
200
+ errors.append(
201
+ f"Multiclass {mc_class} requires {ability} {min_score}, "
202
+ f"but character has {ability_scores.get(ability, 0)}"
203
+ )
204
+
205
+ # Calculate estimated HP
206
+ hit_die = class_info["hit_die"]
207
+ con_mod = get_ability_modifier(ability_scores.get("constitution", 10))
208
+ hp_estimate = hit_die + (level - 1) * (hit_die // 2 + 1) + (level * con_mod)
209
+
210
+ return {
211
+ "valid": len(errors) == 0,
212
+ "errors": errors,
213
+ "warnings": warnings,
214
+ "hp_estimate": hp_estimate,
215
+ "hit_die": f"d{hit_die}",
216
+ "constitution_modifier": con_mod
217
+ }
218
+
219
+
220
+ @mcp.tool()
221
+ def calculate_encounter_cr(
222
+ party_levels: List[int],
223
+ monster_crs: List[float],
224
+ difficulty_target: str = "medium"
225
+ ) -> Dict[str, Any]:
226
+ """
227
+ Calculate encounter difficulty for D&D 5e.
228
+
229
+ Args:
230
+ party_levels: List of party member levels (e.g., [5, 5, 4, 6])
231
+ monster_crs: List of monster CRs (e.g., [2, 2, 0.5])
232
+ difficulty_target: Target difficulty ("easy", "medium", "hard", "deadly")
233
+
234
+ Returns:
235
+ Encounter analysis with difficulty rating and recommendations
236
+
237
+ Example:
238
+ >>> calculate_encounter_cr([5, 5, 4, 6], [2, 2, 0.5], "medium")
239
+ {"difficulty": "hard", "adjusted_xp": 2100, "threshold": {"easy": 1000, "medium": 2000, "hard": 3000, "deadly": 4400}}
240
+ """
241
+ # Calculate party XP thresholds
242
+ party_thresholds = {"easy": 0, "medium": 0, "hard": 0, "deadly": 0}
243
+ for level in party_levels:
244
+ level = min(max(level, 1), 20) # Clamp to 1-20
245
+ for difficulty in ["easy", "medium", "hard", "deadly"]:
246
+ party_thresholds[difficulty] += XP_THRESHOLDS[level][difficulty]
247
+
248
+ # Calculate monster XP
249
+ total_monster_xp = sum(CR_TO_XP.get(cr, 0) for cr in monster_crs)
250
+
251
+ # Apply encounter multiplier based on number of monsters
252
+ num_monsters = len(monster_crs)
253
+ multiplier = 1.0
254
+ if num_monsters == 1:
255
+ multiplier = 1.0
256
+ elif num_monsters == 2:
257
+ multiplier = 1.5
258
+ elif 3 <= num_monsters <= 6:
259
+ multiplier = 2.0
260
+ elif 7 <= num_monsters <= 10:
261
+ multiplier = 2.5
262
+ elif 11 <= num_monsters <= 14:
263
+ multiplier = 3.0
264
+ else:
265
+ multiplier = 4.0
266
+
267
+ adjusted_xp = int(total_monster_xp * multiplier)
268
+
269
+ # Determine difficulty
270
+ if adjusted_xp < party_thresholds["easy"]:
271
+ difficulty = "trivial"
272
+ elif adjusted_xp < party_thresholds["medium"]:
273
+ difficulty = "easy"
274
+ elif adjusted_xp < party_thresholds["hard"]:
275
+ difficulty = "medium"
276
+ elif adjusted_xp < party_thresholds["deadly"]:
277
+ difficulty = "hard"
278
+ else:
279
+ difficulty = "deadly"
280
+
281
+ # Calculate recommendations
282
+ recommendations = []
283
+ if difficulty == "trivial":
284
+ recommendations.append("This encounter is too easy. Consider adding more monsters or increasing CR.")
285
+ elif difficulty == "deadly":
286
+ recommendations.append("This encounter is deadly! Ensure party has resources and escape options.")
287
+
288
+ if num_monsters > 6:
289
+ recommendations.append(f"Large number of monsters ({num_monsters}) may slow combat. Consider grouping or reducing.")
290
+
291
+ return {
292
+ "difficulty": difficulty,
293
+ "adjusted_xp": adjusted_xp,
294
+ "raw_xp": total_monster_xp,
295
+ "multiplier": multiplier,
296
+ "thresholds": party_thresholds,
297
+ "target_met": difficulty == difficulty_target,
298
+ "recommendations": recommendations,
299
+ "party_size": len(party_levels),
300
+ "monster_count": num_monsters
301
+ }
302
+
303
+
304
+ @mcp.tool()
305
+ def calculate_xp_award(
306
+ party_size: int,
307
+ monster_crs: List[float]
308
+ ) -> Dict[str, int]:
309
+ """
310
+ Calculate XP award per player for defeating monsters.
311
+
312
+ Args:
313
+ party_size: Number of players in party
314
+ monster_crs: List of monster CRs defeated
315
+
316
+ Returns:
317
+ XP breakdown
318
+
319
+ Example:
320
+ >>> calculate_xp_award(4, [2, 2, 0.5])
321
+ {"total_xp": 1150, "xp_per_player": 287}
322
+ """
323
+ total_xp = sum(CR_TO_XP.get(cr, 0) for cr in monster_crs)
324
+ xp_per_player = total_xp // party_size if party_size > 0 else 0
325
+
326
+ return {
327
+ "total_xp": total_xp,
328
+ "xp_per_player": xp_per_player,
329
+ "party_size": party_size,
330
+ "monsters_defeated": len(monster_crs)
331
+ }
332
+
333
+
334
+ @mcp.tool()
335
+ def validate_multiclass(
336
+ current_class: str,
337
+ target_class: str,
338
+ ability_scores: Dict[str, int]
339
+ ) -> Dict[str, Any]:
340
+ """
341
+ Check if character meets multiclass requirements.
342
+
343
+ Args:
344
+ current_class: Current primary class
345
+ target_class: Class to multiclass into
346
+ ability_scores: Character's ability scores
347
+
348
+ Returns:
349
+ Validation result with requirements
350
+
351
+ Example:
352
+ >>> validate_multiclass("fighter", "wizard", {"intelligence": 14, "strength": 16})
353
+ {"valid": True, "requirements_met": True, "required": {"intelligence": 13}}
354
+ """
355
+ current_class = current_class.lower()
356
+ target_class = target_class.lower()
357
+
358
+ if current_class not in CLASSES:
359
+ return {"valid": False, "error": f"Invalid current class: {current_class}"}
360
+
361
+ if target_class not in CLASSES:
362
+ return {"valid": False, "error": f"Invalid target class: {target_class}"}
363
+
364
+ # Check target class requirements
365
+ requirements = MULTICLASS_REQUIREMENTS[target_class]
366
+ requirements_met = True
367
+ missing = []
368
+
369
+ for ability, min_score in requirements.items():
370
+ if ability_scores.get(ability, 0) < min_score:
371
+ requirements_met = False
372
+ missing.append(f"{ability.title()} {min_score} (currently {ability_scores.get(ability, 0)})")
373
+
374
+ return {
375
+ "valid": True,
376
+ "requirements_met": requirements_met,
377
+ "required": requirements,
378
+ "missing": missing,
379
+ "can_multiclass": requirements_met
380
+ }
381
+
382
+
383
+ @mcp.tool()
384
+ def generate_npc_stats(
385
+ npc_name: str,
386
+ character_class: str,
387
+ level: int,
388
+ role: str = "standard"
389
+ ) -> Dict[str, Any]:
390
+ """
391
+ Generate NPC stat block for D&D 5e.
392
+
393
+ Args:
394
+ npc_name: NPC name
395
+ character_class: NPC class
396
+ level: NPC level
397
+ role: Combat role ("tank", "damage", "support", "standard")
398
+
399
+ Returns:
400
+ NPC stat block
401
+
402
+ Example:
403
+ >>> generate_npc_stats("Guard Captain", "fighter", 5, "tank")
404
+ {"name": "Guard Captain", "class": "fighter", "level": 5, "hp": 49, "ac": 18, ...}
405
+ """
406
+ character_class = character_class.lower()
407
+
408
+ if character_class not in CLASSES:
409
+ return {"error": f"Invalid class: {character_class}"}
410
+
411
+ class_info = CLASSES[character_class]
412
+
413
+ # Generate ability scores based on role
414
+ if role == "tank":
415
+ abilities = {"strength": 16, "dexterity": 12, "constitution": 16, "intelligence": 10, "wisdom": 12, "charisma": 10}
416
+ elif role == "damage":
417
+ abilities = {"strength": 16, "dexterity": 16, "constitution": 14, "intelligence": 10, "wisdom": 12, "charisma": 10}
418
+ elif role == "support":
419
+ abilities = {"strength": 10, "dexterity": 12, "constitution": 14, "intelligence": 14, "wisdom": 16, "charisma": 14}
420
+ else: # standard
421
+ abilities = {"strength": 14, "dexterity": 14, "constitution": 14, "intelligence": 12, "wisdom": 12, "charisma": 12}
422
+
423
+ # Calculate stats
424
+ hit_die = class_info["hit_die"]
425
+ con_mod = get_ability_modifier(abilities["constitution"])
426
+ hp = hit_die + (level - 1) * (hit_die // 2 + 1) + (level * con_mod)
427
+
428
+ # Estimate AC based on class and role
429
+ base_ac = {"barbarian": 13, "fighter": 16, "paladin": 16, "ranger": 15, "rogue": 14}.get(character_class, 12)
430
+ if role == "tank":
431
+ base_ac += 2
432
+
433
+ # Proficiency bonus
434
+ proficiency = 2 + ((level - 1) // 4)
435
+
436
+ return {
437
+ "name": npc_name,
438
+ "class": character_class,
439
+ "level": level,
440
+ "role": role,
441
+ "hp": hp,
442
+ "hp_formula": f"{level}d{hit_die} + {level * con_mod}",
443
+ "ac": base_ac,
444
+ "ability_scores": abilities,
445
+ "proficiency_bonus": proficiency,
446
+ "primary_ability": class_info["primary_ability"][0],
447
+ "hit_die": f"d{hit_die}"
448
+ }
449
+
450
+
451
+ @mcp.tool()
452
+ def get_cr_for_solo_monster(party_level: int, party_size: int, difficulty: str = "medium") -> float:
453
+ """
454
+ Calculate appropriate CR for a solo monster encounter.
455
+
456
+ Args:
457
+ party_level: Average party level
458
+ party_size: Number of party members
459
+ difficulty: Desired difficulty ("easy", "medium", "hard", "deadly")
460
+
461
+ Returns:
462
+ Recommended CR
463
+
464
+ Example:
465
+ >>> get_cr_for_solo_monster(5, 4, "hard")
466
+ 7.0
467
+ """
468
+ party_level = min(max(party_level, 1), 20)
469
+
470
+ # Get total party threshold for difficulty
471
+ total_threshold = XP_THRESHOLDS[party_level][difficulty] * party_size
472
+
473
+ # For solo monster, no multiplier
474
+ target_xp = total_threshold
475
+
476
+ # Find closest CR
477
+ best_cr = 0
478
+ best_diff = float('inf')
479
+
480
+ for cr, xp in CR_TO_XP.items():
481
+ diff = abs(xp - target_xp)
482
+ if diff < best_diff:
483
+ best_diff = diff
484
+ best_cr = cr
485
+
486
+ return {
487
+ "recommended_cr": best_cr,
488
+ "target_xp": target_xp,
489
+ "actual_xp": CR_TO_XP[best_cr],
490
+ "party_level": party_level,
491
+ "party_size": party_size,
492
+ "difficulty": difficulty
493
+ }
494
+
495
+
496
+ # ============================================================================
497
+ # MCP SERVER MAIN
498
+ # ============================================================================
499
+
500
+ if __name__ == "__main__":
501
+ print("🎲 D&D Campaign Manager MCP Server")
502
+ print("=" * 50)
503
+ print("Available Tools:")
504
+ print(" - validate_character: Validate character builds")
505
+ print(" - calculate_encounter_cr: Calculate encounter difficulty")
506
+ print(" - calculate_xp_award: Calculate XP rewards")
507
+ print(" - validate_multiclass: Check multiclass requirements")
508
+ print(" - generate_npc_stats: Generate NPC stat blocks")
509
+ print(" - get_ability_modifier: Calculate ability modifiers")
510
+ print(" - get_cr_for_solo_monster: Get CR for solo encounters")
511
+ print("=" * 50)
512
+ print("Starting MCP server...")
513
+
514
+ mcp.run()
mcp_server/mcp_config.json ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "mcpServers": {
3
+ "dnd-campaign-manager": {
4
+ "command": "python3",
5
+ "args": [
6
+ "mcp_server/dnd_mcp_server.py"
7
+ ],
8
+ "description": "D&D 5e Campaign Management Tools",
9
+ "tools": [
10
+ "validate_character",
11
+ "calculate_encounter_cr",
12
+ "calculate_xp_award",
13
+ "validate_multiclass",
14
+ "generate_npc_stats",
15
+ "get_ability_modifier",
16
+ "get_cr_for_solo_monster"
17
+ ]
18
+ }
19
+ }
20
+ }
requirements.txt ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # D&D Campaign Manager - Hugging Face Space Requirements
2
+ # Production dependencies only
3
+
4
+ # Core Framework
5
+ gradio>=5.0.0
6
+ pydantic>=2.0.0
7
+ python-dotenv>=1.0.0
8
+
9
+ # AI/LLM Providers
10
+ anthropic>=0.39.0
11
+ google-generativeai>=0.8.0
12
+ openai>=1.54.0
13
+
14
+ # MCP (Model Context Protocol)
15
+ fastmcp>=0.2.0
16
+
17
+ # Database & Storage
18
+ # Note: SQLite is built into Python, no extra deps needed
19
+
20
+ # Data Processing
21
+ pyyaml>=6.0.0
22
+ jinja2>=3.1.0
23
+
24
+ # Dice & Game Mechanics
25
+ dice>=4.0.0
26
+
27
+ # Image Generation (Optional)
28
+ pillow>=10.0.0
29
+ requests>=2.31.0
30
+
31
+ # Document Parsing
32
+ python-docx>=1.1.0
33
+ pypdf>=3.17.0
34
+
35
+ # Utilities
36
+ rich>=13.0.0
37
+ tqdm>=4.66.0
setup_secrets.md ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Setup Secrets on Hugging Face Spaces
2
+
3
+ After uploading these files to your HF Space, configure secrets:
4
+
5
+ ## Required (at least one)
6
+
7
+ Go to: **Settings β†’ Variables and secrets**
8
+
9
+ Add:
10
+
11
+ ```
12
+ ANTHROPIC_API_KEY = sk-ant-...
13
+ ```
14
+
15
+ OR
16
+
17
+ ```
18
+ OPENAI_API_KEY = sk-...
19
+ ```
20
+
21
+ OR
22
+
23
+ ```
24
+ GOOGLE_API_KEY = AIza...
25
+ ```
26
+
27
+ ## Optional
28
+
29
+ ```
30
+ HUGGINGFACE_API_KEY = hf_...
31
+ PRIMARY_MODEL_PROVIDER = anthropic
32
+ PRIMARY_MODEL = claude-3-5-sonnet-20241022
33
+ ```
34
+
35
+ ## Testing
36
+
37
+ After setting secrets:
38
+ 1. Wait for Space to rebuild (~3-5 min)
39
+ 2. Open your app
40
+ 3. Create a test character
41
+ 4. Verify AI generation works
42
+
43
+ ## Troubleshooting
44
+
45
+ **Build fails:**
46
+ - Check requirements.txt syntax
47
+ - Verify all files uploaded
48
+
49
+ **App crashes:**
50
+ - Check Logs tab for errors
51
+ - Verify API keys in Secrets
52
+
53
+ **No AI generation:**
54
+ - Confirm at least one API key is set
55
+ - Check API key is valid
56
+ - Review error messages in Status box
src/__init__.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ """
2
+ D'n'D Campaign Manager - TTRPG Platform
3
+ A hackathon project for Gradio + Anthropic MCP
4
+ """
5
+
6
+ __version__ = "2.0.0"
7
+ __author__ = "Jesse Stucker"
8
+ __description__ = "Complete TTRPG Campaign Management with MCP Integration"
src/agents/__init__.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Agents for D'n'D Campaign Manager
3
+ """
4
+
5
+ from .character_agent import CharacterAgent
6
+
7
+ __all__ = [
8
+ "CharacterAgent",
9
+ ]
src/agents/campaign_agent.py ADDED
@@ -0,0 +1,990 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Campaign Agent for managing D&D campaigns
3
+ """
4
+ import os
5
+ import sqlite3
6
+ import json
7
+ from datetime import datetime
8
+ from typing import Optional, List
9
+ from src.models.campaign import Campaign, CampaignEvent, CampaignTheme, EventType
10
+ from src.models.character import Character
11
+ from src.models.session_notes import SessionNotes
12
+ from src.utils.ai_client import get_ai_client
13
+
14
+
15
+ class CampaignAgent:
16
+ """Agent for managing D&D campaigns"""
17
+
18
+ def __init__(self, db_path: str = "data/campaigns.db"):
19
+ """Initialize campaign agent"""
20
+ self.db_path = db_path
21
+ self.ai_client = get_ai_client()
22
+ self._ensure_database()
23
+
24
+ def _ensure_database(self):
25
+ """Ensure database and tables exist"""
26
+ os.makedirs(os.path.dirname(self.db_path), exist_ok=True)
27
+
28
+ conn = sqlite3.connect(self.db_path)
29
+ cursor = conn.cursor()
30
+
31
+ # Campaigns table
32
+ cursor.execute("""
33
+ CREATE TABLE IF NOT EXISTS campaigns (
34
+ id TEXT PRIMARY KEY,
35
+ name TEXT NOT NULL,
36
+ theme TEXT NOT NULL,
37
+ setting TEXT NOT NULL,
38
+ world_name TEXT,
39
+ starting_location TEXT,
40
+ summary TEXT NOT NULL,
41
+ current_arc TEXT,
42
+ level_range TEXT,
43
+ main_conflict TEXT NOT NULL,
44
+ key_factions TEXT,
45
+ major_villains TEXT,
46
+ central_mysteries TEXT,
47
+ character_ids TEXT,
48
+ party_size INTEGER,
49
+ current_session INTEGER,
50
+ total_sessions INTEGER,
51
+ game_master TEXT,
52
+ is_active BOOLEAN,
53
+ homebrew_rules TEXT,
54
+ notes TEXT,
55
+ created_at TEXT,
56
+ updated_at TEXT,
57
+ last_session_date TEXT
58
+ )
59
+ """)
60
+
61
+ # Campaign events table
62
+ cursor.execute("""
63
+ CREATE TABLE IF NOT EXISTS campaign_events (
64
+ id TEXT PRIMARY KEY,
65
+ campaign_id TEXT NOT NULL,
66
+ session_number INTEGER NOT NULL,
67
+ event_type TEXT NOT NULL,
68
+ title TEXT NOT NULL,
69
+ description TEXT NOT NULL,
70
+ characters_involved TEXT,
71
+ npcs_involved TEXT,
72
+ locations TEXT,
73
+ consequences TEXT,
74
+ items_gained TEXT,
75
+ items_lost TEXT,
76
+ experience_awarded INTEGER,
77
+ timestamp TEXT,
78
+ importance INTEGER,
79
+ tags TEXT,
80
+ gm_notes TEXT,
81
+ player_visible BOOLEAN,
82
+ FOREIGN KEY (campaign_id) REFERENCES campaigns(id)
83
+ )
84
+ """)
85
+
86
+ # Session notes table
87
+ cursor.execute("""
88
+ CREATE TABLE IF NOT EXISTS session_notes (
89
+ id TEXT PRIMARY KEY,
90
+ campaign_id TEXT NOT NULL,
91
+ session_number INTEGER NOT NULL,
92
+ notes TEXT NOT NULL,
93
+ uploaded_at TEXT NOT NULL,
94
+ file_name TEXT,
95
+ file_type TEXT,
96
+ FOREIGN KEY (campaign_id) REFERENCES campaigns(id),
97
+ UNIQUE(campaign_id, session_number)
98
+ )
99
+ """)
100
+
101
+ conn.commit()
102
+ conn.close()
103
+
104
+ def create_campaign(
105
+ self,
106
+ name: str,
107
+ theme: str,
108
+ setting: str,
109
+ summary: str,
110
+ main_conflict: str,
111
+ game_master: str = "",
112
+ world_name: str = "",
113
+ starting_location: str = "",
114
+ level_range: str = "1-5",
115
+ party_size: int = 4
116
+ ) -> Campaign:
117
+ """Create a new campaign"""
118
+
119
+ # Generate campaign ID
120
+ campaign_id = name.lower().replace(" ", "-").replace("'", "")
121
+
122
+ # Create campaign
123
+ campaign = Campaign(
124
+ id=campaign_id,
125
+ name=name,
126
+ theme=CampaignTheme(theme),
127
+ setting=setting,
128
+ world_name=world_name,
129
+ starting_location=starting_location,
130
+ summary=summary,
131
+ main_conflict=main_conflict,
132
+ level_range=level_range,
133
+ party_size=party_size,
134
+ game_master=game_master,
135
+ current_session=1,
136
+ total_sessions=0,
137
+ is_active=True
138
+ )
139
+
140
+ # Save to database
141
+ self.save_campaign(campaign)
142
+
143
+ return campaign
144
+
145
+ def save_campaign(self, campaign: Campaign):
146
+ """Save campaign to database"""
147
+ conn = sqlite3.connect(self.db_path)
148
+ cursor = conn.cursor()
149
+
150
+ cursor.execute("""
151
+ INSERT OR REPLACE INTO campaigns (
152
+ id, name, theme, setting, world_name, starting_location,
153
+ summary, current_arc, level_range, main_conflict,
154
+ key_factions, major_villains, central_mysteries,
155
+ character_ids, party_size, current_session, total_sessions,
156
+ game_master, is_active, homebrew_rules, notes,
157
+ created_at, updated_at, last_session_date
158
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
159
+ """, (
160
+ campaign.id,
161
+ campaign.name,
162
+ campaign.theme.value,
163
+ campaign.setting,
164
+ campaign.world_name,
165
+ campaign.starting_location,
166
+ campaign.summary,
167
+ campaign.current_arc,
168
+ campaign.level_range,
169
+ campaign.main_conflict,
170
+ json.dumps(campaign.key_factions),
171
+ json.dumps(campaign.major_villains),
172
+ json.dumps(campaign.central_mysteries),
173
+ json.dumps(campaign.character_ids),
174
+ campaign.party_size,
175
+ campaign.current_session,
176
+ campaign.total_sessions,
177
+ campaign.game_master,
178
+ campaign.is_active,
179
+ json.dumps(campaign.homebrew_rules),
180
+ campaign.notes,
181
+ campaign.created_at.isoformat() if isinstance(campaign.created_at, datetime) else campaign.created_at,
182
+ campaign.updated_at.isoformat() if isinstance(campaign.updated_at, datetime) else campaign.updated_at,
183
+ campaign.last_session_date.isoformat() if campaign.last_session_date else None
184
+ ))
185
+
186
+ conn.commit()
187
+ conn.close()
188
+
189
+ def load_campaign(self, campaign_id: str) -> Optional[Campaign]:
190
+ """Load campaign from database"""
191
+ conn = sqlite3.connect(self.db_path)
192
+ cursor = conn.cursor()
193
+
194
+ cursor.execute("SELECT * FROM campaigns WHERE id = ?", (campaign_id,))
195
+ row = cursor.fetchone()
196
+ conn.close()
197
+
198
+ if not row:
199
+ return None
200
+
201
+ # Convert row to dict
202
+ columns = [
203
+ 'id', 'name', 'theme', 'setting', 'world_name', 'starting_location',
204
+ 'summary', 'current_arc', 'level_range', 'main_conflict',
205
+ 'key_factions', 'major_villains', 'central_mysteries',
206
+ 'character_ids', 'party_size', 'current_session', 'total_sessions',
207
+ 'game_master', 'is_active', 'homebrew_rules', 'notes',
208
+ 'created_at', 'updated_at', 'last_session_date'
209
+ ]
210
+
211
+ data = {}
212
+ for i, col in enumerate(columns):
213
+ value = row[i]
214
+
215
+ # Parse JSON fields
216
+ if col in ['key_factions', 'major_villains', 'central_mysteries', 'character_ids', 'homebrew_rules']:
217
+ data[col] = json.loads(value) if value else []
218
+ else:
219
+ data[col] = value
220
+
221
+ return Campaign(**data)
222
+
223
+ def list_campaigns(self, active_only: bool = False) -> List[Campaign]:
224
+ """List all campaigns"""
225
+ conn = sqlite3.connect(self.db_path)
226
+ cursor = conn.cursor()
227
+
228
+ if active_only:
229
+ cursor.execute("SELECT id FROM campaigns WHERE is_active = 1")
230
+ else:
231
+ cursor.execute("SELECT id FROM campaigns")
232
+
233
+ campaign_ids = [row[0] for row in cursor.fetchall()]
234
+ conn.close()
235
+
236
+ return [self.load_campaign(cid) for cid in campaign_ids]
237
+
238
+ def delete_campaign(self, campaign_id: str) -> bool:
239
+ """Delete a campaign and all associated data"""
240
+ conn = sqlite3.connect(self.db_path)
241
+ cursor = conn.cursor()
242
+
243
+ # Delete session notes first
244
+ cursor.execute("DELETE FROM session_notes WHERE campaign_id = ?", (campaign_id,))
245
+
246
+ # Delete events
247
+ cursor.execute("DELETE FROM campaign_events WHERE campaign_id = ?", (campaign_id,))
248
+
249
+ # Delete campaign
250
+ cursor.execute("DELETE FROM campaigns WHERE id = ?", (campaign_id,))
251
+
252
+ deleted = cursor.rowcount > 0
253
+ conn.commit()
254
+ conn.close()
255
+
256
+ return deleted
257
+
258
+ def add_character_to_campaign(self, campaign_id: str, character_id: str) -> bool:
259
+ """Add a character to a campaign"""
260
+ campaign = self.load_campaign(campaign_id)
261
+ if not campaign:
262
+ return False
263
+
264
+ campaign.add_character(character_id)
265
+ self.save_campaign(campaign)
266
+ return True
267
+
268
+ def remove_character_from_campaign(self, campaign_id: str, character_id: str) -> bool:
269
+ """Remove a character from a campaign"""
270
+ campaign = self.load_campaign(campaign_id)
271
+ if not campaign:
272
+ return False
273
+
274
+ campaign.remove_character(character_id)
275
+ self.save_campaign(campaign)
276
+ return True
277
+
278
+ def start_new_session(self, campaign_id: str) -> bool:
279
+ """Start a new session for a campaign"""
280
+ campaign = self.load_campaign(campaign_id)
281
+ if not campaign:
282
+ return False
283
+
284
+ campaign.start_new_session()
285
+ self.save_campaign(campaign)
286
+ return True
287
+
288
+ def add_event(
289
+ self,
290
+ campaign_id: str,
291
+ event_type: str,
292
+ title: str,
293
+ description: str,
294
+ session_number: Optional[int] = None,
295
+ characters_involved: Optional[List[str]] = None,
296
+ npcs_involved: Optional[List[str]] = None,
297
+ locations: Optional[List[str]] = None,
298
+ importance: int = 3
299
+ ) -> Optional[CampaignEvent]:
300
+ """Add an event to a campaign"""
301
+ campaign = self.load_campaign(campaign_id)
302
+ if not campaign:
303
+ return None
304
+
305
+ # Use current session if not specified
306
+ if session_number is None:
307
+ session_number = campaign.current_session
308
+
309
+ # Create event
310
+ event_id = f"{campaign_id}-event-{datetime.now().timestamp()}"
311
+ event = CampaignEvent(
312
+ id=event_id,
313
+ campaign_id=campaign_id,
314
+ session_number=session_number,
315
+ event_type=EventType(event_type),
316
+ title=title,
317
+ description=description,
318
+ characters_involved=characters_involved or [],
319
+ npcs_involved=npcs_involved or [],
320
+ locations=locations or [],
321
+ importance=importance
322
+ )
323
+
324
+ # Save to database
325
+ conn = sqlite3.connect(self.db_path)
326
+ cursor = conn.cursor()
327
+
328
+ cursor.execute("""
329
+ INSERT INTO campaign_events (
330
+ id, campaign_id, session_number, event_type, title, description,
331
+ characters_involved, npcs_involved, locations, consequences,
332
+ items_gained, items_lost, experience_awarded, timestamp,
333
+ importance, tags, gm_notes, player_visible
334
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
335
+ """, (
336
+ event.id,
337
+ event.campaign_id,
338
+ event.session_number,
339
+ event.event_type.value,
340
+ event.title,
341
+ event.description,
342
+ json.dumps(event.characters_involved),
343
+ json.dumps(event.npcs_involved),
344
+ json.dumps(event.locations),
345
+ json.dumps(event.consequences),
346
+ json.dumps(event.items_gained),
347
+ json.dumps(event.items_lost),
348
+ event.experience_awarded,
349
+ event.timestamp.isoformat(),
350
+ event.importance,
351
+ json.dumps(event.tags),
352
+ event.gm_notes,
353
+ event.player_visible
354
+ ))
355
+
356
+ conn.commit()
357
+ conn.close()
358
+
359
+ # Update campaign memory
360
+ campaign.add_event(event)
361
+ self.save_campaign(campaign)
362
+
363
+ return event
364
+
365
+ def get_campaign_events(self, campaign_id: str, session_number: Optional[int] = None) -> List[CampaignEvent]:
366
+ """Get events for a campaign"""
367
+ conn = sqlite3.connect(self.db_path)
368
+ cursor = conn.cursor()
369
+
370
+ if session_number:
371
+ cursor.execute(
372
+ "SELECT * FROM campaign_events WHERE campaign_id = ? AND session_number = ? ORDER BY timestamp",
373
+ (campaign_id, session_number)
374
+ )
375
+ else:
376
+ cursor.execute(
377
+ "SELECT * FROM campaign_events WHERE campaign_id = ? ORDER BY timestamp",
378
+ (campaign_id,)
379
+ )
380
+
381
+ rows = cursor.fetchall()
382
+ conn.close()
383
+
384
+ events = []
385
+ for row in rows:
386
+ event_data = {
387
+ 'id': row[0],
388
+ 'campaign_id': row[1],
389
+ 'session_number': row[2],
390
+ 'event_type': row[3],
391
+ 'title': row[4],
392
+ 'description': row[5],
393
+ 'characters_involved': json.loads(row[6]) if row[6] else [],
394
+ 'npcs_involved': json.loads(row[7]) if row[7] else [],
395
+ 'locations': json.loads(row[8]) if row[8] else [],
396
+ 'consequences': json.loads(row[9]) if row[9] else [],
397
+ 'items_gained': json.loads(row[10]) if row[10] else [],
398
+ 'items_lost': json.loads(row[11]) if row[11] else [],
399
+ 'experience_awarded': row[12],
400
+ 'timestamp': row[13],
401
+ 'importance': row[14],
402
+ 'tags': json.loads(row[15]) if row[15] else [],
403
+ 'gm_notes': row[16],
404
+ 'player_visible': row[17]
405
+ }
406
+ events.append(CampaignEvent(**event_data))
407
+
408
+ return events
409
+
410
+ def export_campaign_summary(self, campaign_id: str) -> str:
411
+ """Export campaign summary as markdown"""
412
+ campaign = self.load_campaign(campaign_id)
413
+ if not campaign:
414
+ return "Campaign not found"
415
+
416
+ return campaign.to_markdown()
417
+
418
+ def synthesize_campaign_from_characters(
419
+ self,
420
+ characters: List[Character],
421
+ game_master: str = "",
422
+ additional_notes: str = ""
423
+ ) -> Campaign:
424
+ """
425
+ Synthesize a campaign tailored to the provided characters using AI.
426
+
427
+ Analyzes the party composition, backstories, alignments, and creates
428
+ a custom campaign that fits the characters.
429
+ """
430
+
431
+ # Build character analysis
432
+ party_analysis = []
433
+ for char in characters:
434
+ party_analysis.append(f"""
435
+ - **{char.name}** (Level {char.level} {char.race.value} {char.character_class.value})
436
+ - Alignment: {char.alignment.value}
437
+ - Background: {char.background.background_type}
438
+ - Backstory: {char.background.backstory[:200]}...
439
+ - Personality: {char.background.personality_traits[:150]}...""")
440
+
441
+ party_summary = "\n".join(party_analysis)
442
+
443
+ # Calculate party level range
444
+ levels = [char.level for char in characters]
445
+ min_level = min(levels)
446
+ max_level = max(levels)
447
+ avg_level = sum(levels) // len(levels)
448
+ level_range = f"{min_level}-{max_level}" if min_level != max_level else f"{min_level}"
449
+
450
+ # Determine appropriate challenge level
451
+ if avg_level <= 3:
452
+ tier = "Tier 1 (Local Heroes)"
453
+ scope = "local region or small kingdom"
454
+ elif avg_level <= 10:
455
+ tier = "Tier 2 (Heroes of the Realm)"
456
+ scope = "kingdom or large region"
457
+ elif avg_level <= 16:
458
+ tier = "Tier 3 (Masters of the Realm)"
459
+ scope = "continent or multiple kingdoms"
460
+ else:
461
+ tier = "Tier 4 (Masters of the World)"
462
+ scope = "entire world or planar"
463
+
464
+ # Create AI prompt for campaign synthesis
465
+ prompt = f"""You are an expert Dungeon Master creating a COMPLETE campaign guide for D&D 5e.
466
+
467
+ **Party Composition ({len(characters)} characters, Level {level_range}):**
468
+ {party_summary}
469
+
470
+ **Campaign Tier:** {tier}
471
+ **Appropriate Scope:** {scope}
472
+
473
+ {f"**Additional DM Notes:** {additional_notes}" if additional_notes else ""}
474
+
475
+ Create a DETAILED campaign that weaves all these characters together. Include:
476
+
477
+ 1. **Character Connections**: How do these characters know each other or become connected?
478
+ 2. **Personal Stakes**: What does each character have to lose/gain?
479
+ 3. **Villain Details**: Full profiles with motivations and how they threaten each PC
480
+ 4. **World Details**: Specific locations, politics, and cultural elements
481
+ 5. **Adventure Hooks**: 3-4 specific scenarios that pull characters in
482
+ 6. **First Session Outline**: Concrete opening scenario
483
+ 7. **Story Progression**: Where the campaign leads over 5-10 sessions
484
+
485
+ Generate a comprehensive campaign document with these sections:
486
+
487
+ **CAMPAIGN_NAME:** (Epic, memorable name - 2-5 words)
488
+
489
+ **THEME:** (Choose ONE: High Fantasy, Dark Fantasy, Urban Fantasy, Political Intrigue, Horror, Exploration, Dungeon Crawl, or Custom)
490
+
491
+ **WORLD_NAME:** (Name of the world/realm)
492
+
493
+ **STARTING_LOCATION:** (Town, city, or region where adventure begins)
494
+
495
+ **SETTING:** (3-4 sentences about the world, its current political state, and atmosphere)
496
+
497
+ **SUMMARY:** (4-5 sentences explaining the campaign hook and what drives the story)
498
+
499
+ **CONFLICT:** (2-3 sentences describing the central threat and why it matters)
500
+
501
+ **FACTIONS:** (List 3-4 factions with brief descriptions, format: "Faction Name - description")
502
+
503
+ **VILLAINS:** (2-3 detailed villain profiles with:
504
+ - Name and role
505
+ - Motivation (what they want and why)
506
+ - Connection to party (which PCs they threaten and how)
507
+ - Methods (how they operate)
508
+ Format: "Villain Name - Role | Motivation | Connections | Methods")
509
+
510
+ **CHARACTER_CONNECTIONS:** REQUIRED - For EACH party member listed above, explain how they fit (Format: "Name: connection details | Name2: connection details | ...")
511
+
512
+ **ADVENTURE_HOOKS:** REQUIRED - List 3-4 specific hooks separated by pipes (Format: "Hook 1 details | Hook 2 details | Hook 3 details")
513
+
514
+ **FIRST_SESSION:** REQUIRED - Detailed opening scene (Format: "Scene description")
515
+
516
+ **SESSION_OUTLINES:** REQUIRED - Sessions 2-5 separated by pipes (Format: "Session 2 details | Session 3 details | Session 4 details | Session 5 details")
517
+
518
+ **MYSTERIES:** REQUIRED - 2-3 mysteries separated by pipes (Format: "Mystery 1 | Mystery 2 | Mystery 3")
519
+
520
+ **KEY_NPCS:** REQUIRED - 3-4 NPCs separated by pipes (Format: "NPC Name - role and connection | NPC2 Name - role and connection | ...")
521
+
522
+ **LOCATIONS:** REQUIRED - 3-4 locations separated by pipes (Format: "Location Name - description | Location2 Name - description | ...")
523
+
524
+ **STORY_ARC:** (First major story arc with clear beginning, middle, and climax)
525
+
526
+ Format your response EXACTLY as follows (DO NOT SKIP ANY FIELDS):
527
+ ---
528
+ CAMPAIGN_NAME: [name]
529
+ THEME: [theme]
530
+ WORLD_NAME: [world]
531
+ STARTING_LOCATION: [location]
532
+ SETTING: [setting details]
533
+ SUMMARY: [campaign summary]
534
+ CONFLICT: [main conflict]
535
+ FACTIONS: [faction1 - desc | faction2 - desc | faction3 - desc]
536
+ VILLAINS: [villain1 details | villain2 details]
537
+ CHARACTER_CONNECTIONS: [char1: how they fit | char2: how they fit | char3: how they fit | char4: how they fit]
538
+ ADVENTURE_HOOKS: [hook1 description in 2-3 sentences | hook2 description | hook3 description | hook4 description]
539
+ FIRST_SESSION: [opening scene details]
540
+ SESSION_OUTLINES: [session 2 outline | session 3 outline | session 4 outline | session 5 outline]
541
+ MYSTERIES: [mystery1 details | mystery2 details | mystery3 details]
542
+ KEY_NPCS: [NPC Name - their role and connection to party | NPC2 Name - role and connection | NPC3 Name - role | NPC4 Name - role]
543
+ LOCATIONS: [Location Name - description of place | Location2 Name - description | Location3 Name - description | Location4 Name - description]
544
+ STORY_ARC: [arc details]
545
+ ---
546
+
547
+ IMPORTANT: Every field listed above MUST have content. Use pipe separators (|) between items in list fields."""
548
+
549
+ # Get AI response
550
+ response = self.ai_client.generate_creative(prompt)
551
+
552
+ # Parse the response
553
+ parsed = self._parse_campaign_synthesis(response)
554
+
555
+ # If critical fields are missing, make a second focused call
556
+ missing_fields = []
557
+ if not parsed.get('character_connections') or len(parsed.get('character_connections', [])) == 0:
558
+ missing_fields.append('CHARACTER_CONNECTIONS')
559
+ if not parsed.get('adventure_hooks') or len(parsed.get('adventure_hooks', [])) == 0:
560
+ missing_fields.append('ADVENTURE_HOOKS')
561
+ if not parsed.get('key_npcs') or len(parsed.get('key_npcs', [])) == 0:
562
+ missing_fields.append('KEY_NPCS')
563
+ if not parsed.get('locations') or len(parsed.get('locations', [])) == 0:
564
+ missing_fields.append('LOCATIONS')
565
+ if not parsed.get('session_outlines') or len(parsed.get('session_outlines', [])) == 0:
566
+ missing_fields.append('SESSION_OUTLINES')
567
+
568
+ if missing_fields:
569
+ # Make focused call for missing fields
570
+ fields_list = ' '.join(['**' + field + ':**' for field in missing_fields])
571
+ format_template = '\n'.join([field + ': [details | more details | etc]' for field in missing_fields])
572
+
573
+ followup_prompt = f"""Campaign: {parsed.get('name', 'Campaign')}
574
+ Setting: {parsed.get('setting', '')}
575
+
576
+ Party:
577
+ {party_summary}
578
+
579
+ Generate ONLY these missing campaign elements. Use pipe (|) separators between items:
580
+
581
+ {fields_list}
582
+
583
+ Format:
584
+ ---
585
+ {format_template}
586
+ ---"""
587
+
588
+ followup_response = self.ai_client.generate_creative(followup_prompt)
589
+ followup_parsed = self._parse_campaign_synthesis(followup_response)
590
+
591
+ # Merge the results
592
+ for key in ['character_connections', 'adventure_hooks', 'key_npcs', 'locations', 'session_outlines']:
593
+ if key in followup_parsed and followup_parsed[key]:
594
+ parsed[key] = followup_parsed[key]
595
+
596
+ # Create campaign
597
+ campaign = self.create_campaign(
598
+ name=parsed.get('name', 'Untitled Campaign'),
599
+ theme=parsed.get('theme', 'High Fantasy'),
600
+ setting=parsed.get('setting', 'A fantasy realm awaits...'),
601
+ summary=parsed.get('summary', 'An epic adventure begins...'),
602
+ main_conflict=parsed.get('conflict', 'Evil threatens the land...'),
603
+ game_master=game_master,
604
+ world_name=parsed.get('world_name', 'The Realm'),
605
+ starting_location=parsed.get('starting_location', 'The Crossroads'),
606
+ level_range=level_range,
607
+ party_size=len(characters)
608
+ )
609
+
610
+ # Load the created campaign and add additional details
611
+ campaign = self.load_campaign(campaign.id)
612
+
613
+ # Add factions, villains, and mysteries
614
+ if 'factions' in parsed:
615
+ campaign.key_factions = parsed['factions']
616
+ if 'villains' in parsed:
617
+ campaign.major_villains = parsed['villains']
618
+ if 'mysteries' in parsed:
619
+ campaign.central_mysteries = parsed['mysteries']
620
+ if 'story_arc' in parsed:
621
+ campaign.current_arc = parsed['story_arc']
622
+
623
+ # Add detailed campaign notes with all the extra information
624
+ campaign_notes = self._build_campaign_notes(parsed)
625
+ campaign.notes = campaign_notes
626
+
627
+ # Add all characters to the campaign
628
+ for char in characters:
629
+ campaign.add_character(char.id)
630
+
631
+ # Save updated campaign
632
+ self.save_campaign(campaign)
633
+
634
+ return campaign
635
+
636
+ def _build_campaign_notes(self, parsed: dict) -> str:
637
+ """Build detailed campaign notes from parsed data"""
638
+ notes = []
639
+
640
+ if 'character_connections' in parsed:
641
+ notes.append("## Character Connections\n")
642
+ for conn in parsed['character_connections']:
643
+ notes.append(f"- {conn}\n")
644
+ notes.append("\n")
645
+
646
+ if 'adventure_hooks' in parsed:
647
+ notes.append("## Adventure Hooks\n")
648
+ for i, hook in enumerate(parsed['adventure_hooks'], 1):
649
+ notes.append(f"{i}. {hook}\n")
650
+ notes.append("\n")
651
+
652
+ if 'first_session' in parsed:
653
+ notes.append("## First Session Opening\n")
654
+ notes.append(f"{parsed['first_session']}\n\n")
655
+
656
+ if 'session_outlines' in parsed:
657
+ notes.append("## Session Progression\n")
658
+ for i, outline in enumerate(parsed['session_outlines'], 2):
659
+ notes.append(f"**Session {i}:** {outline}\n")
660
+ notes.append("\n")
661
+
662
+ if 'key_npcs' in parsed:
663
+ notes.append("## Key NPCs\n")
664
+ for npc in parsed['key_npcs']:
665
+ notes.append(f"- {npc}\n")
666
+ notes.append("\n")
667
+
668
+ if 'locations' in parsed:
669
+ notes.append("## Key Locations\n")
670
+ for loc in parsed['locations']:
671
+ notes.append(f"- {loc}\n")
672
+ notes.append("\n")
673
+
674
+ return "".join(notes)
675
+
676
+ def _parse_campaign_synthesis(self, ai_response: str) -> dict:
677
+ """Parse AI response for campaign synthesis"""
678
+ parsed = {}
679
+
680
+ # Extract content between --- markers
681
+ if '---' in ai_response:
682
+ parts = ai_response.split('---')
683
+ if len(parts) >= 2:
684
+ content = parts[1]
685
+ else:
686
+ content = ai_response
687
+ else:
688
+ content = ai_response
689
+
690
+ # Parse each field
691
+ lines = content.strip().split('\n')
692
+ for line in lines:
693
+ if ':' in line:
694
+ key, value = line.split(':', 1)
695
+ key = key.strip().lower().replace('campaign_', '').replace('_', '_')
696
+ value = value.strip()
697
+
698
+ # Handle pipe-separated fields (detailed lists)
699
+ if key in ['factions', 'villains', 'character_connections', 'adventure_hooks',
700
+ 'session_outlines', 'mysteries', 'key_npcs', 'locations']:
701
+ # Split by pipe and clean up each item
702
+ parsed[key] = [item.strip() for item in value.split('|') if item.strip()]
703
+ else:
704
+ parsed[key] = value
705
+
706
+ return parsed
707
+
708
+ def auto_generate_next_session(self, campaign_id: str) -> dict:
709
+ """
710
+ Autonomously generate the next session based on campaign progress.
711
+
712
+ This is a LOW-RISK autonomous feature that:
713
+ - Analyzes campaign state and previous sessions
714
+ - Generates structured session content
715
+ - Does NOT modify the campaign directly
716
+ - Returns session data for user review/approval
717
+
718
+ Args:
719
+ campaign_id: Campaign identifier
720
+
721
+ Returns:
722
+ dict with session details (title, opening, encounters, npcs, etc.)
723
+ """
724
+ campaign = self.load_campaign(campaign_id)
725
+ if not campaign:
726
+ return {"error": "Campaign not found"}
727
+
728
+ # Get session context
729
+ events = self.get_campaign_events(campaign_id)
730
+ session_events = [e for e in events if e.type == EventType.SESSION]
731
+ current_session_num = len(session_events) + 1
732
+ last_session = session_events[-1] if session_events else None
733
+
734
+ # Get session notes from previous sessions (last 2-3 sessions for context)
735
+ all_session_notes = self.get_session_notes(campaign_id)
736
+ recent_notes = all_session_notes[:3] if all_session_notes else []
737
+
738
+ # Build session notes context
739
+ notes_context = ""
740
+ if recent_notes:
741
+ notes_context = "\n**PREVIOUS SESSION NOTES:**\n\n"
742
+ for note in reversed(recent_notes): # Chronological order
743
+ notes_context += f"**Session {note.session_number} Notes:**\n"
744
+ # Limit each session's notes to 2000 characters
745
+ truncated_notes = note.notes[:2000]
746
+ if len(note.notes) > 2000:
747
+ truncated_notes += "\n... (notes truncated)"
748
+ notes_context += f"{truncated_notes}\n\n"
749
+
750
+ # Build last session context
751
+ if not notes_context:
752
+ if last_session:
753
+ last_session_info = f"**Last Session Summary:**\n{last_session.description}"
754
+ else:
755
+ last_session_info = "**Last Session Summary:**\nThis is the first session - use the campaign first session opening from the notes."
756
+ else:
757
+ last_session_info = notes_context
758
+
759
+ # Build context for AI
760
+ prompt = f"""You are an expert Dungeon Master planning the next D&D 5e session.
761
+
762
+ **Campaign:** {campaign.name}
763
+ **Theme:** {campaign.theme}
764
+ **Setting:** {campaign.setting}
765
+ **Current Arc:** {campaign.current_arc}
766
+ **Party Size:** {campaign.party_size}
767
+ **Session Number:** {current_session_num}
768
+
769
+ **Main Conflict:** {campaign.main_conflict}
770
+
771
+ **Key Factions:** {', '.join(campaign.key_factions) if campaign.key_factions else 'None yet'}
772
+
773
+ **Major Villains:** {', '.join(campaign.major_villains) if campaign.major_villains else 'None yet'}
774
+
775
+ {last_session_info}
776
+
777
+ **Campaign Notes:**
778
+ {campaign.notes[:1000] if campaign.notes else 'No additional notes'}
779
+
780
+ ---
781
+
782
+ Based on what happened in the previous sessions (see session notes above), generate a complete
783
+ session plan for Session {current_session_num} that:
784
+
785
+ 1. Builds on player choices and consequences documented in previous session notes
786
+ 2. Follows up on narrative threads mentioned in the notes
787
+ 3. Responds to how players engaged with NPCs and locations
788
+ 4. Incorporates improvised content that worked well
789
+ 5. Addresses unresolved plot hooks from the notes
790
+
791
+ Include:
792
+
793
+ 1. **SESSION_TITLE:** A compelling title for this session (3-6 words)
794
+ 2. **OPENING_SCENE:** The opening narration/scene (2-3 paragraphs)
795
+ 3. **KEY_ENCOUNTERS:** 2-4 encounters (combat, social, exploration) separated by pipes (|)
796
+ 4. **NPCS_FEATURED:** NPCs that appear in this session, separated by pipes (|)
797
+ 5. **LOCATIONS:** Locations visited in this session, separated by pipes (|)
798
+ 6. **PLOT_DEVELOPMENTS:** Major plot points that advance this session, separated by pipes (|)
799
+ 7. **POTENTIAL_OUTCOMES:** 2-3 possible ways the session could end, separated by pipes (|)
800
+ 8. **REWARDS:** Treasure, XP, or story rewards, separated by pipes (|)
801
+ 9. **CLIFFHANGER:** Optional cliffhanger for next session (1-2 sentences)
802
+
803
+ Format your response EXACTLY as:
804
+ ---
805
+ SESSION_TITLE: [title]
806
+ OPENING_SCENE: [opening narration in 2-3 paragraphs]
807
+ KEY_ENCOUNTERS: [encounter 1 description | encounter 2 description | encounter 3 description]
808
+ NPCS_FEATURED: [NPC name and role | NPC2 name and role | NPC3 name and role]
809
+ LOCATIONS: [location 1 | location 2 | location 3]
810
+ PLOT_DEVELOPMENTS: [development 1 | development 2 | development 3]
811
+ POTENTIAL_OUTCOMES: [outcome 1 | outcome 2 | outcome 3]
812
+ REWARDS: [reward 1 | reward 2 | reward 3]
813
+ CLIFFHANGER: [cliffhanger sentence]
814
+ ---
815
+ """
816
+
817
+ # Generate session content
818
+ ai_response = self.ai_client.generate_creative(prompt)
819
+
820
+ # Parse the response
821
+ session_data = self._parse_session_generation(ai_response)
822
+
823
+ # Add metadata
824
+ session_data['session_number'] = current_session_num
825
+ session_data['campaign_id'] = campaign_id
826
+ session_data['generated_at'] = datetime.now().isoformat()
827
+ session_data['auto_generated'] = True
828
+ session_data['used_session_notes'] = len(recent_notes) > 0
829
+ session_data['notes_count'] = len(recent_notes)
830
+
831
+ return session_data
832
+
833
+ def _parse_session_generation(self, ai_response: str) -> dict:
834
+ """Parse AI response for session generation"""
835
+ parsed = {}
836
+
837
+ # Extract content between --- markers
838
+ if '---' in ai_response:
839
+ parts = ai_response.split('---')
840
+ content = parts[1] if len(parts) >= 2 else ai_response
841
+ else:
842
+ content = ai_response
843
+
844
+ # Parse each field
845
+ lines = content.strip().split('\n')
846
+ for line in lines:
847
+ if ':' in line:
848
+ key, value = line.split(':', 1)
849
+ key = key.strip().lower().replace(' ', '_')
850
+ value = value.strip()
851
+
852
+ # Handle pipe-separated fields
853
+ if key in ['key_encounters', 'npcs_featured', 'locations',
854
+ 'plot_developments', 'potential_outcomes', 'rewards']:
855
+ parsed[key] = [item.strip() for item in value.split('|') if item.strip()]
856
+ else:
857
+ parsed[key] = value
858
+
859
+ return parsed
860
+
861
+ def save_session_notes(
862
+ self,
863
+ campaign_id: str,
864
+ session_number: int,
865
+ notes: str,
866
+ file_name: Optional[str] = None,
867
+ file_type: Optional[str] = None
868
+ ) -> SessionNotes:
869
+ """
870
+ Save session notes for a campaign.
871
+
872
+ Args:
873
+ campaign_id: Campaign identifier
874
+ session_number: Session number
875
+ notes: Freeform session notes content
876
+ file_name: Optional original filename if uploaded
877
+ file_type: Optional file extension (.txt, .md, .docx, .pdf)
878
+
879
+ Returns:
880
+ SessionNotes object
881
+ """
882
+ # Create session notes object
883
+ session_notes = SessionNotes(
884
+ id=f"{campaign_id}-session-{session_number}",
885
+ campaign_id=campaign_id,
886
+ session_number=session_number,
887
+ notes=notes,
888
+ uploaded_at=datetime.now(),
889
+ file_name=file_name,
890
+ file_type=file_type
891
+ )
892
+
893
+ # Save to database (upsert - replace if exists)
894
+ conn = sqlite3.connect(self.db_path)
895
+ cursor = conn.cursor()
896
+
897
+ cursor.execute("""
898
+ INSERT OR REPLACE INTO session_notes
899
+ (id, campaign_id, session_number, notes, uploaded_at, file_name, file_type)
900
+ VALUES (?, ?, ?, ?, ?, ?, ?)
901
+ """, (
902
+ session_notes.id,
903
+ session_notes.campaign_id,
904
+ session_notes.session_number,
905
+ session_notes.notes,
906
+ session_notes.uploaded_at.isoformat(),
907
+ session_notes.file_name,
908
+ session_notes.file_type
909
+ ))
910
+
911
+ conn.commit()
912
+ conn.close()
913
+
914
+ return session_notes
915
+
916
+ def get_session_notes(
917
+ self,
918
+ campaign_id: str,
919
+ session_number: Optional[int] = None
920
+ ) -> List[SessionNotes]:
921
+ """
922
+ Get session notes for a campaign.
923
+
924
+ Args:
925
+ campaign_id: Campaign identifier
926
+ session_number: Optional specific session number (if None, returns all)
927
+
928
+ Returns:
929
+ List of SessionNotes objects (ordered by session number DESC)
930
+ """
931
+ conn = sqlite3.connect(self.db_path)
932
+ cursor = conn.cursor()
933
+
934
+ if session_number is not None:
935
+ cursor.execute("""
936
+ SELECT id, campaign_id, session_number, notes, uploaded_at, file_name, file_type
937
+ FROM session_notes
938
+ WHERE campaign_id = ? AND session_number = ?
939
+ """, (campaign_id, session_number))
940
+ else:
941
+ cursor.execute("""
942
+ SELECT id, campaign_id, session_number, notes, uploaded_at, file_name, file_type
943
+ FROM session_notes
944
+ WHERE campaign_id = ?
945
+ ORDER BY session_number DESC
946
+ """, (campaign_id,))
947
+
948
+ rows = cursor.fetchall()
949
+ conn.close()
950
+
951
+ # Convert to SessionNotes objects
952
+ notes_list = []
953
+ for row in rows:
954
+ notes_list.append(SessionNotes(
955
+ id=row[0],
956
+ campaign_id=row[1],
957
+ session_number=row[2],
958
+ notes=row[3],
959
+ uploaded_at=datetime.fromisoformat(row[4]),
960
+ file_name=row[5],
961
+ file_type=row[6]
962
+ ))
963
+
964
+ return notes_list
965
+
966
+ def delete_session_notes(self, campaign_id: str, session_number: int) -> bool:
967
+ """
968
+ Delete session notes for a specific session.
969
+
970
+ Args:
971
+ campaign_id: Campaign identifier
972
+ session_number: Session number
973
+
974
+ Returns:
975
+ True if deleted, False if not found
976
+ """
977
+ conn = sqlite3.connect(self.db_path)
978
+ cursor = conn.cursor()
979
+
980
+ cursor.execute("""
981
+ DELETE FROM session_notes
982
+ WHERE campaign_id = ? AND session_number = ?
983
+ """, (campaign_id, session_number))
984
+
985
+ deleted = cursor.rowcount > 0
986
+
987
+ conn.commit()
988
+ conn.close()
989
+
990
+ return deleted
src/agents/character_agent.py ADDED
@@ -0,0 +1,854 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Character Creator Agent - D&D character generation for D'n'D Campaign Manager
3
+ """
4
+
5
+ import uuid
6
+ from typing import Optional, Dict, Any
7
+ from datetime import datetime
8
+
9
+ from src.models.character import (
10
+ Character, CharacterStats, CharacterBackground,
11
+ DnDRace, DnDClass, Alignment, HIT_DICE_BY_CLASS
12
+ )
13
+ from src.utils.ai_client import get_ai_client
14
+ from src.utils.dice import DiceRoller
15
+ from src.utils.database import get_database
16
+ from src.utils.validators import validate_character
17
+ from src.utils.image_generator import get_image_generator
18
+
19
+
20
+ class CharacterAgent:
21
+ """Agent for creating D&D characters"""
22
+
23
+ def __init__(self):
24
+ self.ai_client = get_ai_client()
25
+ self.dice_roller = DiceRoller()
26
+ self.database = get_database()
27
+ try:
28
+ self.image_generator = get_image_generator()
29
+ except Exception as e:
30
+ print(f"Warning: Image generator not available: {e}")
31
+ self.image_generator = None
32
+
33
+ def create_character(
34
+ self,
35
+ name: Optional[str] = None,
36
+ race: Optional[DnDRace] = None,
37
+ character_class: Optional[DnDClass] = None,
38
+ level: int = 1,
39
+ background_type: Optional[str] = None,
40
+ personality_prompt: Optional[str] = None,
41
+ stats_method: str = "standard_array", # "roll", "standard_array", "point_buy"
42
+ custom_stats: Optional[Dict[str, int]] = None,
43
+ ) -> Character:
44
+ """
45
+ Create a complete D&D character
46
+
47
+ Args:
48
+ name: Character name (auto-generated if None)
49
+ race: Character race (random if None)
50
+ character_class: Character class (random if None)
51
+ level: Starting level (1-20)
52
+ background_type: Background type
53
+ personality_prompt: Prompt to generate personality
54
+ stats_method: How to generate ability scores
55
+ custom_stats: Pre-set ability scores
56
+
57
+ Returns:
58
+ Complete Character object
59
+ """
60
+ # Generate character ID
61
+ character_id = str(uuid.uuid4())
62
+
63
+ # Generate name if not provided
64
+ if not name:
65
+ name = self._generate_name(race, character_class)
66
+
67
+ # Select race and class if not provided
68
+ if not race:
69
+ race = self._select_random_race()
70
+ if not character_class:
71
+ character_class = self._select_random_class()
72
+
73
+ # Generate ability scores
74
+ if custom_stats:
75
+ stats = CharacterStats(**custom_stats)
76
+ else:
77
+ stats = self._generate_stats(stats_method)
78
+
79
+ # Apply racial ability score bonuses (D&D 5e rule)
80
+ stats = self._apply_racial_bonuses(stats, race)
81
+
82
+ # Calculate HP
83
+ max_hp = self._calculate_starting_hp(character_class, stats.constitution_modifier, level)
84
+
85
+ # Generate background and personality
86
+ background = self._generate_background(
87
+ name, race, character_class, background_type, personality_prompt
88
+ )
89
+
90
+ # Generate alignment based on personality
91
+ alignment = self._determine_alignment(background)
92
+
93
+ # Get starting equipment
94
+ equipment = self._get_starting_equipment(character_class)
95
+
96
+ # Get class features
97
+ features = self._get_class_features(character_class, level)
98
+
99
+ # Get proficiencies
100
+ proficiencies = self._get_proficiencies(character_class, background.background_type)
101
+
102
+ # Calculate AC (base 10 + dex modifier)
103
+ armor_class = 10 + stats.dexterity_modifier
104
+
105
+ # Create character
106
+ character = Character(
107
+ id=character_id,
108
+ name=name,
109
+ race=race,
110
+ character_class=character_class,
111
+ level=level,
112
+ alignment=alignment,
113
+ stats=stats,
114
+ max_hit_points=max_hp,
115
+ current_hit_points=max_hp,
116
+ armor_class=armor_class,
117
+ background=background,
118
+ equipment=equipment,
119
+ features=features,
120
+ proficiencies=proficiencies,
121
+ )
122
+
123
+ # Validate character
124
+ is_valid, errors = validate_character(character)
125
+ if not is_valid:
126
+ raise ValueError(f"Character validation failed: {', '.join(errors)}")
127
+
128
+ # Save to database
129
+ self.save_character(character)
130
+
131
+ return character
132
+
133
+ def generate_name(
134
+ self,
135
+ race: Optional[DnDRace] = None,
136
+ character_class: Optional[DnDClass] = None,
137
+ gender: Optional[str] = None
138
+ ) -> str:
139
+ """
140
+ PUBLIC method to generate character name using AI
141
+ Can be called independently of character creation
142
+
143
+ Args:
144
+ race: Character race (optional)
145
+ character_class: Character class (optional)
146
+ gender: Character gender (optional)
147
+
148
+ Returns:
149
+ Generated character name
150
+ """
151
+ gender_text = f"\nGender: {gender}" if gender and gender != "Not specified" else ""
152
+
153
+ prompt = f"""Generate a single fantasy character name for a D&D character.
154
+
155
+ Race: {race.value if race else 'any race'}
156
+ Class: {character_class.value if character_class else 'any class'}{gender_text}
157
+
158
+ Requirements:
159
+ - Just the name, nothing else
160
+ - Make it sound appropriate for the race{' and gender' if gender_text else ''}
161
+ - Make it memorable and fitting for an adventurer
162
+ - 2-3 words maximum
163
+
164
+ Example formats:
165
+ - "Thorin Ironforge" (Male Dwarf)
166
+ - "Elara Moonwhisper" (Female Elf)
167
+ - "Grunk Bonecrusher" (Male Orc)
168
+ - "Pip Thornberry" (Any Halfling)
169
+
170
+ Generate only the name:"""
171
+
172
+ try:
173
+ name = self.ai_client.generate_creative(prompt).strip()
174
+ # Clean up any extra text
175
+ name = name.split('\n')[0].strip('"\'')
176
+ return name
177
+ except Exception as e:
178
+ # Fallback to simple name generation
179
+ import random
180
+ prefixes = ["Brave", "Bold", "Swift", "Wise", "Dark", "Bright"]
181
+ suffixes = ["blade", "heart", "forge", "walker", "runner", "seeker"]
182
+ return f"{random.choice(prefixes)}{random.choice(suffixes)}"
183
+
184
+ def _generate_name(self, race: Optional[DnDRace], character_class: Optional[DnDClass]) -> str:
185
+ """
186
+ PRIVATE method - calls public generate_name
187
+ Kept for backward compatibility
188
+ """
189
+ return self.generate_name(race, character_class)
190
+
191
+ def _select_random_race(self) -> DnDRace:
192
+ """Select random race"""
193
+ import random
194
+ return random.choice(list(DnDRace))
195
+
196
+ def _select_random_class(self) -> DnDClass:
197
+ """Select random class"""
198
+ import random
199
+ return random.choice(list(DnDClass))
200
+
201
+ def _generate_stats(self, method: str) -> CharacterStats:
202
+ """Generate ability scores"""
203
+ if method == "roll":
204
+ # Roll 4d6 drop lowest
205
+ stats_dict = self.dice_roller.roll_stats()
206
+ return CharacterStats(**stats_dict)
207
+
208
+ elif method == "standard_array":
209
+ # Standard array: 15, 14, 13, 12, 10, 8
210
+ import random
211
+ array = [15, 14, 13, 12, 10, 8]
212
+ random.shuffle(array)
213
+ return CharacterStats(
214
+ strength=array[0],
215
+ dexterity=array[1],
216
+ constitution=array[2],
217
+ intelligence=array[3],
218
+ wisdom=array[4],
219
+ charisma=array[5]
220
+ )
221
+
222
+ elif method == "point_buy":
223
+ # Balanced point buy (27 points)
224
+ return CharacterStats(
225
+ strength=13,
226
+ dexterity=14,
227
+ constitution=13,
228
+ intelligence=12,
229
+ wisdom=10,
230
+ charisma=10
231
+ )
232
+
233
+ else:
234
+ # Default to standard array
235
+ return CharacterStats()
236
+
237
+ def _apply_racial_bonuses(self, stats: CharacterStats, race: DnDRace) -> CharacterStats:
238
+ """Apply racial ability score increases per D&D 5e PHB"""
239
+ racial_bonuses = {
240
+ DnDRace.HUMAN: {"strength": 1, "dexterity": 1, "constitution": 1,
241
+ "intelligence": 1, "wisdom": 1, "charisma": 1},
242
+ DnDRace.ELF: {"dexterity": 2},
243
+ DnDRace.DWARF: {"constitution": 2},
244
+ DnDRace.HALFLING: {"dexterity": 2},
245
+ DnDRace.DRAGONBORN: {"strength": 2, "charisma": 1},
246
+ DnDRace.GNOME: {"intelligence": 2},
247
+ DnDRace.HALF_ELF: {"charisma": 2}, # +1 to two highest (auto-assigned)
248
+ DnDRace.HALF_ORC: {"strength": 2, "constitution": 1},
249
+ DnDRace.TIEFLING: {"charisma": 2, "intelligence": 1},
250
+ DnDRace.DROW: {"dexterity": 2, "charisma": 1},
251
+ }
252
+
253
+ bonuses = racial_bonuses.get(race, {})
254
+ stats_dict = stats.model_dump()
255
+
256
+ # Apply bonuses, capping at 20 per D&D 5e standard rules
257
+ for ability, bonus in bonuses.items():
258
+ stats_dict[ability] = min(20, stats_dict[ability] + bonus)
259
+
260
+ # Half-Elf special case: +1 to two highest abilities after CHA
261
+ if race == DnDRace.HALF_ELF:
262
+ # Find two highest abilities (excluding charisma which already got +2)
263
+ abilities_except_cha = [(k, v) for k, v in stats_dict.items() if k != "charisma"]
264
+ abilities_except_cha.sort(key=lambda x: x[1], reverse=True)
265
+
266
+ # Apply +1 to top two (capped at 20)
267
+ for i in range(min(2, len(abilities_except_cha))):
268
+ ability_name = abilities_except_cha[i][0]
269
+ stats_dict[ability_name] = min(20, stats_dict[ability_name] + 1)
270
+
271
+ return CharacterStats(**stats_dict)
272
+
273
+ def _calculate_starting_hp(self, character_class: DnDClass, con_modifier: int, level: int) -> int:
274
+ """Calculate starting hit points (D&D 5e rules)"""
275
+ hit_die = HIT_DICE_BY_CLASS.get(character_class, 8)
276
+
277
+ # First level: max hit die + con mod
278
+ # Subsequent levels: average of hit die + con mod
279
+ first_level_hp = hit_die + con_modifier
280
+ subsequent_hp = ((hit_die // 2) + 1 + con_modifier) * (level - 1)
281
+
282
+ return max(1, first_level_hp + subsequent_hp)
283
+
284
+ def _generate_background(
285
+ self,
286
+ name: str,
287
+ race: DnDRace,
288
+ character_class: DnDClass,
289
+ background_type: Optional[str],
290
+ personality_prompt: Optional[str]
291
+ ) -> CharacterBackground:
292
+ """Generate character background and personality using AI"""
293
+ system_prompt = """You are a D&D character background generator.
294
+ Create compelling, detailed character backgrounds that feel authentic and provide hooks for roleplay.
295
+ Be creative but grounded in D&D lore."""
296
+
297
+ prompt = f"""Generate a complete character background for:
298
+
299
+ Name: {name}
300
+ Race: {race.value}
301
+ Class: {character_class.value}
302
+ Background Type: {background_type or 'Adventurer'}
303
+ {f'Additional guidance: {personality_prompt}' if personality_prompt else ''}
304
+
305
+ Generate in this EXACT format:
306
+
307
+ PERSONALITY TRAITS:
308
+ - [trait 1]
309
+ - [trait 2]
310
+
311
+ IDEALS:
312
+ [One core ideal that drives them]
313
+
314
+ BONDS:
315
+ [What/who they care about most]
316
+
317
+ FLAWS:
318
+ [A meaningful character flaw]
319
+
320
+ BACKSTORY:
321
+ [2-3 paragraphs of compelling backstory that explains how they became an adventurer]
322
+
323
+ GOALS:
324
+ - [goal 1]
325
+ - [goal 2]
326
+
327
+ Keep it concise but evocative. Focus on what makes this character interesting to play."""
328
+
329
+ response = self.ai_client.generate_creative(prompt, system_prompt=system_prompt)
330
+
331
+ # Parse response
332
+ background = self._parse_background_response(response, background_type or "Adventurer")
333
+
334
+ return background
335
+
336
+ def _parse_background_response(self, response: str, background_type: str) -> CharacterBackground:
337
+ """Parse AI response into CharacterBackground"""
338
+ lines = response.strip().split('\n')
339
+
340
+ traits = []
341
+ ideals = ""
342
+ bonds = ""
343
+ flaws = ""
344
+ backstory = ""
345
+ goals = []
346
+
347
+ current_section = None
348
+ backstory_lines = []
349
+
350
+ for line in lines:
351
+ line = line.strip()
352
+ if not line:
353
+ continue
354
+
355
+ if line.startswith('PERSONALITY TRAITS:'):
356
+ current_section = 'traits'
357
+ elif line.startswith('IDEALS:'):
358
+ current_section = 'ideals'
359
+ elif line.startswith('BONDS:'):
360
+ current_section = 'bonds'
361
+ elif line.startswith('FLAWS:'):
362
+ current_section = 'flaws'
363
+ elif line.startswith('BACKSTORY:'):
364
+ current_section = 'backstory'
365
+ elif line.startswith('GOALS:'):
366
+ current_section = 'goals'
367
+ elif line.startswith('-'):
368
+ content = line[1:].strip()
369
+ if current_section == 'traits':
370
+ traits.append(content)
371
+ elif current_section == 'goals':
372
+ goals.append(content)
373
+ else:
374
+ if current_section == 'ideals':
375
+ ideals += line + " "
376
+ elif current_section == 'bonds':
377
+ bonds += line + " "
378
+ elif current_section == 'flaws':
379
+ flaws += line + " "
380
+ elif current_section == 'backstory':
381
+ backstory_lines.append(line)
382
+
383
+ backstory = '\n'.join(backstory_lines).strip()
384
+
385
+ return CharacterBackground(
386
+ background_type=background_type,
387
+ personality_traits=traits[:3], # Max 3 traits
388
+ ideals=ideals.strip(),
389
+ bonds=bonds.strip(),
390
+ flaws=flaws.strip(),
391
+ backstory=backstory,
392
+ goals=goals
393
+ )
394
+
395
+ def _determine_alignment(self, background: CharacterBackground) -> Alignment:
396
+ """Determine alignment based on personality"""
397
+ # Simple heuristic based on ideals and traits
398
+ ideals_lower = background.ideals.lower()
399
+
400
+ if 'law' in ideals_lower or 'order' in ideals_lower or 'honor' in ideals_lower:
401
+ if 'help' in ideals_lower or 'good' in ideals_lower or 'kind' in ideals_lower:
402
+ return Alignment.LAWFUL_GOOD
403
+ elif 'evil' in ideals_lower or 'power' in ideals_lower:
404
+ return Alignment.LAWFUL_EVIL
405
+ else:
406
+ return Alignment.LAWFUL_NEUTRAL
407
+
408
+ elif 'chaos' in ideals_lower or 'freedom' in ideals_lower:
409
+ if 'help' in ideals_lower or 'good' in ideals_lower:
410
+ return Alignment.CHAOTIC_GOOD
411
+ elif 'evil' in ideals_lower or 'selfish' in ideals_lower:
412
+ return Alignment.CHAOTIC_EVIL
413
+ else:
414
+ return Alignment.CHAOTIC_NEUTRAL
415
+
416
+ else:
417
+ if 'help' in ideals_lower or 'good' in ideals_lower:
418
+ return Alignment.NEUTRAL_GOOD
419
+ elif 'evil' in ideals_lower:
420
+ return Alignment.NEUTRAL_EVIL
421
+ else:
422
+ return Alignment.TRUE_NEUTRAL
423
+
424
+ def _get_starting_equipment(self, character_class: DnDClass) -> list:
425
+ """Get starting equipment for class"""
426
+ equipment_by_class = {
427
+ DnDClass.FIGHTER: ["Longsword", "Shield", "Chain Mail", "Explorer's Pack"],
428
+ DnDClass.WIZARD: ["Spellbook", "Quarterstaff", "Component Pouch", "Scholar's Pack"],
429
+ DnDClass.ROGUE: ["Shortbow", "Arrows (20)", "Leather Armor", "Thieves' Tools", "Burglar's Pack"],
430
+ DnDClass.CLERIC: ["Mace", "Scale Mail", "Holy Symbol", "Priest's Pack"],
431
+ DnDClass.RANGER: ["Longbow", "Arrows (20)", "Leather Armor", "Explorer's Pack"],
432
+ DnDClass.PALADIN: ["Longsword", "Shield", "Chain Mail", "Holy Symbol", "Priest's Pack"],
433
+ DnDClass.BARD: ["Rapier", "Lute", "Leather Armor", "Entertainer's Pack"],
434
+ DnDClass.BARBARIAN: ["Greataxe", "Javelin (4)", "Explorer's Pack"],
435
+ DnDClass.DRUID: ["Quarterstaff", "Leather Armor", "Druidic Focus", "Explorer's Pack"],
436
+ DnDClass.MONK: ["Shortsword", "Dart (10)", "Explorer's Pack"],
437
+ DnDClass.SORCERER: ["Dagger (2)", "Component Pouch", "Dungeoneer's Pack"],
438
+ DnDClass.WARLOCK: ["Crossbow", "Bolts (20)", "Leather Armor", "Component Pouch", "Scholar's Pack"],
439
+ }
440
+
441
+ return equipment_by_class.get(character_class, ["Basic Equipment"])
442
+
443
+ def _get_class_features(self, character_class: DnDClass, level: int) -> list:
444
+ """Get class features for level (D&D 5e PHB)"""
445
+ # Features by class and level
446
+ features_by_level = {
447
+ DnDClass.FIGHTER: {
448
+ 1: ["Fighting Style", "Second Wind"],
449
+ 2: ["Action Surge (1 use)"],
450
+ 3: ["Martial Archetype"],
451
+ 4: ["Ability Score Improvement"],
452
+ 5: ["Extra Attack (1)"],
453
+ 6: ["Ability Score Improvement"],
454
+ 7: ["Martial Archetype Feature"],
455
+ 8: ["Ability Score Improvement"],
456
+ 9: ["Indomitable (1 use)"],
457
+ 10: ["Martial Archetype Feature"],
458
+ 11: ["Extra Attack (2)"],
459
+ 12: ["Ability Score Improvement"],
460
+ 13: ["Indomitable (2 uses)"],
461
+ 14: ["Ability Score Improvement"],
462
+ 15: ["Martial Archetype Feature"],
463
+ 16: ["Ability Score Improvement"],
464
+ 17: ["Action Surge (2 uses)", "Indomitable (3 uses)"],
465
+ 18: ["Martial Archetype Feature"],
466
+ 19: ["Ability Score Improvement"],
467
+ 20: ["Extra Attack (3)"],
468
+ },
469
+ DnDClass.WIZARD: {
470
+ 1: ["Spellcasting", "Arcane Recovery"],
471
+ 2: ["Arcane Tradition"],
472
+ 3: [],
473
+ 4: ["Ability Score Improvement"],
474
+ 5: [],
475
+ 6: ["Arcane Tradition Feature"],
476
+ 7: [],
477
+ 8: ["Ability Score Improvement"],
478
+ 9: [],
479
+ 10: ["Arcane Tradition Feature"],
480
+ 11: [],
481
+ 12: ["Ability Score Improvement"],
482
+ 13: [],
483
+ 14: ["Arcane Tradition Feature"],
484
+ 15: [],
485
+ 16: ["Ability Score Improvement"],
486
+ 17: [],
487
+ 18: ["Spell Mastery"],
488
+ 19: ["Ability Score Improvement"],
489
+ 20: ["Signature Spells"],
490
+ },
491
+ DnDClass.ROGUE: {
492
+ 1: ["Expertise", "Sneak Attack (1d6)", "Thieves' Cant"],
493
+ 2: ["Cunning Action"],
494
+ 3: ["Sneak Attack (2d6)", "Roguish Archetype"],
495
+ 4: ["Ability Score Improvement"],
496
+ 5: ["Sneak Attack (3d6)", "Uncanny Dodge"],
497
+ 6: ["Expertise"],
498
+ 7: ["Sneak Attack (4d6)", "Evasion"],
499
+ 8: ["Ability Score Improvement"],
500
+ 9: ["Sneak Attack (5d6)", "Roguish Archetype Feature"],
501
+ 10: ["Ability Score Improvement"],
502
+ 11: ["Sneak Attack (6d6)", "Reliable Talent"],
503
+ 12: ["Ability Score Improvement"],
504
+ 13: ["Sneak Attack (7d6)", "Roguish Archetype Feature"],
505
+ 14: ["Blindsense"],
506
+ 15: ["Sneak Attack (8d6)", "Slippery Mind"],
507
+ 16: ["Ability Score Improvement"],
508
+ 17: ["Sneak Attack (9d6)", "Roguish Archetype Feature"],
509
+ 18: ["Elusive"],
510
+ 19: ["Sneak Attack (10d6)", "Ability Score Improvement"],
511
+ 20: ["Stroke of Luck"],
512
+ },
513
+ DnDClass.CLERIC: {
514
+ 1: ["Spellcasting", "Divine Domain"],
515
+ 2: ["Channel Divinity (1/rest)", "Divine Domain Feature"],
516
+ 3: [],
517
+ 4: ["Ability Score Improvement"],
518
+ 5: ["Destroy Undead (CR 1/2)"],
519
+ 6: ["Channel Divinity (2/rest)", "Divine Domain Feature"],
520
+ 7: [],
521
+ 8: ["Ability Score Improvement", "Destroy Undead (CR 1)", "Divine Domain Feature"],
522
+ 9: [],
523
+ 10: ["Divine Intervention"],
524
+ 11: ["Destroy Undead (CR 2)"],
525
+ 12: ["Ability Score Improvement"],
526
+ 13: [],
527
+ 14: ["Destroy Undead (CR 3)"],
528
+ 15: [],
529
+ 16: ["Ability Score Improvement"],
530
+ 17: ["Destroy Undead (CR 4)", "Divine Domain Feature"],
531
+ 18: ["Channel Divinity (3/rest)"],
532
+ 19: ["Ability Score Improvement"],
533
+ 20: ["Divine Intervention Improvement"],
534
+ },
535
+ DnDClass.RANGER: {
536
+ 1: ["Favored Enemy", "Natural Explorer"],
537
+ 2: ["Fighting Style", "Spellcasting"],
538
+ 3: ["Ranger Archetype", "Primeval Awareness"],
539
+ 4: ["Ability Score Improvement"],
540
+ 5: ["Extra Attack"],
541
+ 6: ["Favored Enemy Improvement", "Natural Explorer Improvement"],
542
+ 7: ["Ranger Archetype Feature"],
543
+ 8: ["Ability Score Improvement", "Land's Stride"],
544
+ 9: [],
545
+ 10: ["Natural Explorer Improvement", "Hide in Plain Sight"],
546
+ 11: ["Ranger Archetype Feature"],
547
+ 12: ["Ability Score Improvement"],
548
+ 13: [],
549
+ 14: ["Favored Enemy Improvement", "Vanish"],
550
+ 15: ["Ranger Archetype Feature"],
551
+ 16: ["Ability Score Improvement"],
552
+ 17: [],
553
+ 18: ["Feral Senses"],
554
+ 19: ["Ability Score Improvement"],
555
+ 20: ["Foe Slayer"],
556
+ },
557
+ DnDClass.PALADIN: {
558
+ 1: ["Divine Sense", "Lay on Hands"],
559
+ 2: ["Fighting Style", "Spellcasting", "Divine Smite"],
560
+ 3: ["Divine Health", "Sacred Oath"],
561
+ 4: ["Ability Score Improvement"],
562
+ 5: ["Extra Attack"],
563
+ 6: ["Aura of Protection"],
564
+ 7: ["Sacred Oath Feature"],
565
+ 8: ["Ability Score Improvement"],
566
+ 9: [],
567
+ 10: ["Aura of Courage"],
568
+ 11: ["Improved Divine Smite"],
569
+ 12: ["Ability Score Improvement"],
570
+ 13: [],
571
+ 14: ["Cleansing Touch"],
572
+ 15: ["Sacred Oath Feature"],
573
+ 16: ["Ability Score Improvement"],
574
+ 17: [],
575
+ 18: ["Aura Improvements"],
576
+ 19: ["Ability Score Improvement"],
577
+ 20: ["Sacred Oath Feature"],
578
+ },
579
+ DnDClass.BARD: {
580
+ 1: ["Spellcasting", "Bardic Inspiration (d6)"],
581
+ 2: ["Jack of All Trades", "Song of Rest (d6)"],
582
+ 3: ["Bard College", "Expertise"],
583
+ 4: ["Ability Score Improvement"],
584
+ 5: ["Bardic Inspiration (d8)", "Font of Inspiration"],
585
+ 6: ["Countercharm", "Bard College Feature"],
586
+ 7: [],
587
+ 8: ["Ability Score Improvement"],
588
+ 9: ["Song of Rest (d8)"],
589
+ 10: ["Bardic Inspiration (d10)", "Expertise", "Magical Secrets"],
590
+ 11: [],
591
+ 12: ["Ability Score Improvement"],
592
+ 13: ["Song of Rest (d10)"],
593
+ 14: ["Magical Secrets", "Bard College Feature"],
594
+ 15: ["Bardic Inspiration (d12)"],
595
+ 16: ["Ability Score Improvement"],
596
+ 17: ["Song of Rest (d12)"],
597
+ 18: ["Magical Secrets"],
598
+ 19: ["Ability Score Improvement"],
599
+ 20: ["Superior Inspiration"],
600
+ },
601
+ DnDClass.BARBARIAN: {
602
+ 1: ["Rage (2/day)", "Unarmored Defense"],
603
+ 2: ["Reckless Attack", "Danger Sense"],
604
+ 3: ["Primal Path", "Rage (3/day)"],
605
+ 4: ["Ability Score Improvement"],
606
+ 5: ["Extra Attack", "Fast Movement"],
607
+ 6: ["Path Feature", "Rage (4/day)"],
608
+ 7: ["Feral Instinct"],
609
+ 8: ["Ability Score Improvement"],
610
+ 9: ["Brutal Critical (1 die)"],
611
+ 10: ["Path Feature", "Rage (5/day)"],
612
+ 11: ["Relentless Rage"],
613
+ 12: ["Ability Score Improvement", "Rage (6/day)"],
614
+ 13: ["Brutal Critical (2 dice)"],
615
+ 14: ["Path Feature"],
616
+ 15: ["Persistent Rage"],
617
+ 16: ["Ability Score Improvement"],
618
+ 17: ["Brutal Critical (3 dice)", "Rage (Unlimited)"],
619
+ 18: ["Indomitable Might"],
620
+ 19: ["Ability Score Improvement"],
621
+ 20: ["Primal Champion"],
622
+ },
623
+ DnDClass.DRUID: {
624
+ 1: ["Druidic", "Spellcasting"],
625
+ 2: ["Wild Shape", "Druid Circle"],
626
+ 3: [],
627
+ 4: ["Wild Shape Improvement", "Ability Score Improvement"],
628
+ 5: [],
629
+ 6: ["Druid Circle Feature"],
630
+ 7: [],
631
+ 8: ["Wild Shape Improvement", "Ability Score Improvement"],
632
+ 9: [],
633
+ 10: ["Druid Circle Feature"],
634
+ 11: [],
635
+ 12: ["Ability Score Improvement"],
636
+ 13: [],
637
+ 14: ["Druid Circle Feature"],
638
+ 15: [],
639
+ 16: ["Ability Score Improvement"],
640
+ 17: [],
641
+ 18: ["Timeless Body", "Beast Spells"],
642
+ 19: ["Ability Score Improvement"],
643
+ 20: ["Archdruid"],
644
+ },
645
+ DnDClass.MONK: {
646
+ 1: ["Unarmored Defense", "Martial Arts (1d4)"],
647
+ 2: ["Ki", "Unarmored Movement"],
648
+ 3: ["Monastic Tradition", "Deflect Missiles"],
649
+ 4: ["Ability Score Improvement", "Slow Fall"],
650
+ 5: ["Extra Attack", "Stunning Strike", "Martial Arts (1d6)"],
651
+ 6: ["Ki-Empowered Strikes", "Monastic Tradition Feature"],
652
+ 7: ["Evasion", "Stillness of Mind"],
653
+ 8: ["Ability Score Improvement"],
654
+ 9: ["Unarmored Movement Improvement"],
655
+ 10: ["Purity of Body"],
656
+ 11: ["Monastic Tradition Feature", "Martial Arts (1d8)"],
657
+ 12: ["Ability Score Improvement"],
658
+ 13: ["Tongue of the Sun and Moon"],
659
+ 14: ["Diamond Soul"],
660
+ 15: ["Timeless Body"],
661
+ 16: ["Ability Score Improvement"],
662
+ 17: ["Monastic Tradition Feature", "Martial Arts (1d10)"],
663
+ 18: ["Empty Body"],
664
+ 19: ["Ability Score Improvement"],
665
+ 20: ["Perfect Self"],
666
+ },
667
+ DnDClass.SORCERER: {
668
+ 1: ["Spellcasting", "Sorcerous Origin"],
669
+ 2: ["Font of Magic"],
670
+ 3: ["Metamagic (2 options)"],
671
+ 4: ["Ability Score Improvement"],
672
+ 5: [],
673
+ 6: ["Sorcerous Origin Feature"],
674
+ 7: [],
675
+ 8: ["Ability Score Improvement"],
676
+ 9: [],
677
+ 10: ["Metamagic (3 options)"],
678
+ 11: [],
679
+ 12: ["Ability Score Improvement"],
680
+ 13: [],
681
+ 14: ["Sorcerous Origin Feature"],
682
+ 15: [],
683
+ 16: ["Ability Score Improvement"],
684
+ 17: ["Metamagic (4 options)"],
685
+ 18: ["Sorcerous Origin Feature"],
686
+ 19: ["Ability Score Improvement"],
687
+ 20: ["Sorcerous Restoration"],
688
+ },
689
+ DnDClass.WARLOCK: {
690
+ 1: ["Otherworldly Patron", "Pact Magic"],
691
+ 2: ["Eldritch Invocations (2)"],
692
+ 3: ["Pact Boon"],
693
+ 4: ["Ability Score Improvement"],
694
+ 5: ["Eldritch Invocations (3)"],
695
+ 6: ["Otherworldly Patron Feature"],
696
+ 7: ["Eldritch Invocations (4)"],
697
+ 8: ["Ability Score Improvement"],
698
+ 9: ["Eldritch Invocations (5)"],
699
+ 10: ["Otherworldly Patron Feature"],
700
+ 11: ["Mystic Arcanum (6th level)"],
701
+ 12: ["Ability Score Improvement", "Eldritch Invocations (6)"],
702
+ 13: ["Mystic Arcanum (7th level)"],
703
+ 14: ["Otherworldly Patron Feature"],
704
+ 15: ["Mystic Arcanum (8th level)", "Eldritch Invocations (7)"],
705
+ 16: ["Ability Score Improvement"],
706
+ 17: ["Mystic Arcanum (9th level)"],
707
+ 18: ["Eldritch Invocations (8)"],
708
+ 19: ["Ability Score Improvement"],
709
+ 20: ["Eldritch Master"],
710
+ },
711
+ }
712
+
713
+ # Get features for this class up to the current level
714
+ class_features_by_level = features_by_level.get(character_class, {})
715
+ all_features = []
716
+
717
+ for lvl in range(1, min(level + 1, 21)):
718
+ all_features.extend(class_features_by_level.get(lvl, []))
719
+
720
+ return all_features if all_features else ["Class Features"]
721
+
722
+ def _get_proficiencies(self, character_class: DnDClass, background: str) -> list:
723
+ """
724
+ Get proficiencies - includes fixed proficiencies and notes about choices
725
+ Players need to make skill choices in D&D 5e
726
+ """
727
+ # Saving throw proficiencies (FIXED - no choice)
728
+ saves = {
729
+ DnDClass.FIGHTER: ["Saving Throws: Strength, Constitution"],
730
+ DnDClass.WIZARD: ["Saving Throws: Intelligence, Wisdom"],
731
+ DnDClass.ROGUE: ["Saving Throws: Dexterity, Intelligence"],
732
+ DnDClass.CLERIC: ["Saving Throws: Wisdom, Charisma"],
733
+ DnDClass.RANGER: ["Saving Throws: Strength, Dexterity"],
734
+ DnDClass.PALADIN: ["Saving Throws: Wisdom, Charisma"],
735
+ DnDClass.BARD: ["Saving Throws: Dexterity, Charisma"],
736
+ DnDClass.BARBARIAN: ["Saving Throws: Strength, Constitution"],
737
+ DnDClass.DRUID: ["Saving Throws: Intelligence, Wisdom"],
738
+ DnDClass.MONK: ["Saving Throws: Strength, Dexterity"],
739
+ DnDClass.SORCERER: ["Saving Throws: Constitution, Charisma"],
740
+ DnDClass.WARLOCK: ["Saving Throws: Wisdom, Charisma"],
741
+ }
742
+
743
+ # Armor and weapon proficiencies (FIXED - no choice)
744
+ armor_weapons = {
745
+ DnDClass.FIGHTER: ["All armor", "All shields", "Simple weapons", "Martial weapons"],
746
+ DnDClass.WIZARD: ["Weapons: Daggers, Darts, Slings, Quarterstaffs, Light crossbows"],
747
+ DnDClass.ROGUE: ["Light armor", "Weapons: Simple weapons, Hand crossbows, Longswords, Rapiers, Shortswords", "Tools: Thieves' tools"],
748
+ DnDClass.CLERIC: ["Light armor", "Medium armor", "Shields", "Simple weapons"],
749
+ DnDClass.RANGER: ["Light armor", "Medium armor", "Shields", "Simple weapons", "Martial weapons"],
750
+ DnDClass.PALADIN: ["All armor", "All shields", "Simple weapons", "Martial weapons"],
751
+ DnDClass.BARD: ["Light armor", "Weapons: Simple weapons, Hand crossbows, Longswords, Rapiers, Shortswords", "Tools: Three musical instruments of your choice"],
752
+ DnDClass.BARBARIAN: ["Light armor", "Medium armor", "Shields", "Simple weapons", "Martial weapons"],
753
+ DnDClass.DRUID: ["Light armor (nonmetal)", "Medium armor (nonmetal)", "Shields (nonmetal)", "Weapons: Clubs, Daggers, Darts, Javelins, Maces, Quarterstaffs, Scimitars, Sickles, Slings, Spears", "Tools: Herbalism kit"],
754
+ DnDClass.MONK: ["Weapons: Simple weapons, Shortswords", "Tools: Choose one artisan's tool or musical instrument"],
755
+ DnDClass.SORCERER: ["Weapons: Daggers, Darts, Slings, Quarterstaffs, Light crossbows"],
756
+ DnDClass.WARLOCK: ["Light armor", "Simple weapons"],
757
+ }
758
+
759
+ # Skill choices (PLAYER MUST CHOOSE - provide guidance)
760
+ skill_choices = {
761
+ DnDClass.FIGHTER: ["Choose 2 skills from: Acrobatics, Animal Handling, Athletics, History, Insight, Intimidation, Perception, Survival"],
762
+ DnDClass.WIZARD: ["Choose 2 skills from: Arcana, History, Insight, Investigation, Medicine, Religion"],
763
+ DnDClass.ROGUE: ["Choose 4 skills from: Acrobatics, Athletics, Deception, Insight, Intimidation, Investigation, Perception, Performance, Persuasion, Sleight of Hand, Stealth"],
764
+ DnDClass.CLERIC: ["Choose 2 skills from: History, Insight, Medicine, Persuasion, Religion"],
765
+ DnDClass.RANGER: ["Choose 3 skills from: Animal Handling, Athletics, Insight, Investigation, Nature, Perception, Stealth, Survival"],
766
+ DnDClass.PALADIN: ["Choose 2 skills from: Athletics, Insight, Intimidation, Medicine, Persuasion, Religion"],
767
+ DnDClass.BARD: ["Choose any 3 skills"],
768
+ DnDClass.BARBARIAN: ["Choose 2 skills from: Animal Handling, Athletics, Intimidation, Nature, Perception, Survival"],
769
+ DnDClass.DRUID: ["Choose 2 skills from: Arcana, Animal Handling, Insight, Medicine, Nature, Perception, Religion, Survival"],
770
+ DnDClass.MONK: ["Choose 2 skills from: Acrobatics, Athletics, History, Insight, Religion, Stealth"],
771
+ DnDClass.SORCERER: ["Choose 2 skills from: Arcana, Deception, Insight, Intimidation, Persuasion, Religion"],
772
+ DnDClass.WARLOCK: ["Choose 2 skills from: Arcana, Deception, History, Intimidation, Investigation, Nature, Religion"],
773
+ }
774
+
775
+ # Background provides 2 additional skills (VARIES - typical examples provided)
776
+ background_note = f"Background ({background}): Grants 2 additional skill proficiencies (varies by background)"
777
+
778
+ # Combine all proficiencies
779
+ proficiencies = []
780
+ proficiencies.extend(saves.get(character_class, []))
781
+ proficiencies.extend(armor_weapons.get(character_class, []))
782
+ proficiencies.extend(skill_choices.get(character_class, []))
783
+ proficiencies.append(background_note)
784
+
785
+ return proficiencies
786
+
787
+ def save_character(self, character: Character):
788
+ """Save character to database"""
789
+ self.database.save(
790
+ entity_id=character.id,
791
+ entity_type="character",
792
+ data=character.to_dict()
793
+ )
794
+
795
+ def load_character(self, character_id: str) -> Optional[Character]:
796
+ """Load character from database"""
797
+ data = self.database.load(character_id, "character")
798
+ if data:
799
+ return Character(**data)
800
+ return None
801
+
802
+ def list_characters(self) -> list[Character]:
803
+ """List all saved characters"""
804
+ characters_data = self.database.load_all("character")
805
+ return [Character(**data) for data in characters_data]
806
+
807
+ def delete_character(self, character_id: str):
808
+ """Delete character from database"""
809
+ self.database.delete(character_id)
810
+
811
+ def generate_portrait(
812
+ self,
813
+ character: Character,
814
+ style: str = "fantasy art",
815
+ quality: str = "standard",
816
+ provider: str = "auto"
817
+ ) -> tuple[Optional[str], Optional[str]]:
818
+ """
819
+ Generate character portrait using DALL-E 3 or HuggingFace
820
+
821
+ Args:
822
+ character: Character to generate portrait for
823
+ style: Art style (e.g., "fantasy art", "digital painting", "anime")
824
+ quality: Image quality ("standard" or "hd") - OpenAI only
825
+ provider: "openai", "huggingface", or "auto"
826
+
827
+ Returns:
828
+ Tuple of (file_path, status_message)
829
+ """
830
+ if not self.image_generator:
831
+ return None, "❌ Image generation not available (API key required)"
832
+
833
+ try:
834
+ file_path, image_bytes = self.image_generator.generate_character_portrait(
835
+ character=character,
836
+ style=style,
837
+ quality=quality,
838
+ provider=provider
839
+ )
840
+
841
+ if file_path:
842
+ return file_path, f"βœ… Portrait generated successfully!\nSaved to: {file_path}"
843
+ else:
844
+ return None, "❌ Failed to generate portrait"
845
+
846
+ except Exception as e:
847
+ return None, f"❌ Error generating portrait: {str(e)}"
848
+
849
+ def get_portrait_path(self, character_id: str) -> Optional[str]:
850
+ """Get saved portrait path for character"""
851
+ if not self.image_generator:
852
+ return None
853
+
854
+ return self.image_generator.get_portrait_path(character_id)
src/config.py ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Configuration management for D'n'D Campaign Manager
3
+ Handles API keys, model settings, and application config
4
+ """
5
+
6
+ import os
7
+ from pathlib import Path
8
+ from typing import Optional
9
+ from pydantic import BaseModel, Field
10
+ from dotenv import load_dotenv
11
+
12
+ # Load environment variables
13
+ load_dotenv()
14
+
15
+ class ModelConfig(BaseModel):
16
+ """Configuration for AI models"""
17
+ # Primary model for generation
18
+ primary_provider: str = Field(default="anthropic", description="anthropic, openai, or google")
19
+ primary_model: str = Field(default="claude-3-haiku-20240307", description="Model name")
20
+
21
+ # Memory model for long context
22
+ memory_provider: str = Field(default="google", description="Provider for campaign memory")
23
+ memory_model: str = Field(default="gemini-2.0-flash-exp", description="2M token context model")
24
+
25
+ # Temperature settings
26
+ creative_temp: float = Field(default=0.9, description="For character/story generation")
27
+ balanced_temp: float = Field(default=0.7, description="For general tasks")
28
+ precise_temp: float = Field(default=0.3, description="For rules/stats")
29
+
30
+ # Token limits
31
+ max_tokens_generation: int = Field(default=2000, description="For content generation")
32
+ max_tokens_memory: int = Field(default=1000000, description="For memory queries")
33
+
34
+
35
+ class DatabaseConfig(BaseModel):
36
+ """Database configuration"""
37
+ db_type: str = Field(default="sqlite", description="Database type")
38
+ db_path: Path = Field(default=Path("data/dnd_campaign_manager.db"), description="SQLite path")
39
+
40
+ def ensure_db_dir(self):
41
+ """Ensure database directory exists"""
42
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
43
+
44
+
45
+ class MCPConfig(BaseModel):
46
+ """MCP Server configuration"""
47
+ server_name: str = Field(default="dnd-campaign-manager", description="MCP server name")
48
+ server_version: str = Field(default="2.0.0", description="MCP server version")
49
+
50
+ # MCP tools to expose
51
+ enable_character_tools: bool = Field(default=True, description="Character creation tools")
52
+ enable_campaign_tools: bool = Field(default=True, description="Campaign management tools")
53
+ enable_npc_tools: bool = Field(default=True, description="NPC generation tools")
54
+ enable_loot_tools: bool = Field(default=True, description="Loot generation tools")
55
+ enable_encounter_tools: bool = Field(default=True, description="Encounter generation tools")
56
+
57
+ # MCP resources to expose
58
+ enable_character_resources: bool = Field(default=True, description="Character data resources")
59
+ enable_campaign_resources: bool = Field(default=True, description="Campaign data resources")
60
+
61
+
62
+ class AppConfig(BaseModel):
63
+ """Application configuration"""
64
+ app_name: str = Field(default="D'n'D Campaign Manager", description="Application name")
65
+ app_version: str = Field(default="2.0.0", description="Application version")
66
+
67
+ # Gradio settings
68
+ gradio_share: bool = Field(default=False, description="Enable Gradio sharing")
69
+ gradio_server_name: str = Field(default="0.0.0.0", description="Server host")
70
+ gradio_server_port: int = Field(default=7860, description="Server port")
71
+
72
+ # Data persistence
73
+ enable_persistence: bool = Field(default=True, description="Save data to database")
74
+ auto_backup: bool = Field(default=True, description="Auto-backup campaigns")
75
+ backup_interval_hours: int = Field(default=24, description="Backup interval")
76
+
77
+ # Feature flags
78
+ enable_image_generation: bool = Field(default=True, description="Character portraits")
79
+ enable_voice_generation: bool = Field(default=False, description="NPC voices (future)")
80
+ enable_music_generation: bool = Field(default=False, description="Background music (future)")
81
+
82
+
83
+ class Config:
84
+ """Main configuration class"""
85
+
86
+ def __init__(self):
87
+ # API Keys
88
+ self.anthropic_api_key: Optional[str] = os.getenv("ANTHROPIC_API_KEY")
89
+ self.openai_api_key: Optional[str] = os.getenv("OPENAI_API_KEY")
90
+ self.google_api_key: Optional[str] = os.getenv("GOOGLE_API_KEY")
91
+ self.huggingface_api_key: Optional[str] = os.getenv("HUGGINGFACE_API_KEY")
92
+
93
+ # Sub-configurations
94
+ self.model = ModelConfig()
95
+ self.database = DatabaseConfig()
96
+ self.mcp = MCPConfig()
97
+ self.app = AppConfig()
98
+
99
+ # Validate configuration
100
+ self._validate()
101
+
102
+ def _validate(self):
103
+ """Validate configuration and API keys"""
104
+ if not self.anthropic_api_key and self.model.primary_provider == "anthropic":
105
+ raise ValueError("ANTHROPIC_API_KEY not found in environment")
106
+
107
+ if not self.google_api_key and self.model.memory_provider == "google":
108
+ raise ValueError("GOOGLE_API_KEY not found in environment for memory model")
109
+
110
+ # Ensure database directory exists
111
+ self.database.ensure_db_dir()
112
+
113
+ def get_model_config(self, task_type: str = "balanced") -> dict:
114
+ """Get model configuration for specific task type"""
115
+ temp_map = {
116
+ "creative": self.model.creative_temp,
117
+ "balanced": self.model.balanced_temp,
118
+ "precise": self.model.precise_temp
119
+ }
120
+
121
+ return {
122
+ "model": self.model.primary_model,
123
+ "temperature": temp_map.get(task_type, self.model.balanced_temp),
124
+ "max_tokens": self.model.max_tokens_generation
125
+ }
126
+
127
+ def get_memory_config(self) -> dict:
128
+ """Get configuration for memory/context model"""
129
+ return {
130
+ "model": self.model.memory_model,
131
+ "temperature": self.model.balanced_temp,
132
+ "max_tokens": self.model.max_tokens_memory
133
+ }
134
+
135
+
136
+ # Global configuration instance
137
+ config = Config()
138
+
139
+
140
+ # Export commonly used values
141
+ ANTHROPIC_API_KEY = config.anthropic_api_key
142
+ GOOGLE_API_KEY = config.google_api_key
143
+ OPENAI_API_KEY = config.openai_api_key
144
+ HUGGINGFACE_API_KEY = config.huggingface_api_key
145
+
146
+ DB_PATH = config.database.db_path
147
+ MCP_SERVER_NAME = config.mcp.server_name
148
+
149
+ # Data directories
150
+ DATA_DIR = Path("data")
151
+ CAMPAIGNS_DIR = DATA_DIR / "campaigns"
152
+ CHARACTERS_DIR = DATA_DIR / "characters"
153
+ TEMPLATES_DIR = DATA_DIR / "templates"
154
+
155
+ # Ensure directories exist
156
+ for directory in [DATA_DIR, CAMPAIGNS_DIR, CHARACTERS_DIR, TEMPLATES_DIR]:
157
+ directory.mkdir(parents=True, exist_ok=True)
src/models/__init__.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Data models for D'n'D Campaign Manager
3
+ """
4
+
5
+ from .character import Character, CharacterStats, CharacterBackground
6
+ from .campaign import Campaign, CampaignEvent, CampaignMemory
7
+ from .npc import NPC, NPCPersonality, NPCRelationship
8
+ from .game_objects import Item, Encounter, Location
9
+
10
+ __all__ = [
11
+ "Character",
12
+ "CharacterStats",
13
+ "CharacterBackground",
14
+ "Campaign",
15
+ "CampaignEvent",
16
+ "CampaignMemory",
17
+ "NPC",
18
+ "NPCPersonality",
19
+ "NPCRelationship",
20
+ "Item",
21
+ "Encounter",
22
+ "Location",
23
+ ]
src/models/campaign.py ADDED
@@ -0,0 +1,275 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Campaign and session management data models
3
+ """
4
+
5
+ from datetime import datetime
6
+ from typing import Optional, List, Dict, Any
7
+ from enum import Enum
8
+ from pydantic import BaseModel, Field
9
+
10
+
11
+ class CampaignTheme(str, Enum):
12
+ """Campaign themes"""
13
+ HIGH_FANTASY = "High Fantasy"
14
+ DARK_FANTASY = "Dark Fantasy"
15
+ URBAN_FANTASY = "Urban Fantasy"
16
+ POLITICAL_INTRIGUE = "Political Intrigue"
17
+ HORROR = "Horror"
18
+ EXPLORATION = "Exploration"
19
+ DUNGEON_CRAWL = "Dungeon Crawl"
20
+ CUSTOM = "Custom"
21
+
22
+
23
+ class EventType(str, Enum):
24
+ """Types of campaign events"""
25
+ COMBAT = "Combat"
26
+ SOCIAL = "Social"
27
+ EXPLORATION = "Exploration"
28
+ DISCOVERY = "Discovery"
29
+ PLOT_DEVELOPMENT = "Plot Development"
30
+ CHARACTER_MOMENT = "Character Moment"
31
+ NPC_INTERACTION = "NPC Interaction"
32
+ QUEST_UPDATE = "Quest Update"
33
+
34
+
35
+ class CampaignEvent(BaseModel):
36
+ """Individual campaign event/memory"""
37
+ id: Optional[str] = Field(default=None, description="Event ID")
38
+ campaign_id: str = Field(description="Campaign this event belongs to")
39
+ session_number: int = Field(ge=1, description="Session number")
40
+
41
+ event_type: EventType = Field(description="Type of event")
42
+ title: str = Field(min_length=1, max_length=200, description="Event title")
43
+ description: str = Field(description="Full event description")
44
+
45
+ # Participants
46
+ characters_involved: List[str] = Field(default_factory=list, description="Character IDs")
47
+ npcs_involved: List[str] = Field(default_factory=list, description="NPC IDs")
48
+ locations: List[str] = Field(default_factory=list, description="Location names")
49
+
50
+ # Context
51
+ consequences: List[str] = Field(default_factory=list, description="Event consequences")
52
+ items_gained: List[str] = Field(default_factory=list, description="Items acquired")
53
+ items_lost: List[str] = Field(default_factory=list, description="Items lost")
54
+ experience_awarded: int = Field(ge=0, default=0, description="XP awarded")
55
+
56
+ # Metadata
57
+ timestamp: datetime = Field(default_factory=datetime.now)
58
+ importance: int = Field(ge=1, le=5, default=3, description="Event importance (1-5)")
59
+ tags: List[str] = Field(default_factory=list, description="Event tags")
60
+
61
+ # GM notes
62
+ gm_notes: str = Field(default="", description="Private GM notes")
63
+ player_visible: bool = Field(default=True, description="Visible to players")
64
+
65
+ def to_markdown(self) -> str:
66
+ """Convert to markdown"""
67
+ return f"""## {self.title}
68
+ **Type:** {self.event_type.value} | **Importance:** {'⭐' * self.importance}
69
+ **Session:** {self.session_number} | **Date:** {self.timestamp.strftime('%Y-%m-%d')}
70
+
71
+ {self.description}
72
+
73
+ **Participants:** {', '.join(self.characters_involved)}
74
+ **NPCs:** {', '.join(self.npcs_involved)}
75
+ **Locations:** {', '.join(self.locations)}
76
+
77
+ **Consequences:**
78
+ {chr(10).join(f"- {c}" for c in self.consequences)}
79
+ """
80
+
81
+
82
+ class CampaignMemory(BaseModel):
83
+ """Searchable campaign memory/context"""
84
+ campaign_id: str = Field(description="Campaign ID")
85
+
86
+ # Full context for AI
87
+ full_context: str = Field(default="", description="Complete campaign context")
88
+
89
+ # Key information
90
+ major_npcs: Dict[str, str] = Field(default_factory=dict, description="NPC name -> description")
91
+ key_locations: Dict[str, str] = Field(default_factory=dict, description="Location -> description")
92
+ active_quests: List[str] = Field(default_factory=list, description="Current quests")
93
+ completed_quests: List[str] = Field(default_factory=list, description="Finished quests")
94
+
95
+ # Story beats
96
+ important_events: List[str] = Field(default_factory=list, description="Key story moments")
97
+ unresolved_threads: List[str] = Field(default_factory=list, description="Plot threads")
98
+ secrets_discovered: List[str] = Field(default_factory=list, description="Revealed secrets")
99
+ secrets_hidden: List[str] = Field(default_factory=list, description="Hidden secrets")
100
+
101
+ # Relationships
102
+ faction_standings: Dict[str, int] = Field(default_factory=dict, description="Faction name -> standing (-100 to 100)")
103
+ npc_relationships: Dict[str, str] = Field(default_factory=dict, description="NPC -> relationship status")
104
+
105
+ # Metadata
106
+ last_updated: datetime = Field(default_factory=datetime.now)
107
+ total_events: int = Field(ge=0, default=0)
108
+
109
+ def add_event_to_memory(self, event: CampaignEvent):
110
+ """Update memory with new event"""
111
+ self.total_events += 1
112
+
113
+ # Add NPCs
114
+ for npc in event.npcs_involved:
115
+ if npc not in self.major_npcs:
116
+ self.major_npcs[npc] = "Recently introduced"
117
+
118
+ # Add locations
119
+ for location in event.locations:
120
+ if location not in self.key_locations:
121
+ self.key_locations[location] = "Visited"
122
+
123
+ # Add to important events if high importance
124
+ if event.importance >= 4:
125
+ self.important_events.append(event.title)
126
+
127
+ self.last_updated = datetime.now()
128
+
129
+
130
+ class Campaign(BaseModel):
131
+ """Complete D&D campaign"""
132
+ # Core identity
133
+ id: Optional[str] = Field(default=None, description="Campaign ID")
134
+ name: str = Field(min_length=1, max_length=100, description="Campaign name")
135
+ theme: CampaignTheme = Field(description="Campaign theme")
136
+
137
+ # Setting
138
+ setting: str = Field(description="Campaign setting description")
139
+ world_name: str = Field(default="", description="World/realm name")
140
+ starting_location: str = Field(default="", description="Starting location")
141
+
142
+ # Campaign info
143
+ summary: str = Field(description="Campaign summary/hook")
144
+ current_arc: str = Field(default="", description="Current story arc")
145
+ level_range: str = Field(default="1-5", description="Expected level range")
146
+
147
+ # Story elements
148
+ main_conflict: str = Field(description="Central conflict")
149
+ key_factions: List[str] = Field(default_factory=list, description="Important factions")
150
+ major_villains: List[str] = Field(default_factory=list, description="Main antagonists")
151
+ central_mysteries: List[str] = Field(default_factory=list, description="Ongoing mysteries")
152
+
153
+ # Characters
154
+ character_ids: List[str] = Field(default_factory=list, description="Player character IDs")
155
+ party_size: int = Field(ge=1, le=10, default=4, description="Expected party size")
156
+
157
+ # Session tracking
158
+ current_session: int = Field(ge=1, default=1, description="Current session number")
159
+ total_sessions: int = Field(ge=0, default=0, description="Total sessions played")
160
+
161
+ # Memory
162
+ memory: CampaignMemory = Field(default=None, description="Campaign memory")
163
+
164
+ # Metadata
165
+ game_master: str = Field(default="", description="GM name")
166
+ created_at: datetime = Field(default_factory=datetime.now)
167
+ updated_at: datetime = Field(default_factory=datetime.now)
168
+ last_session_date: Optional[datetime] = Field(default=None)
169
+
170
+ # Settings
171
+ is_active: bool = Field(default=True, description="Campaign active?")
172
+ homebrew_rules: List[str] = Field(default_factory=list, description="Custom rules")
173
+ notes: str = Field(default="", description="GM notes")
174
+
175
+ def __init__(self, **data):
176
+ super().__init__(**data)
177
+ if self.memory is None:
178
+ self.memory = CampaignMemory(campaign_id=self.id or "")
179
+
180
+ def add_character(self, character_id: str):
181
+ """Add character to campaign"""
182
+ if character_id not in self.character_ids:
183
+ self.character_ids.append(character_id)
184
+ self.updated_at = datetime.now()
185
+
186
+ def remove_character(self, character_id: str):
187
+ """Remove character from campaign"""
188
+ if character_id in self.character_ids:
189
+ self.character_ids.remove(character_id)
190
+ self.updated_at = datetime.now()
191
+
192
+ def start_new_session(self):
193
+ """Start a new session"""
194
+ self.current_session += 1
195
+ self.total_sessions += 1
196
+ self.last_session_date = datetime.now()
197
+ self.updated_at = datetime.now()
198
+
199
+ def add_event(self, event: CampaignEvent):
200
+ """Add event to campaign memory"""
201
+ self.memory.add_event_to_memory(event)
202
+ self.updated_at = datetime.now()
203
+
204
+ def to_context_string(self) -> str:
205
+ """Generate context string for AI"""
206
+ return f"""Campaign: {self.name}
207
+ Theme: {self.theme.value}
208
+ Setting: {self.setting}
209
+
210
+ Main Conflict: {self.main_conflict}
211
+
212
+ Current Arc: {self.current_arc}
213
+ Session: {self.current_session}
214
+
215
+ Key Factions: {', '.join(self.key_factions)}
216
+ Major Villains: {', '.join(self.major_villains)}
217
+
218
+ Active Quests: {', '.join(self.memory.active_quests)}
219
+ Important Events: {', '.join(self.memory.important_events[-5:])} # Last 5 events
220
+
221
+ Party Size: {self.party_size}
222
+ Level Range: {self.level_range}
223
+ """
224
+
225
+ def to_markdown(self) -> str:
226
+ """Generate markdown campaign summary"""
227
+ return f"""# {self.name}
228
+ **{self.theme.value} Campaign**
229
+
230
+ ## Setting
231
+ {self.setting}
232
+
233
+ **World:** {self.world_name}
234
+ **Starting Location:** {self.starting_location}
235
+
236
+ ## Campaign Summary
237
+ {self.summary}
238
+
239
+ ## Main Conflict
240
+ {self.main_conflict}
241
+
242
+ ## Current Story Arc
243
+ {self.current_arc}
244
+
245
+ ## Key Elements
246
+ **Factions:** {', '.join(self.key_factions)}
247
+ **Villains:** {', '.join(self.major_villains)}
248
+ **Mysteries:** {', '.join(self.central_mysteries)}
249
+
250
+ ## Party Information
251
+ **Party Size:** {self.party_size}
252
+ **Level Range:** {self.level_range}
253
+ **Current Session:** {self.current_session}
254
+ **Total Sessions:** {self.total_sessions}
255
+
256
+ ## Campaign Status
257
+ **Active:** {'Yes' if self.is_active else 'No'}
258
+ **Last Session:** {self.last_session_date.strftime('%Y-%m-%d') if self.last_session_date else 'Not started'}
259
+
260
+ ## GM Notes
261
+ {self.notes}
262
+ """
263
+
264
+ class Config:
265
+ json_schema_extra = {
266
+ "example": {
267
+ "name": "The Shattered Crown",
268
+ "theme": "High Fantasy",
269
+ "setting": "A kingdom torn by civil war",
270
+ "summary": "Adventurers must unite the realm",
271
+ "main_conflict": "Succession crisis threatens the kingdom",
272
+ "party_size": 4,
273
+ "level_range": "1-10"
274
+ }
275
+ }
src/models/character.py ADDED
@@ -0,0 +1,285 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Character data models for D&D 5e characters
3
+ """
4
+
5
+ from datetime import datetime
6
+ from typing import Optional, List, Dict, Any
7
+ from enum import Enum
8
+ from pydantic import BaseModel, Field, validator
9
+
10
+
11
+ class DnDRace(str, Enum):
12
+ """D&D 5e races"""
13
+ HUMAN = "Human"
14
+ ELF = "Elf"
15
+ DWARF = "Dwarf"
16
+ HALFLING = "Halfling"
17
+ DRAGONBORN = "Dragonborn"
18
+ GNOME = "Gnome"
19
+ HALF_ELF = "Half-Elf"
20
+ HALF_ORC = "Half-Orc"
21
+ TIEFLING = "Tiefling"
22
+ DROW = "Drow"
23
+
24
+
25
+ class DnDClass(str, Enum):
26
+ """D&D 5e classes"""
27
+ BARBARIAN = "Barbarian"
28
+ BARD = "Bard"
29
+ CLERIC = "Cleric"
30
+ DRUID = "Druid"
31
+ FIGHTER = "Fighter"
32
+ MONK = "Monk"
33
+ PALADIN = "Paladin"
34
+ RANGER = "Ranger"
35
+ ROGUE = "Rogue"
36
+ SORCERER = "Sorcerer"
37
+ WARLOCK = "Warlock"
38
+ WIZARD = "Wizard"
39
+
40
+
41
+ class Alignment(str, Enum):
42
+ """D&D alignments"""
43
+ LAWFUL_GOOD = "Lawful Good"
44
+ NEUTRAL_GOOD = "Neutral Good"
45
+ CHAOTIC_GOOD = "Chaotic Good"
46
+ LAWFUL_NEUTRAL = "Lawful Neutral"
47
+ TRUE_NEUTRAL = "True Neutral"
48
+ CHAOTIC_NEUTRAL = "Chaotic Neutral"
49
+ LAWFUL_EVIL = "Lawful Evil"
50
+ NEUTRAL_EVIL = "Neutral Evil"
51
+ CHAOTIC_EVIL = "Chaotic Evil"
52
+
53
+
54
+ # D&D 5e Hit Dice by Class (shared constant to avoid duplication)
55
+ HIT_DICE_BY_CLASS = {
56
+ DnDClass.BARBARIAN: 12,
57
+ DnDClass.FIGHTER: 10,
58
+ DnDClass.PALADIN: 10,
59
+ DnDClass.RANGER: 10,
60
+ DnDClass.BARD: 8,
61
+ DnDClass.CLERIC: 8,
62
+ DnDClass.DRUID: 8,
63
+ DnDClass.MONK: 8,
64
+ DnDClass.ROGUE: 8,
65
+ DnDClass.WARLOCK: 8,
66
+ DnDClass.SORCERER: 6,
67
+ DnDClass.WIZARD: 6,
68
+ }
69
+
70
+
71
+ class CharacterStats(BaseModel):
72
+ """D&D 5e ability scores and derived stats"""
73
+ strength: int = Field(ge=1, le=20, default=10, description="Strength score (1-20 per D&D 5e standard rules)")
74
+ dexterity: int = Field(ge=1, le=20, default=10, description="Dexterity score (1-20 per D&D 5e standard rules)")
75
+ constitution: int = Field(ge=1, le=20, default=10, description="Constitution score (1-20 per D&D 5e standard rules)")
76
+ intelligence: int = Field(ge=1, le=20, default=10, description="Intelligence score (1-20 per D&D 5e standard rules)")
77
+ wisdom: int = Field(ge=1, le=20, default=10, description="Wisdom score (1-20 per D&D 5e standard rules)")
78
+ charisma: int = Field(ge=1, le=20, default=10, description="Charisma score (1-20 per D&D 5e standard rules)")
79
+
80
+ @property
81
+ def strength_modifier(self) -> int:
82
+ """Calculate strength modifier"""
83
+ return (self.strength - 10) // 2
84
+
85
+ @property
86
+ def dexterity_modifier(self) -> int:
87
+ """Calculate dexterity modifier"""
88
+ return (self.dexterity - 10) // 2
89
+
90
+ @property
91
+ def constitution_modifier(self) -> int:
92
+ """Calculate constitution modifier"""
93
+ return (self.constitution - 10) // 2
94
+
95
+ @property
96
+ def intelligence_modifier(self) -> int:
97
+ """Calculate intelligence modifier"""
98
+ return (self.intelligence - 10) // 2
99
+
100
+ @property
101
+ def wisdom_modifier(self) -> int:
102
+ """Calculate wisdom modifier"""
103
+ return (self.wisdom - 10) // 2
104
+
105
+ @property
106
+ def charisma_modifier(self) -> int:
107
+ """Calculate charisma modifier"""
108
+ return (self.charisma - 10) // 2
109
+
110
+ def get_all_modifiers(self) -> Dict[str, int]:
111
+ """Get all ability modifiers"""
112
+ return {
113
+ "strength": self.strength_modifier,
114
+ "dexterity": self.dexterity_modifier,
115
+ "constitution": self.constitution_modifier,
116
+ "intelligence": self.intelligence_modifier,
117
+ "wisdom": self.wisdom_modifier,
118
+ "charisma": self.charisma_modifier,
119
+ }
120
+
121
+
122
+ class CharacterBackground(BaseModel):
123
+ """Character background and personality"""
124
+ background_type: str = Field(default="Adventurer", description="Background archetype")
125
+ personality_traits: List[str] = Field(default_factory=list, description="2-3 personality traits")
126
+ ideals: str = Field(default="", description="Character's ideals")
127
+ bonds: str = Field(default="", description="Character's bonds")
128
+ flaws: str = Field(default="", description="Character's flaws")
129
+ backstory: str = Field(default="", description="Full backstory")
130
+ goals: List[str] = Field(default_factory=list, description="Character goals")
131
+
132
+
133
+ class Character(BaseModel):
134
+ """Complete D&D 5e character"""
135
+ # Core identity
136
+ id: Optional[str] = Field(default=None, description="Unique character ID")
137
+ name: str = Field(min_length=1, max_length=100, description="Character name")
138
+ race: DnDRace = Field(description="Character race")
139
+ character_class: DnDClass = Field(description="Character class")
140
+ level: int = Field(ge=1, le=20, default=1, description="Character level")
141
+ alignment: Alignment = Field(default=Alignment.TRUE_NEUTRAL)
142
+ gender: Optional[str] = Field(default=None, description="Character gender")
143
+ skin_tone: Optional[str] = Field(default=None, description="Character skin tone/color")
144
+
145
+ # Stats
146
+ stats: CharacterStats = Field(default_factory=CharacterStats)
147
+ max_hit_points: int = Field(ge=1, default=10)
148
+ current_hit_points: int = Field(ge=0, default=10)
149
+ armor_class: int = Field(ge=1, default=10)
150
+ proficiency_bonus: int = Field(ge=2, default=2)
151
+
152
+ # Background & personality
153
+ background: CharacterBackground = Field(default_factory=CharacterBackground)
154
+
155
+ # Equipment & abilities
156
+ equipment: List[str] = Field(default_factory=list, description="Equipment list")
157
+ spells: List[str] = Field(default_factory=list, description="Known spells")
158
+ features: List[str] = Field(default_factory=list, description="Class features")
159
+ proficiencies: List[str] = Field(default_factory=list, description="Skill proficiencies")
160
+
161
+ # Portrait
162
+ portrait_url: Optional[str] = Field(default=None, description="Character portrait URL")
163
+ portrait_prompt: Optional[str] = Field(default=None, description="Prompt used for portrait")
164
+
165
+ # Metadata
166
+ campaign_id: Optional[str] = Field(default=None, description="Associated campaign")
167
+ player_name: Optional[str] = Field(default=None, description="Player's name")
168
+ created_at: datetime = Field(default_factory=datetime.now)
169
+ updated_at: datetime = Field(default_factory=datetime.now)
170
+
171
+ # Additional data
172
+ notes: str = Field(default="", description="GM/player notes")
173
+ experience_points: int = Field(ge=0, default=0)
174
+
175
+ @validator("proficiency_bonus", always=True)
176
+ def calculate_proficiency_bonus(cls, v, values):
177
+ """Calculate proficiency bonus based on level"""
178
+ level = values.get("level", 1)
179
+ return 2 + ((level - 1) // 4)
180
+
181
+ @validator("current_hit_points", always=True)
182
+ def validate_hp(cls, v, values):
183
+ """Ensure current HP doesn't exceed max HP"""
184
+ max_hp = values.get("max_hit_points", 10)
185
+ return min(v, max_hp)
186
+
187
+ def calculate_max_hp(self) -> int:
188
+ """Calculate max HP based on class and level (D&D 5e rules)"""
189
+ hit_die = HIT_DICE_BY_CLASS.get(self.character_class, 8)
190
+ con_mod = self.stats.constitution_modifier
191
+
192
+ # First level: max hit die + con mod (minimum 1)
193
+ first_level_hp = max(1, hit_die + con_mod)
194
+
195
+ # Subsequent levels: average (rounded up) + con mod (minimum 1 per level per D&D 5e)
196
+ hp_per_level = max(1, (hit_die // 2) + 1 + con_mod)
197
+ subsequent_hp = hp_per_level * (self.level - 1)
198
+
199
+ return first_level_hp + subsequent_hp
200
+
201
+ def take_damage(self, damage: int) -> int:
202
+ """Apply damage to character"""
203
+ self.current_hit_points = max(0, self.current_hit_points - damage)
204
+ self.updated_at = datetime.now()
205
+ return self.current_hit_points
206
+
207
+ def heal(self, healing: int) -> int:
208
+ """Heal character"""
209
+ self.current_hit_points = min(self.max_hit_points, self.current_hit_points + healing)
210
+ self.updated_at = datetime.now()
211
+ return self.current_hit_points
212
+
213
+ def level_up(self):
214
+ """Level up the character (D&D 5e rules)"""
215
+ if self.level < 20:
216
+ self.level += 1
217
+ # Recalculate proficiency bonus based on new level
218
+ self.proficiency_bonus = 2 + ((self.level - 1) // 4)
219
+ self.max_hit_points = self.calculate_max_hp()
220
+ self.current_hit_points = self.max_hit_points
221
+ self.updated_at = datetime.now()
222
+
223
+ def to_dict(self) -> Dict[str, Any]:
224
+ """Convert to dictionary"""
225
+ return self.model_dump()
226
+
227
+ def to_markdown(self) -> str:
228
+ """Generate markdown character sheet"""
229
+ return f"""# {self.name}
230
+ **Level {self.level} {self.race.value} {self.character_class.value}**
231
+ *{self.alignment.value}*
232
+
233
+ ## Ability Scores
234
+ - **STR:** {self.stats.strength} ({self.stats.strength_modifier:+d})
235
+ - **DEX:** {self.stats.dexterity} ({self.stats.dexterity_modifier:+d})
236
+ - **CON:** {self.stats.constitution} ({self.stats.constitution_modifier:+d})
237
+ - **INT:** {self.stats.intelligence} ({self.stats.intelligence_modifier:+d})
238
+ - **WIS:** {self.stats.wisdom} ({self.stats.wisdom_modifier:+d})
239
+ - **CHA:** {self.stats.charisma} ({self.stats.charisma_modifier:+d})
240
+
241
+ ## Combat Stats
242
+ - **HP:** {self.current_hit_points}/{self.max_hit_points}
243
+ - **AC:** {self.armor_class}
244
+ - **Proficiency:** +{self.proficiency_bonus}
245
+
246
+ ## Background
247
+ **{self.background.background_type}**
248
+
249
+ {self.background.backstory}
250
+
251
+ ## Personality
252
+ **Traits:** {', '.join(self.background.personality_traits)}
253
+ **Ideals:** {self.background.ideals}
254
+ **Bonds:** {self.background.bonds}
255
+ **Flaws:** {self.background.flaws}
256
+
257
+ ## Equipment
258
+ {chr(10).join(f"- {item}" for item in self.equipment)}
259
+
260
+ ## Notes
261
+ {self.notes}
262
+ """
263
+
264
+ class Config:
265
+ json_schema_extra = {
266
+ "example": {
267
+ "name": "Thorin Ironforge",
268
+ "race": "Dwarf",
269
+ "character_class": "Fighter",
270
+ "level": 3,
271
+ "alignment": "Lawful Good",
272
+ "stats": {
273
+ "strength": 16,
274
+ "dexterity": 12,
275
+ "constitution": 15,
276
+ "intelligence": 10,
277
+ "wisdom": 13,
278
+ "charisma": 8
279
+ },
280
+ "background": {
281
+ "background_type": "Soldier",
282
+ "backstory": "A veteran warrior seeking redemption."
283
+ }
284
+ }
285
+ }
src/models/game_objects.py ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Game objects: Items, Encounters, Locations
3
+ """
4
+
5
+ from datetime import datetime
6
+ from typing import Optional, List, Dict, Any
7
+ from enum import Enum
8
+ from pydantic import BaseModel, Field
9
+
10
+
11
+ # ==================== ITEMS ====================
12
+
13
+ class ItemRarity(str, Enum):
14
+ """D&D item rarity"""
15
+ COMMON = "Common"
16
+ UNCOMMON = "Uncommon"
17
+ RARE = "Rare"
18
+ VERY_RARE = "Very Rare"
19
+ LEGENDARY = "Legendary"
20
+ ARTIFACT = "Artifact"
21
+
22
+
23
+ class ItemType(str, Enum):
24
+ """Item categories"""
25
+ WEAPON = "Weapon"
26
+ ARMOR = "Armor"
27
+ POTION = "Potion"
28
+ SCROLL = "Scroll"
29
+ WAND = "Wand"
30
+ RING = "Ring"
31
+ WONDROUS = "Wondrous Item"
32
+ TOOL = "Tool"
33
+ TREASURE = "Treasure"
34
+ CONSUMABLE = "Consumable"
35
+
36
+
37
+ class Item(BaseModel):
38
+ """Magic items and equipment"""
39
+ id: Optional[str] = Field(default=None)
40
+ name: str = Field(min_length=1, max_length=100)
41
+ item_type: ItemType
42
+ rarity: ItemRarity = Field(default=ItemRarity.COMMON)
43
+
44
+ description: str = Field(description="Item description")
45
+ properties: List[str] = Field(default_factory=list, description="Magical properties")
46
+
47
+ requires_attunement: bool = Field(default=False)
48
+ attunement_requirements: Optional[str] = Field(default=None)
49
+
50
+ value_gp: int = Field(ge=0, default=0, description="Value in gold pieces")
51
+ weight_lbs: float = Field(ge=0, default=0, description="Weight in pounds")
52
+
53
+ charges: Optional[int] = Field(default=None, description="Current charges")
54
+ max_charges: Optional[int] = Field(default=None, description="Maximum charges")
55
+
56
+ flavor_text: str = Field(default="", description="Flavor/lore text")
57
+ history: str = Field(default="", description="Item history")
58
+
59
+ campaign_id: Optional[str] = Field(default=None)
60
+ owner_id: Optional[str] = Field(default=None, description="Current owner")
61
+
62
+ created_at: datetime = Field(default_factory=datetime.now)
63
+
64
+
65
+ # ==================== ENCOUNTERS ====================
66
+
67
+ class EncounterDifficulty(str, Enum):
68
+ """Encounter difficulty"""
69
+ EASY = "Easy"
70
+ MEDIUM = "Medium"
71
+ HARD = "Hard"
72
+ DEADLY = "Deadly"
73
+
74
+
75
+ class EncounterType(str, Enum):
76
+ """Type of encounter"""
77
+ COMBAT = "Combat"
78
+ SOCIAL = "Social"
79
+ EXPLORATION = "Exploration"
80
+ PUZZLE = "Puzzle"
81
+ TRAP = "Trap"
82
+ MIXED = "Mixed"
83
+
84
+
85
+ class Encounter(BaseModel):
86
+ """Combat/challenge encounter"""
87
+ id: Optional[str] = Field(default=None)
88
+ name: str = Field(min_length=1, max_length=100)
89
+ encounter_type: EncounterType
90
+ difficulty: EncounterDifficulty
91
+
92
+ party_level: int = Field(ge=1, le=20, description="Expected party level")
93
+ party_size: int = Field(ge=1, le=10, default=4, description="Expected party size")
94
+
95
+ description: str = Field(description="Encounter description")
96
+ setup: str = Field(default="", description="How to set up the encounter")
97
+
98
+ # Combat specific
99
+ enemies: List[Dict[str, Any]] = Field(default_factory=list, description="Enemy creatures")
100
+ enemy_tactics: str = Field(default="", description="How enemies fight")
101
+
102
+ # Environment
103
+ location: str = Field(default="", description="Where encounter takes place")
104
+ terrain_features: List[str] = Field(default_factory=list, description="Terrain elements")
105
+ environmental_effects: List[str] = Field(default_factory=list, description="Environmental hazards/effects")
106
+
107
+ # Rewards
108
+ experience_reward: int = Field(ge=0, default=0)
109
+ treasure: List[str] = Field(default_factory=list, description="Treasure rewards")
110
+
111
+ # Alternatives
112
+ alternative_solutions: List[str] = Field(default_factory=list, description="Non-combat solutions")
113
+ scaling_suggestions: str = Field(default="", description="How to scale difficulty")
114
+
115
+ # Metadata
116
+ campaign_id: Optional[str] = Field(default=None)
117
+ session_number: Optional[int] = Field(default=None)
118
+ completed: bool = Field(default=False)
119
+
120
+ notes: str = Field(default="", description="GM notes")
121
+ created_at: datetime = Field(default_factory=datetime.now)
122
+
123
+
124
+ # ==================== LOCATIONS ====================
125
+
126
+ class LocationType(str, Enum):
127
+ """Location categories"""
128
+ CITY = "City"
129
+ TOWN = "Town"
130
+ VILLAGE = "Village"
131
+ DUNGEON = "Dungeon"
132
+ WILDERNESS = "Wilderness"
133
+ CASTLE = "Castle"
134
+ TEMPLE = "Temple"
135
+ TAVERN = "Tavern"
136
+ SHOP = "Shop"
137
+ RUINS = "Ruins"
138
+ CAVE = "Cave"
139
+ FOREST = "Forest"
140
+ MOUNTAIN = "Mountain"
141
+ COAST = "Coast"
142
+ PLANE = "Plane"
143
+
144
+
145
+ class Location(BaseModel):
146
+ """Places in the campaign world"""
147
+ id: Optional[str] = Field(default=None)
148
+ name: str = Field(min_length=1, max_length=100)
149
+ location_type: LocationType
150
+
151
+ description: str = Field(description="Location description")
152
+ atmosphere: str = Field(default="", description="Mood/feeling of the place")
153
+
154
+ # Geography
155
+ region: Optional[str] = Field(default=None, description="Larger region")
156
+ parent_location: Optional[str] = Field(default=None, description="Contains this location")
157
+ sub_locations: List[str] = Field(default_factory=list, description="Locations within this one")
158
+
159
+ # Details
160
+ population: Optional[int] = Field(default=None)
161
+ government: Optional[str] = Field(default=None)
162
+ notable_features: List[str] = Field(default_factory=list)
163
+ points_of_interest: List[str] = Field(default_factory=list)
164
+
165
+ # NPCs & Factions
166
+ notable_npcs: List[str] = Field(default_factory=list, description="NPC IDs")
167
+ controlling_faction: Optional[str] = Field(default=None)
168
+
169
+ # Resources
170
+ available_services: List[str] = Field(default_factory=list, description="Services available")
171
+ shops: List[str] = Field(default_factory=list, description="Shops/merchants")
172
+
173
+ # Secrets & hooks
174
+ secrets: List[str] = Field(default_factory=list, description="Hidden information")
175
+ rumors: List[str] = Field(default_factory=list, description="Local rumors")
176
+ plot_hooks: List[str] = Field(default_factory=list, description="Adventure hooks")
177
+
178
+ # Map
179
+ map_description: str = Field(default="", description="Layout/map description")
180
+ connected_locations: List[str] = Field(default_factory=list, description="Connected locations")
181
+ travel_times: Dict[str, str] = Field(default_factory=dict, description="Travel times to other locations")
182
+
183
+ # Metadata
184
+ campaign_id: Optional[str] = Field(default=None)
185
+ visited: bool = Field(default=False)
186
+ first_visit_session: Optional[int] = Field(default=None)
187
+
188
+ notes: str = Field(default="", description="GM notes")
189
+ created_at: datetime = Field(default_factory=datetime.now)
190
+
191
+ def mark_visited(self, session_number: int):
192
+ """Mark location as visited"""
193
+ if not self.visited:
194
+ self.visited = True
195
+ self.first_visit_session = session_number
src/models/npc.py ADDED
@@ -0,0 +1,246 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ NPC (Non-Player Character) data models
3
+ """
4
+
5
+ from datetime import datetime
6
+ from typing import Optional, List, Dict
7
+ from enum import Enum
8
+ from pydantic import BaseModel, Field
9
+
10
+
11
+ class NPCRole(str, Enum):
12
+ """NPC roles in the story"""
13
+ QUEST_GIVER = "Quest Giver"
14
+ MERCHANT = "Merchant"
15
+ ALLY = "Ally"
16
+ RIVAL = "Rival"
17
+ VILLAIN = "Villain"
18
+ MENTOR = "Mentor"
19
+ INFORMANT = "Informant"
20
+ GUARD = "Guard"
21
+ NOBLE = "Noble"
22
+ COMMONER = "Commoner"
23
+ MONSTER = "Monster"
24
+ COMPANION = "Companion"
25
+
26
+
27
+ class NPCDisposition(str, Enum):
28
+ """NPC attitude toward party"""
29
+ HOSTILE = "Hostile"
30
+ UNFRIENDLY = "Unfriendly"
31
+ NEUTRAL = "Neutral"
32
+ FRIENDLY = "Friendly"
33
+ HELPFUL = "Helpful"
34
+
35
+
36
+ class NPCPersonality(BaseModel):
37
+ """NPC personality traits"""
38
+ traits: List[str] = Field(default_factory=list, description="2-3 personality traits")
39
+ mannerisms: List[str] = Field(default_factory=list, description="Physical mannerisms")
40
+ voice_description: str = Field(default="", description="How they sound")
41
+ motivations: List[str] = Field(default_factory=list, description="What drives them")
42
+ fears: List[str] = Field(default_factory=list, description="What they fear")
43
+ secrets: List[str] = Field(default_factory=list, description="Hidden secrets")
44
+
45
+
46
+ class NPCRelationship(BaseModel):
47
+ """Relationship between NPC and character/party"""
48
+ character_id: str = Field(description="Character or 'party' for group")
49
+ relationship_type: str = Field(description="Type of relationship")
50
+ affinity: int = Field(ge=-100, le=100, default=0, description="Relationship strength")
51
+ history: str = Field(default="", description="Shared history")
52
+ notes: str = Field(default="", description="Relationship notes")
53
+
54
+
55
+ class NPC(BaseModel):
56
+ """Non-player character"""
57
+ # Core identity
58
+ id: Optional[str] = Field(default=None, description="NPC ID")
59
+ name: str = Field(min_length=1, max_length=100, description="NPC name")
60
+ title: Optional[str] = Field(default=None, description="Title/honorific")
61
+
62
+ # Basic info
63
+ race: str = Field(description="NPC race/species")
64
+ age: Optional[str] = Field(default=None, description="Age or age category")
65
+ gender: Optional[str] = Field(default=None, description="Gender")
66
+ occupation: str = Field(description="Occupation/profession")
67
+
68
+ # Role in campaign
69
+ role: NPCRole = Field(description="Story role")
70
+ disposition: NPCDisposition = Field(default=NPCDisposition.NEUTRAL)
71
+ importance: int = Field(ge=1, le=5, default=3, description="Story importance (1-5)")
72
+
73
+ # Description
74
+ appearance: str = Field(description="Physical description")
75
+ personality: NPCPersonality = Field(default_factory=NPCPersonality)
76
+
77
+ # Game stats (optional)
78
+ challenge_rating: Optional[float] = Field(default=None, description="CR if combatant")
79
+ armor_class: Optional[int] = Field(default=None)
80
+ hit_points: Optional[int] = Field(default=None)
81
+
82
+ # Story elements
83
+ backstory: str = Field(default="", description="NPC backstory")
84
+ current_situation: str = Field(default="", description="Current circumstances")
85
+ goals: List[str] = Field(default_factory=list, description="NPC goals")
86
+ connections: List[str] = Field(default_factory=list, description="Connected NPCs/factions")
87
+
88
+ # Relationships
89
+ relationships: List[NPCRelationship] = Field(default_factory=list)
90
+
91
+ # Location & availability
92
+ location: Optional[str] = Field(default=None, description="Current location")
93
+ availability: str = Field(default="Available", description="When/where to find them")
94
+
95
+ # Dialogue
96
+ greeting: Optional[str] = Field(default=None, description="Standard greeting")
97
+ catchphrase: Optional[str] = Field(default=None, description="Memorable phrase")
98
+ dialogue_samples: List[str] = Field(default_factory=list, description="Sample dialogue")
99
+
100
+ # Items & abilities
101
+ notable_items: List[str] = Field(default_factory=list, description="Important items")
102
+ special_abilities: List[str] = Field(default_factory=list, description="Special abilities")
103
+
104
+ # Metadata
105
+ campaign_id: Optional[str] = Field(default=None, description="Associated campaign")
106
+ first_appearance: Optional[int] = Field(default=None, description="Session first appeared")
107
+ last_appearance: Optional[int] = Field(default=None, description="Session last appeared")
108
+ is_alive: bool = Field(default=True, description="Living status")
109
+
110
+ created_at: datetime = Field(default_factory=datetime.now)
111
+ updated_at: datetime = Field(default_factory=datetime.now)
112
+
113
+ # GM notes
114
+ gm_notes: str = Field(default="", description="Private GM notes")
115
+ plot_hooks: List[str] = Field(default_factory=list, description="Plot hooks involving this NPC")
116
+
117
+ def add_relationship(self, character_id: str, relationship_type: str, affinity: int = 0):
118
+ """Add or update relationship"""
119
+ for rel in self.relationships:
120
+ if rel.character_id == character_id:
121
+ rel.relationship_type = relationship_type
122
+ rel.affinity = affinity
123
+ self.updated_at = datetime.now()
124
+ return
125
+
126
+ # Create new relationship
127
+ new_rel = NPCRelationship(
128
+ character_id=character_id,
129
+ relationship_type=relationship_type,
130
+ affinity=affinity
131
+ )
132
+ self.relationships.append(new_rel)
133
+ self.updated_at = datetime.now()
134
+
135
+ def change_disposition(self, new_disposition: NPCDisposition):
136
+ """Change NPC disposition"""
137
+ self.disposition = new_disposition
138
+ self.updated_at = datetime.now()
139
+
140
+ def update_location(self, location: str):
141
+ """Update NPC location"""
142
+ self.location = location
143
+ self.updated_at = datetime.now()
144
+
145
+ def record_appearance(self, session_number: int):
146
+ """Record NPC appearance in session"""
147
+ if self.first_appearance is None:
148
+ self.first_appearance = session_number
149
+ self.last_appearance = session_number
150
+ self.updated_at = datetime.now()
151
+
152
+ def get_relationship_with(self, character_id: str) -> Optional[NPCRelationship]:
153
+ """Get relationship with specific character"""
154
+ for rel in self.relationships:
155
+ if rel.character_id == character_id:
156
+ return rel
157
+ return None
158
+
159
+ def to_roleplay_prompt(self) -> str:
160
+ """Generate prompt for AI to roleplay this NPC"""
161
+ return f"""You are roleplaying as {self.name}, a {self.race} {self.occupation}.
162
+
163
+ APPEARANCE: {self.appearance}
164
+
165
+ PERSONALITY:
166
+ - Traits: {', '.join(self.personality.traits)}
167
+ - Mannerisms: {', '.join(self.personality.mannerisms)}
168
+ - Voice: {self.personality.voice_description}
169
+
170
+ BACKGROUND: {self.backstory}
171
+
172
+ CURRENT SITUATION: {self.current_situation}
173
+
174
+ MOTIVATIONS: {', '.join(self.personality.motivations)}
175
+
176
+ DISPOSITION: {self.disposition.value}
177
+
178
+ TYPICAL GREETING: {self.greeting or 'Generic greeting'}
179
+
180
+ Roleplay this character authentically, staying in character and reflecting their personality, motivations, and current circumstances.
181
+ """
182
+
183
+ def to_markdown(self) -> str:
184
+ """Generate markdown NPC sheet"""
185
+ return f"""# {self.name}
186
+ {f'*{self.title}*' if self.title else ''}
187
+
188
+ **{self.race} {self.occupation}** | **{self.role.value}**
189
+ **Disposition:** {self.disposition.value} | **Importance:** {'⭐' * self.importance}
190
+
191
+ ## Appearance
192
+ {self.appearance}
193
+
194
+ ## Personality
195
+ **Traits:** {', '.join(self.personality.traits)}
196
+ **Mannerisms:** {', '.join(self.personality.mannerisms)}
197
+ **Voice:** {self.personality.voice_description}
198
+
199
+ **Motivations:** {', '.join(self.personality.motivations)}
200
+ **Fears:** {', '.join(self.personality.fears)}
201
+
202
+ ## Background
203
+ {self.backstory}
204
+
205
+ ## Current Situation
206
+ {self.current_situation}
207
+
208
+ ## Goals
209
+ {chr(10).join(f"- {goal}" for goal in self.goals)}
210
+
211
+ ## Location & Availability
212
+ **Location:** {self.location or 'Unknown'}
213
+ **Availability:** {self.availability}
214
+
215
+ ## Connections
216
+ {', '.join(self.connections)}
217
+
218
+ ## Dialogue
219
+ **Greeting:** "{self.greeting or 'Hello there.'}"
220
+ **Catchphrase:** "{self.catchphrase or 'N/A'}"
221
+
222
+ ## Combat Stats
223
+ {f"**CR:** {self.challenge_rating} | **AC:** {self.armor_class} | **HP:** {self.hit_points}" if self.challenge_rating else "*Not a combatant*"}
224
+
225
+ ## Plot Hooks
226
+ {chr(10).join(f"- {hook}" for hook in self.plot_hooks)}
227
+
228
+ ## GM Notes
229
+ {self.gm_notes}
230
+ """
231
+
232
+ class Config:
233
+ json_schema_extra = {
234
+ "example": {
235
+ "name": "Elara Moonwhisper",
236
+ "race": "Elf",
237
+ "occupation": "Sage",
238
+ "role": "Quest Giver",
239
+ "disposition": "Friendly",
240
+ "appearance": "Ancient elf with silver hair and piercing blue eyes",
241
+ "personality": {
242
+ "traits": ["Wise", "Mysterious", "Patient"],
243
+ "voice_description": "Soft and melodic"
244
+ }
245
+ }
246
+ }
src/models/session_notes.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Session Notes Model for D&D Campaign Manager
3
+ """
4
+ from pydantic import BaseModel, Field
5
+ from datetime import datetime
6
+ from typing import Optional
7
+
8
+
9
+ class SessionNotes(BaseModel):
10
+ """Model for campaign session notes"""
11
+
12
+ id: str = Field(..., description="Unique identifier for session notes")
13
+ campaign_id: str = Field(..., description="Campaign this session belongs to")
14
+ session_number: int = Field(..., description="Session number")
15
+ notes: str = Field(..., description="Freeform session notes content")
16
+ uploaded_at: datetime = Field(default_factory=datetime.now, description="When notes were uploaded")
17
+ file_name: Optional[str] = Field(None, description="Original filename if uploaded from file")
18
+ file_type: Optional[str] = Field(None, description="File extension (.txt, .md, .docx, .pdf)")
19
+
20
+ class Config:
21
+ json_schema_extra = {
22
+ "example": {
23
+ "id": "the-forgotten-forge-session-3",
24
+ "campaign_id": "the-forgotten-forge",
25
+ "session_number": 3,
26
+ "notes": "Session 3 - The Lost Temple\n\nThe party arrived at the temple ruins...",
27
+ "uploaded_at": "2024-01-15T20:30:00",
28
+ "file_name": "session_3_notes.txt",
29
+ "file_type": ".txt"
30
+ }
31
+ }
src/ui/__init__.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ UI modules for D'n'D Campaign Manager
3
+
4
+ This package now uses a modular architecture with the following structure:
5
+ - app.py: Main application orchestrator
6
+ - tabs/: Individual tab modules
7
+ - components/: Reusable UI components
8
+ - utils/: UI utility functions
9
+
10
+ For backward compatibility, the old CharacterCreatorUI class is still available
11
+ from character_creator_ui.py, but the new modular structure is recommended.
12
+ """
13
+
14
+ # Import from new modular structure (recommended)
15
+ from .app import DnDCampaignManagerApp, launch_ui
16
+
17
+ # Import old monolithic class for backward compatibility
18
+ from .character_creator_ui import CharacterCreatorUI, launch_ui as launch_ui_legacy
19
+
20
+ # Export both for backward compatibility
21
+ __all__ = [
22
+ # New modular structure (recommended)
23
+ "DnDCampaignManagerApp",
24
+ "launch_ui",
25
+
26
+ # Old monolithic structure (deprecated, for backward compatibility)
27
+ "CharacterCreatorUI",
28
+ "launch_ui_legacy",
29
+ ]
src/ui/app.py ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Main Application Orchestrator for D'n'D Campaign Manager
3
+ Assembles all modular tabs into the complete Gradio interface
4
+ """
5
+
6
+ import gradio as gr
7
+ from src.agents.character_agent import CharacterAgent
8
+ from src.agents.campaign_agent import CampaignAgent
9
+ from src.ui.components.dropdown_manager import DropdownManager
10
+ from src.ui.tabs import (
11
+ AboutTab,
12
+ CharacterCreateTab,
13
+ CharacterLoadTab,
14
+ CharacterManageTab,
15
+ CharacterPortraitTab,
16
+ CharacterExportTab,
17
+ CampaignCreateTab,
18
+ CampaignManageTab,
19
+ CampaignAddCharsTab,
20
+ CampaignSynthesizeTab,
21
+ SessionTrackingTab,
22
+ )
23
+
24
+
25
+ class DnDCampaignManagerApp:
26
+ """Main application class that orchestrates all UI components"""
27
+
28
+ def __init__(self):
29
+ # Initialize agents
30
+ self.character_agent = CharacterAgent()
31
+ self.campaign_agent = CampaignAgent()
32
+
33
+ # Initialize dropdown manager
34
+ self.dropdown_manager = DropdownManager(
35
+ character_agent=self.character_agent,
36
+ campaign_agent=self.campaign_agent
37
+ )
38
+
39
+ # Initialize tabs
40
+ self.about_tab = AboutTab()
41
+ self.character_create_tab = CharacterCreateTab(
42
+ character_agent=self.character_agent
43
+ )
44
+ self.character_load_tab = CharacterLoadTab(
45
+ character_agent=self.character_agent,
46
+ dropdown_manager=self.dropdown_manager
47
+ )
48
+ self.character_manage_tab = CharacterManageTab(
49
+ character_agent=self.character_agent,
50
+ dropdown_manager=self.dropdown_manager
51
+ )
52
+ self.character_portrait_tab = CharacterPortraitTab(
53
+ character_agent=self.character_agent,
54
+ dropdown_manager=self.dropdown_manager
55
+ )
56
+ self.character_export_tab = CharacterExportTab(
57
+ character_agent=self.character_agent,
58
+ dropdown_manager=self.dropdown_manager
59
+ )
60
+ self.campaign_create_tab = CampaignCreateTab(
61
+ campaign_agent=self.campaign_agent
62
+ )
63
+ self.campaign_manage_tab = CampaignManageTab(
64
+ campaign_agent=self.campaign_agent,
65
+ dropdown_manager=self.dropdown_manager
66
+ )
67
+ self.campaign_add_chars_tab = CampaignAddCharsTab(
68
+ character_agent=self.character_agent,
69
+ campaign_agent=self.campaign_agent,
70
+ dropdown_manager=self.dropdown_manager
71
+ )
72
+ self.campaign_synthesize_tab = CampaignSynthesizeTab(
73
+ character_agent=self.character_agent,
74
+ campaign_agent=self.campaign_agent,
75
+ dropdown_manager=self.dropdown_manager
76
+ )
77
+ self.session_tracking_tab = SessionTrackingTab(
78
+ campaign_agent=self.campaign_agent,
79
+ dropdown_manager=self.dropdown_manager
80
+ )
81
+
82
+ def create_interface(self) -> gr.Blocks:
83
+ """Create and assemble the complete Gradio interface"""
84
+ with gr.Blocks(title="D'n'D Campaign Manager - Character Creator", theme=gr.themes.Soft()) as interface:
85
+ gr.Markdown("""
86
+ # 🎲 D'n'D Campaign Manager
87
+ ## Complete D&D Character Creator
88
+
89
+ Create and manage complete D&D 5e characters for your campaigns!
90
+ """)
91
+
92
+ with gr.Tabs():
93
+ # Character tabs
94
+ self.character_create_tab.create()
95
+
96
+ character_dropdown = self.character_load_tab.create()
97
+ delete_character_dropdown = self.character_manage_tab.create()
98
+ portrait_character_dropdown = self.character_portrait_tab.create()
99
+ export_character_dropdown = self.character_export_tab.create()
100
+
101
+ # Campaign Management tab with sub-tabs
102
+ with gr.Tab("Campaign Management"):
103
+ gr.Markdown("""
104
+ ### 🎲 Campaign Management
105
+ Create and manage your D&D campaigns, track sessions, and record events!
106
+ """)
107
+
108
+ with gr.Tabs():
109
+ self.campaign_synthesize_tab.create()
110
+ self.campaign_create_tab.create()
111
+ manage_campaign_dropdown = self.campaign_manage_tab.create()
112
+ add_char_campaign_dropdown, add_char_character_dropdown = self.campaign_add_chars_tab.create()
113
+ session_campaign_dropdown, auto_session_campaign_dropdown, notes_campaign_dropdown, event_campaign_dropdown = self.session_tracking_tab.create()
114
+
115
+ # About tab
116
+ self.about_tab.create()
117
+
118
+ gr.Markdown("""
119
+ ---
120
+ ### Tips
121
+ - Enable "Use AI" options for more creative and detailed characters
122
+ - Use Standard Array for balanced characters
123
+ - Use Roll for random variation
124
+ - Save your character ID to load it later
125
+ - Check the character list to find previously created characters
126
+ """)
127
+
128
+ # Auto-populate dropdowns on interface load
129
+ def populate_all_dropdowns():
130
+ """Populate all dropdowns when interface loads"""
131
+ campaign_choices = self.dropdown_manager.get_campaign_dropdown_choices()
132
+ character_choices = self.dropdown_manager.get_character_dropdown_choices()
133
+
134
+ return [
135
+ gr.update(choices=character_choices), # character_dropdown
136
+ gr.update(choices=character_choices), # delete_character_dropdown
137
+ gr.update(choices=character_choices), # portrait_character_dropdown
138
+ gr.update(choices=character_choices), # export_character_dropdown
139
+ gr.update(choices=campaign_choices), # manage_campaign_dropdown
140
+ gr.update(choices=campaign_choices), # add_char_campaign_dropdown
141
+ gr.update(choices=character_choices), # add_char_character_dropdown
142
+ gr.update(choices=campaign_choices), # session_campaign_dropdown
143
+ gr.update(choices=campaign_choices), # auto_session_campaign_dropdown
144
+ gr.update(choices=campaign_choices), # notes_campaign_dropdown
145
+ gr.update(choices=campaign_choices), # event_campaign_dropdown
146
+ ]
147
+
148
+ interface.load(
149
+ fn=populate_all_dropdowns,
150
+ inputs=[],
151
+ outputs=[
152
+ character_dropdown,
153
+ delete_character_dropdown,
154
+ portrait_character_dropdown,
155
+ export_character_dropdown,
156
+ manage_campaign_dropdown,
157
+ add_char_campaign_dropdown,
158
+ add_char_character_dropdown,
159
+ session_campaign_dropdown,
160
+ auto_session_campaign_dropdown,
161
+ notes_campaign_dropdown,
162
+ event_campaign_dropdown,
163
+ ]
164
+ )
165
+
166
+ return interface
167
+
168
+
169
+ def launch_ui():
170
+ """Launch the Gradio interface"""
171
+ app = DnDCampaignManagerApp()
172
+ interface = app.create_interface()
173
+
174
+ interface.launch(
175
+ server_name="0.0.0.0",
176
+ server_port=7860,
177
+ share=False,
178
+ show_error=True
179
+ )
180
+
181
+
182
+ if __name__ == "__main__":
183
+ launch_ui()
src/ui/character_creator_ui.py ADDED
@@ -0,0 +1,1842 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Gradio UI for Character Creator
3
+ """
4
+
5
+ import gradio as gr
6
+ from typing import Optional, Tuple
7
+ import traceback
8
+
9
+ from src.agents.character_agent import CharacterAgent
10
+ from src.agents.campaign_agent import CampaignAgent
11
+ from src.models.character import DnDRace, DnDClass
12
+ from src.models.campaign import CampaignTheme
13
+ from src.utils.validators import get_available_races, get_available_classes
14
+ from src.utils.image_generator import RACE_SKIN_TONES
15
+ from src.utils.character_sheet_exporter import CharacterSheetExporter
16
+
17
+
18
+ class CharacterCreatorUI:
19
+ """Gradio interface for character creation"""
20
+
21
+ def __init__(self):
22
+ self.agent = CharacterAgent()
23
+ self.campaign_agent = CampaignAgent()
24
+ self.exporter = CharacterSheetExporter()
25
+
26
+ def _get_alignment_description(self, alignment: str) -> str:
27
+ """Get personality guidance based on alignment"""
28
+ descriptions = {
29
+ "Lawful Good": "a strong sense of justice, honor, and desire to help others within the rules",
30
+ "Neutral Good": "genuine kindness and desire to help, but flexibility in how they achieve good",
31
+ "Chaotic Good": "rebellious goodness, fighting for freedom and helping others by breaking unjust rules",
32
+ "Lawful Neutral": "strict adherence to law, order, and tradition above good or evil",
33
+ "True Neutral": "balance and pragmatism, avoiding extreme positions",
34
+ "Chaotic Neutral": "unpredictability, freedom-loving nature, and self-interest",
35
+ "Lawful Evil": "tyrannical control, following their own code while causing harm",
36
+ "Neutral Evil": "pure self-interest and willingness to harm others for personal gain",
37
+ "Chaotic Evil": "destructive chaos, cruelty, and disregard for any rules or others' wellbeing"
38
+ }
39
+ return descriptions.get(alignment, "their moral compass")
40
+
41
+ def generate_name_ui(
42
+ self,
43
+ race: str,
44
+ character_class: str,
45
+ gender: str,
46
+ alignment: str,
47
+ ) -> str:
48
+ """
49
+ Generate a character name using AI
50
+ This is a self-contained function that calls the agent's generate_name method
51
+ Alignment can influence name generation (e.g., darker names for evil characters)
52
+ """
53
+ try:
54
+ race_enum = DnDRace(race)
55
+ class_enum = DnDClass(character_class)
56
+
57
+ # Add alignment hint to the generation prompt
58
+ alignment_hint = None
59
+ if "Evil" in alignment:
60
+ alignment_hint = "with a darker, more menacing tone"
61
+ elif "Good" in alignment:
62
+ alignment_hint = "with a heroic, noble tone"
63
+ elif alignment == "Chaotic Neutral":
64
+ alignment_hint = "with a wild, unpredictable feel"
65
+
66
+ # Build custom prompt if we have alignment influence
67
+ if alignment_hint:
68
+ prompt = f"""Generate a single fantasy character name for a D&D character.
69
+
70
+ Race: {race_enum.value}
71
+ Class: {class_enum.value}
72
+ Gender: {gender if gender != "Not specified" else "any"}
73
+ Alignment: {alignment} - name should reflect this {alignment_hint}
74
+
75
+ Requirements:
76
+ - Just the name, nothing else
77
+ - Make it sound appropriate for the race, gender, and alignment
78
+ - {alignment_hint}
79
+ - Make it memorable and fitting for an adventurer
80
+ - 2-3 words maximum
81
+
82
+ Examples:
83
+ - Evil: "Malakai Shadowbane", "Drusilla Nightwhisper"
84
+ - Good: "Elara Lightbringer", "Theron Brightheart"
85
+ - Chaotic: "Raven Wildfire", "Zephyr Stormblade"
86
+
87
+ Generate only the name:"""
88
+
89
+ name = self.agent.ai_client.generate_creative(prompt).strip()
90
+ name = name.split('\n')[0].strip('"\'')
91
+ return name
92
+ else:
93
+ # Use standard generation
94
+ name = self.agent.generate_name(
95
+ race=race_enum,
96
+ character_class=class_enum,
97
+ gender=gender if gender != "Not specified" else None
98
+ )
99
+ return name
100
+
101
+ except Exception as e:
102
+ return f"Error: {str(e)}"
103
+
104
+ def create_character_ui(
105
+ self,
106
+ name: str,
107
+ race: str,
108
+ character_class: str,
109
+ level: int,
110
+ gender: str,
111
+ skin_tone: str,
112
+ alignment: str,
113
+ background_dropdown: str,
114
+ custom_background: str,
115
+ personality_prompt: str,
116
+ stats_method: str,
117
+ use_ai_background: bool,
118
+ ) -> Tuple[str, str]:
119
+ """
120
+ Create character with UI inputs
121
+
122
+ Returns:
123
+ Tuple of (character_sheet_markdown, status_message)
124
+ """
125
+ try:
126
+ # Validate inputs
127
+ if not name.strip():
128
+ return "", "❌ Error: Please provide a character name (use 'Generate Name' button or type one)"
129
+
130
+ if level < 1 or level > 20:
131
+ return "", "❌ Error: Level must be between 1 and 20"
132
+
133
+ # Convert race, class, and alignment
134
+ try:
135
+ race_enum = DnDRace(race)
136
+ class_enum = DnDClass(character_class)
137
+ from src.models.character import Alignment
138
+ alignment_enum = Alignment(alignment)
139
+ except ValueError as e:
140
+ return "", f"❌ Error: Invalid race, class, or alignment - {e}"
141
+
142
+ # Determine final background type
143
+ if background_dropdown == "Custom (enter below)":
144
+ final_background = custom_background.strip() if custom_background.strip() else "Adventurer"
145
+ else:
146
+ final_background = background_dropdown
147
+
148
+ # Create character with gender AND alignment in personality prompt
149
+ gender_hint = f"Character is {gender}. " if gender != "Not specified" else ""
150
+ alignment_hint = f"Character's alignment is {alignment}, so their personality and backstory should reflect {self._get_alignment_description(alignment)}. "
151
+
152
+ full_personality_prompt = gender_hint + alignment_hint + (personality_prompt if personality_prompt else "")
153
+
154
+ character = self.agent.create_character(
155
+ name=name,
156
+ race=race_enum,
157
+ character_class=class_enum,
158
+ level=level,
159
+ background_type=final_background,
160
+ personality_prompt=full_personality_prompt if use_ai_background else None,
161
+ stats_method=stats_method,
162
+ )
163
+
164
+ # Override alignment, gender, and skin tone if user specified
165
+ character.alignment = alignment_enum
166
+ character.gender = gender if gender != "Not specified" else None
167
+ character.skin_tone = skin_tone if skin_tone else None
168
+
169
+ # Generate markdown
170
+ markdown = character.to_markdown()
171
+
172
+ status = f"""βœ… Character Created Successfully!
173
+
174
+ **ID:** {character.id}
175
+ **Name:** {character.name}
176
+ **Race:** {character.race.value}
177
+ **Class:** {character.character_class.value}
178
+ **Level:** {character.level}
179
+
180
+ Character has been saved to database."""
181
+
182
+ return markdown, status
183
+
184
+ except Exception as e:
185
+ error_msg = f"❌ Error creating character:\n\n{str(e)}\n\n{traceback.format_exc()}"
186
+ return "", error_msg
187
+
188
+ def load_character_ui(self, character_id: str) -> Tuple[str, str]:
189
+ """Load character by ID"""
190
+ try:
191
+ if not character_id.strip():
192
+ return "", "❌ Error: Please provide a character ID"
193
+
194
+ character = self.agent.load_character(character_id)
195
+
196
+ if character:
197
+ markdown = character.to_markdown()
198
+ status = f"βœ… Loaded character: {character.name}"
199
+ return markdown, status
200
+ else:
201
+ return "", f"❌ Character not found: {character_id}"
202
+
203
+ except Exception as e:
204
+ return "", f"❌ Error loading character: {e}"
205
+
206
+ def list_characters_ui(self) -> Tuple[str, str]:
207
+ """List all saved characters"""
208
+ try:
209
+ characters = self.agent.list_characters()
210
+
211
+ if not characters:
212
+ return "", "No characters found in database."
213
+
214
+ # Create table
215
+ markdown = "# Saved Characters\n\n"
216
+ markdown += "| Name | Race | Class | Level | ID |\n"
217
+ markdown += "|------|------|-------|-------|----|\n"
218
+
219
+ for char in characters[-20:]: # Last 20 characters
220
+ markdown += f"| {char.name} | {char.race.value} | {char.character_class.value} | {char.level} | `{char.id}` |\n"
221
+
222
+ status = f"βœ… Found {len(characters)} character(s)"
223
+ return markdown, status
224
+
225
+ except Exception as e:
226
+ return "", f"❌ Error listing characters: {e}"
227
+
228
+ def delete_character_ui(self, character_id: str) -> str:
229
+ """Delete character by ID"""
230
+ try:
231
+ if not character_id.strip():
232
+ return "❌ Error: Please provide a character ID"
233
+
234
+ # Check if exists
235
+ character = self.agent.load_character(character_id)
236
+ if not character:
237
+ return f"❌ Character not found: {character_id}"
238
+
239
+ # Delete
240
+ self.agent.delete_character(character_id)
241
+ return f"βœ… Deleted character: {character.name} ({character_id})"
242
+
243
+ except Exception as e:
244
+ return f"❌ Error deleting character: {e}"
245
+
246
+ def generate_portrait_ui(
247
+ self,
248
+ character_id: str,
249
+ style: str = "fantasy art",
250
+ quality: str = "standard",
251
+ provider: str = "auto"
252
+ ) -> Tuple[Optional[str], str]:
253
+ """
254
+ Generate character portrait
255
+
256
+ Returns:
257
+ Tuple of (image_path, status_message)
258
+ """
259
+ try:
260
+ if not character_id.strip():
261
+ return None, "❌ Error: Please provide a character ID"
262
+
263
+ # Load character
264
+ character = self.agent.load_character(character_id)
265
+ if not character:
266
+ return None, f"❌ Character not found: {character_id}"
267
+
268
+ # Generate portrait
269
+ file_path, status = self.agent.generate_portrait(
270
+ character=character,
271
+ style=style,
272
+ quality=quality,
273
+ provider=provider
274
+ )
275
+
276
+ return file_path, status
277
+
278
+ except Exception as e:
279
+ import traceback
280
+ error_msg = f"❌ Error generating portrait:\n\n{str(e)}\n\n{traceback.format_exc()}"
281
+ return None, error_msg
282
+
283
+ def export_character_sheet_ui(
284
+ self,
285
+ character_id: str,
286
+ export_format: str = "markdown"
287
+ ) -> str:
288
+ """
289
+ Export character sheet to file
290
+
291
+ Returns:
292
+ Status message with file path
293
+ """
294
+ try:
295
+ if not character_id.strip():
296
+ return "❌ Error: Please provide a character ID"
297
+
298
+ # Load character
299
+ character = self.agent.load_character(character_id)
300
+ if not character:
301
+ return f"❌ Character not found: {character_id}"
302
+
303
+ # Export to selected format
304
+ file_path = self.exporter.save_export(character, format=export_format)
305
+
306
+ return f"""βœ… Character sheet exported successfully!
307
+
308
+ **Character:** {character.name}
309
+ **Format:** {export_format.upper()}
310
+ **File:** {file_path}
311
+
312
+ You can find the exported file in the data/exports/ directory."""
313
+
314
+ except Exception as e:
315
+ return f"❌ Error exporting character sheet:\n\n{str(e)}\n\n{traceback.format_exc()}"
316
+
317
+ def preview_export_ui(
318
+ self,
319
+ character_id: str,
320
+ export_format: str = "markdown"
321
+ ) -> Tuple[str, str]:
322
+ """
323
+ Preview character sheet export without saving
324
+
325
+ Returns:
326
+ Tuple of (preview_content, status_message)
327
+ """
328
+ try:
329
+ if not character_id.strip():
330
+ return "", "❌ Error: Please provide a character ID"
331
+
332
+ # Load character
333
+ character = self.agent.load_character(character_id)
334
+ if not character:
335
+ return "", f"❌ Character not found: {character_id}"
336
+
337
+ # Generate preview based on format
338
+ if export_format == "markdown":
339
+ preview = self.exporter.export_to_markdown(character)
340
+ elif export_format == "json":
341
+ preview = f"```json\n{self.exporter.export_to_json(character)}\n```"
342
+ elif export_format == "html":
343
+ preview = f"```html\n{self.exporter.export_to_html(character)}\n```"
344
+ else:
345
+ return "", f"❌ Unknown format: {export_format}"
346
+
347
+ status = f"βœ… Preview generated for {character.name}"
348
+ return preview, status
349
+
350
+ except Exception as e:
351
+ return "", f"❌ Error generating preview:\n\n{str(e)}\n\n{traceback.format_exc()}"
352
+
353
+ # Campaign Management UI Methods
354
+ def create_campaign_ui(
355
+ self,
356
+ name: str,
357
+ theme: str,
358
+ setting: str,
359
+ summary: str,
360
+ main_conflict: str,
361
+ game_master: str,
362
+ world_name: str,
363
+ starting_location: str,
364
+ level_range: str,
365
+ party_size: int
366
+ ) -> str:
367
+ """Create a new campaign"""
368
+ try:
369
+ if not name.strip():
370
+ return "❌ Error: Please provide a campaign name"
371
+
372
+ campaign = self.campaign_agent.create_campaign(
373
+ name=name,
374
+ theme=theme,
375
+ setting=setting,
376
+ summary=summary,
377
+ main_conflict=main_conflict,
378
+ game_master=game_master,
379
+ world_name=world_name,
380
+ starting_location=starting_location,
381
+ level_range=level_range,
382
+ party_size=party_size
383
+ )
384
+
385
+ return f"""βœ… Campaign Created Successfully!
386
+
387
+ **ID:** {campaign.id}
388
+ **Name:** {campaign.name}
389
+ **Theme:** {campaign.theme.value}
390
+ **Setting:** {campaign.setting}
391
+
392
+ Campaign has been saved to database.
393
+ Use the campaign ID to manage characters and sessions."""
394
+
395
+ except Exception as e:
396
+ return f"❌ Error creating campaign:\n\n{str(e)}\n\n{traceback.format_exc()}"
397
+
398
+ def list_campaigns_ui(self, active_only: bool = False) -> Tuple[str, str]:
399
+ """List all campaigns"""
400
+ try:
401
+ campaigns = self.campaign_agent.list_campaigns(active_only=active_only)
402
+
403
+ if not campaigns:
404
+ return "", "No campaigns found in database."
405
+
406
+ # Create table
407
+ markdown = "# Campaigns\n\n"
408
+ markdown += "| Name | Theme | Session | Status | ID |\n"
409
+ markdown += "|------|-------|---------|--------|----|\n"
410
+
411
+ for campaign in campaigns[-20:]: # Last 20 campaigns
412
+ status = "Active" if campaign.is_active else "Inactive"
413
+ markdown += f"| {campaign.name} | {campaign.theme.value} | {campaign.current_session} | {status} | `{campaign.id}` |\n"
414
+
415
+ status = f"βœ… Found {len(campaigns)} campaign(s)"
416
+ return markdown, status
417
+
418
+ except Exception as e:
419
+ return "", f"❌ Error listing campaigns: {e}"
420
+
421
+ def load_campaign_ui(self, campaign_id: str) -> Tuple[str, str]:
422
+ """Load campaign details"""
423
+ try:
424
+ if not campaign_id.strip():
425
+ return "", "❌ Error: Please provide a campaign ID"
426
+
427
+ campaign = self.campaign_agent.load_campaign(campaign_id)
428
+
429
+ if campaign:
430
+ markdown = campaign.to_markdown()
431
+ status = f"βœ… Loaded campaign: {campaign.name}"
432
+ return markdown, status
433
+ else:
434
+ return "", f"❌ Campaign not found: {campaign_id}"
435
+
436
+ except Exception as e:
437
+ return "", f"❌ Error loading campaign: {e}"
438
+
439
+ def add_character_to_campaign_ui(self, campaign_id: str, character_id: str) -> str:
440
+ """Add a character to a campaign"""
441
+ try:
442
+ if not campaign_id.strip() or not character_id.strip():
443
+ return "❌ Error: Please provide both campaign ID and character ID"
444
+
445
+ # Verify character exists
446
+ character = self.agent.load_character(character_id)
447
+ if not character:
448
+ return f"❌ Character not found: {character_id}"
449
+
450
+ # Add to campaign
451
+ success = self.campaign_agent.add_character_to_campaign(campaign_id, character_id)
452
+
453
+ if success:
454
+ return f"βœ… Added {character.name} to campaign!"
455
+ else:
456
+ return f"❌ Campaign not found: {campaign_id}"
457
+
458
+ except Exception as e:
459
+ return f"❌ Error: {str(e)}"
460
+
461
+ def start_session_ui(self, campaign_id: str) -> str:
462
+ """Start a new session"""
463
+ try:
464
+ if not campaign_id.strip():
465
+ return "❌ Error: Please provide a campaign ID"
466
+
467
+ campaign = self.campaign_agent.load_campaign(campaign_id)
468
+ if not campaign:
469
+ return f"❌ Campaign not found: {campaign_id}"
470
+
471
+ self.campaign_agent.start_new_session(campaign_id)
472
+
473
+ return f"""βœ… Started Session {campaign.current_session + 1}!
474
+
475
+ **Campaign:** {campaign.name}
476
+ **New Session Number:** {campaign.current_session + 1}
477
+ **Total Sessions:** {campaign.total_sessions + 1}"""
478
+
479
+ except Exception as e:
480
+ return f"❌ Error: {str(e)}"
481
+
482
+ def auto_generate_session_ui(self, campaign_id: str) -> str:
483
+ """Auto-generate next session using AI"""
484
+ try:
485
+ if not campaign_id.strip():
486
+ return "❌ Error: Please select a campaign"
487
+
488
+ campaign = self.campaign_agent.load_campaign(campaign_id)
489
+ if not campaign:
490
+ return f"❌ Campaign not found: {campaign_id}"
491
+
492
+ # Generate session using autonomous AI
493
+ session_data = self.campaign_agent.auto_generate_next_session(campaign_id)
494
+
495
+ if 'error' in session_data:
496
+ return f"❌ Error: {session_data['error']}"
497
+
498
+ # Format output for display
499
+ output = []
500
+ output.append(f"# πŸ€– Auto-Generated Session {session_data.get('session_number', 'N/A')}")
501
+ output.append(f"\n**Campaign:** {campaign.name}")
502
+ output.append(f"\n**Session Title:** {session_data.get('session_title', 'Untitled')}")
503
+ output.append(f"\n---\n")
504
+
505
+ # Opening Scene
506
+ if 'opening_scene' in session_data:
507
+ output.append(f"## 🎬 Opening Scene\n\n{session_data['opening_scene']}\n\n")
508
+
509
+ # Key Encounters
510
+ if 'key_encounters' in session_data and session_data['key_encounters']:
511
+ output.append("## βš”οΈ Key Encounters\n\n")
512
+ for i, encounter in enumerate(session_data['key_encounters'], 1):
513
+ output.append(f"{i}. {encounter}\n")
514
+ output.append("\n")
515
+
516
+ # NPCs Featured
517
+ if 'npcs_featured' in session_data and session_data['npcs_featured']:
518
+ output.append("## πŸ‘₯ NPCs Featured\n\n")
519
+ for npc in session_data['npcs_featured']:
520
+ output.append(f"- {npc}\n")
521
+ output.append("\n")
522
+
523
+ # Locations
524
+ if 'locations' in session_data and session_data['locations']:
525
+ output.append("## πŸ—ΊοΈ Locations\n\n")
526
+ for loc in session_data['locations']:
527
+ output.append(f"- {loc}\n")
528
+ output.append("\n")
529
+
530
+ # Plot Developments
531
+ if 'plot_developments' in session_data and session_data['plot_developments']:
532
+ output.append("## πŸ“– Plot Developments\n\n")
533
+ for i, dev in enumerate(session_data['plot_developments'], 1):
534
+ output.append(f"{i}. {dev}\n")
535
+ output.append("\n")
536
+
537
+ # Potential Outcomes
538
+ if 'potential_outcomes' in session_data and session_data['potential_outcomes']:
539
+ output.append("## 🎲 Potential Outcomes\n\n")
540
+ for i, outcome in enumerate(session_data['potential_outcomes'], 1):
541
+ output.append(f"{i}. {outcome}\n")
542
+ output.append("\n")
543
+
544
+ # Rewards
545
+ if 'rewards' in session_data and session_data['rewards']:
546
+ output.append("## πŸ’° Rewards\n\n")
547
+ for reward in session_data['rewards']:
548
+ output.append(f"- {reward}\n")
549
+ output.append("\n")
550
+
551
+ # Cliffhanger
552
+ if 'cliffhanger' in session_data and session_data['cliffhanger']:
553
+ output.append(f"## 🎭 Cliffhanger\n\n{session_data['cliffhanger']}\n\n")
554
+
555
+ output.append("---\n\n")
556
+ output.append("βœ… **Session plan generated successfully!**\n\n")
557
+ output.append("πŸ’‘ **Next Steps:**\n")
558
+ output.append("- Review the session plan above\n")
559
+ output.append("- Adjust encounters/NPCs as needed for your table\n")
560
+ output.append("- Copy relevant sections to your session notes\n")
561
+ output.append("- Start the session when ready!\n")
562
+
563
+ return "".join(output)
564
+
565
+ except Exception as e:
566
+ import traceback
567
+ return f"❌ Error generating session:\n\n{str(e)}\n\n{traceback.format_exc()}"
568
+
569
+ def add_event_ui(
570
+ self,
571
+ campaign_id: str,
572
+ event_type: str,
573
+ title: str,
574
+ description: str,
575
+ importance: int
576
+ ) -> str:
577
+ """Add an event to the campaign"""
578
+ try:
579
+ if not campaign_id.strip():
580
+ return "❌ Error: Please provide a campaign ID"
581
+
582
+ if not title.strip() or not description.strip():
583
+ return "❌ Error: Please provide event title and description"
584
+
585
+ event = self.campaign_agent.add_event(
586
+ campaign_id=campaign_id,
587
+ event_type=event_type,
588
+ title=title,
589
+ description=description,
590
+ importance=importance
591
+ )
592
+
593
+ if event:
594
+ return f"""βœ… Event Added!
595
+
596
+ **Title:** {title}
597
+ **Type:** {event_type}
598
+ **Importance:** {'⭐' * importance}
599
+
600
+ Event has been recorded in campaign history."""
601
+ else:
602
+ return f"❌ Campaign not found: {campaign_id}"
603
+
604
+ except Exception as e:
605
+ return f"❌ Error: {str(e)}"
606
+
607
+ def get_character_choices_ui(self) -> list:
608
+ """Get list of characters for selection"""
609
+ try:
610
+ characters = self.agent.list_characters()
611
+ if not characters:
612
+ return []
613
+
614
+ # Create choices as "Name (Race Class, Level X) - ID"
615
+ choices = []
616
+ for char in characters:
617
+ label = f"{char.name} ({char.race.value} {char.character_class.value}, Level {char.level})"
618
+ choices.append((label, char.id))
619
+
620
+ return choices
621
+ except Exception as e:
622
+ return []
623
+
624
+ def get_character_dropdown_choices(self) -> list:
625
+ """Get character choices for dropdown (returns IDs only)"""
626
+ try:
627
+ characters = self.agent.list_characters()
628
+ if not characters:
629
+ return []
630
+
631
+ # Create dropdown choices with nice labels
632
+ choices = []
633
+ for char in characters:
634
+ label = f"{char.name} ({char.race.value} {char.character_class.value}, Lvl {char.level})"
635
+ choices.append(label)
636
+
637
+ return choices
638
+ except Exception as e:
639
+ return []
640
+
641
+ def get_character_id_from_label(self, label: str) -> str:
642
+ """Extract character ID from dropdown label"""
643
+ try:
644
+ # Parse the label to get character name
645
+ if not label:
646
+ return ""
647
+
648
+ name = label.split(" (")[0] if " (" in label else label
649
+
650
+ # Find character by name
651
+ characters = self.agent.list_characters()
652
+ for char in characters:
653
+ if char.name == name:
654
+ return char.id
655
+
656
+ return ""
657
+ except Exception as e:
658
+ return ""
659
+
660
+ def get_campaign_dropdown_choices(self) -> list:
661
+ """Get campaign choices for dropdown"""
662
+ try:
663
+ campaigns = self.campaign_agent.list_campaigns()
664
+ if not campaigns:
665
+ return []
666
+
667
+ choices = []
668
+ for campaign in campaigns:
669
+ label = f"{campaign.name} ({campaign.theme.value}, Session {campaign.current_session})"
670
+ choices.append(label)
671
+
672
+ return choices
673
+ except Exception as e:
674
+ return []
675
+
676
+ def get_campaign_id_from_label(self, label: str) -> str:
677
+ """Extract campaign ID from dropdown label"""
678
+ try:
679
+ if not label:
680
+ return ""
681
+
682
+ name = label.split(" (")[0] if " (" in label else label
683
+
684
+ campaigns = self.campaign_agent.list_campaigns()
685
+ for campaign in campaigns:
686
+ if campaign.name == name:
687
+ return campaign.id
688
+
689
+ return ""
690
+ except Exception as e:
691
+ return ""
692
+
693
+ def synthesize_campaign_ui(
694
+ self,
695
+ selected_character_ids: list,
696
+ game_master: str,
697
+ additional_notes: str
698
+ ) -> str:
699
+ """Synthesize a campaign from selected characters"""
700
+ try:
701
+ # Check if any characters selected
702
+ if not selected_character_ids:
703
+ return "❌ Error: Please select at least one character"
704
+
705
+ # Load all characters
706
+ characters = []
707
+ for char_id in selected_character_ids:
708
+ char = self.agent.load_character(char_id)
709
+ if char:
710
+ characters.append(char)
711
+
712
+ if not characters:
713
+ return "❌ Error: No valid characters found"
714
+
715
+ # Synthesize campaign
716
+ campaign = self.campaign_agent.synthesize_campaign_from_characters(
717
+ characters=characters,
718
+ game_master=game_master,
719
+ additional_notes=additional_notes
720
+ )
721
+
722
+ # Create response with character list
723
+ char_list = "\n".join([f"- {char.name} (Level {char.level} {char.race.value} {char.character_class.value})" for char in characters])
724
+
725
+ # Build comprehensive output with all campaign details
726
+ output = [f"""βœ… Campaign Synthesized Successfully!
727
+
728
+ **Campaign ID:** {campaign.id}
729
+ **Campaign Name:** {campaign.name}
730
+ **Theme:** {campaign.theme.value}
731
+ **World:** {campaign.world_name}
732
+ **Starting Location:** {campaign.starting_location}
733
+
734
+ **Party Members ({len(characters)}):**
735
+ {char_list}
736
+
737
+ **Level Range:** {campaign.level_range}
738
+
739
+ ---
740
+
741
+ ## Campaign Overview
742
+
743
+ **Summary:**
744
+ {campaign.summary}
745
+
746
+ **Main Conflict:**
747
+ {campaign.main_conflict}
748
+
749
+ **Current Story Arc:**
750
+ {campaign.current_arc if campaign.current_arc else "See detailed notes below"}
751
+ """]
752
+
753
+ # Add factions if present
754
+ if campaign.key_factions:
755
+ output.append("\n## Key Factions\n")
756
+ for faction in campaign.key_factions:
757
+ output.append(f"- {faction}\n")
758
+
759
+ # Add villains if present
760
+ if campaign.major_villains:
761
+ output.append("\n## Major Villains\n")
762
+ for villain in campaign.major_villains:
763
+ output.append(f"- {villain}\n")
764
+
765
+ # Add mysteries if present
766
+ if campaign.central_mysteries:
767
+ output.append("\n## Central Mysteries\n")
768
+ for mystery in campaign.central_mysteries:
769
+ output.append(f"- {mystery}\n")
770
+
771
+ # Add detailed campaign notes (includes character connections, hooks, sessions, NPCs, locations)
772
+ if campaign.notes:
773
+ output.append("\n---\n\n")
774
+ output.append(campaign.notes)
775
+
776
+ output.append(f"""
777
+
778
+ ---
779
+
780
+ βœ… **Campaign Created!** All characters have been added to the campaign.
781
+
782
+ πŸ’‘ **Next Steps:**
783
+ - View full details in the "Manage Campaign" tab
784
+ - Start your first session in "Session Tracking"
785
+ - Add campaign events as your story unfolds""")
786
+
787
+ return "".join(output)
788
+
789
+ except Exception as e:
790
+ return f"❌ Error synthesizing campaign:\n\n{str(e)}\n\n{traceback.format_exc()}"
791
+
792
+ def create_interface(self) -> gr.Blocks:
793
+ """Create Gradio interface"""
794
+
795
+ with gr.Blocks(title="D'n'D Campaign Manager - Character Creator", theme=gr.themes.Soft()) as interface:
796
+ gr.Markdown("""
797
+ # 🎲 D'n'D Campaign Manager
798
+ ## Complete D&D Character Creator
799
+
800
+ Create and manage complete D&D 5e characters for your campaigns!
801
+ """)
802
+
803
+ with gr.Tabs():
804
+ # Tab 1: Create Character
805
+ with gr.Tab("Create Character"):
806
+ gr.Markdown("### Character Creation")
807
+
808
+ with gr.Row():
809
+ with gr.Column():
810
+ gr.Markdown("#### Basic Information")
811
+
812
+ with gr.Row():
813
+ name_input = gr.Textbox(
814
+ label="Character Name",
815
+ placeholder="Thorin Ironforge",
816
+ info="Type a name or generate one below",
817
+ scale=3
818
+ )
819
+
820
+ race_dropdown = gr.Dropdown(
821
+ choices=get_available_races(),
822
+ label="Race",
823
+ value="Human",
824
+ info="Character's race"
825
+ )
826
+
827
+ class_dropdown = gr.Dropdown(
828
+ choices=get_available_classes(),
829
+ label="Class",
830
+ value="Fighter",
831
+ info="Character's class"
832
+ )
833
+
834
+ gender_dropdown = gr.Dropdown(
835
+ choices=["Male", "Female", "Non-binary", "Not specified"],
836
+ label="Gender",
837
+ value="Not specified",
838
+ info="Character's gender"
839
+ )
840
+
841
+ skin_tone_dropdown = gr.Dropdown(
842
+ choices=RACE_SKIN_TONES[DnDRace.HUMAN], # Default to Human
843
+ label="Skin Tone / Color",
844
+ value=None,
845
+ info="Select appropriate color for the race"
846
+ )
847
+
848
+ generate_name_btn = gr.Button("🎲 Generate Name", variant="secondary", size="sm")
849
+
850
+ level_slider = gr.Slider(
851
+ minimum=1,
852
+ maximum=20,
853
+ value=1,
854
+ step=1,
855
+ label="Level",
856
+ info="Character level (1-20)"
857
+ )
858
+
859
+ alignment_dropdown = gr.Dropdown(
860
+ choices=[
861
+ "Lawful Good", "Neutral Good", "Chaotic Good",
862
+ "Lawful Neutral", "True Neutral", "Chaotic Neutral",
863
+ "Lawful Evil", "Neutral Evil", "Chaotic Evil"
864
+ ],
865
+ label="Alignment",
866
+ value="True Neutral",
867
+ info="Character's moral alignment"
868
+ )
869
+
870
+ with gr.Column():
871
+ gr.Markdown("#### Background & Personality")
872
+
873
+ background_dropdown = gr.Dropdown(
874
+ choices=[
875
+ "Acolyte", "Charlatan", "Criminal", "Entertainer",
876
+ "Folk Hero", "Guild Artisan", "Hermit", "Noble",
877
+ "Outlander", "Sage", "Sailor", "Soldier",
878
+ "Urchin", "Custom (enter below)"
879
+ ],
880
+ label="Background Type",
881
+ value="Soldier",
882
+ info="Select from D&D 5e backgrounds or choose Custom"
883
+ )
884
+
885
+ custom_background_input = gr.Textbox(
886
+ label="Custom Background",
887
+ placeholder="Enter your custom background...",
888
+ value="",
889
+ visible=False,
890
+ info="Only used if 'Custom' is selected above"
891
+ )
892
+
893
+ use_ai_background = gr.Checkbox(
894
+ label="Generate detailed backstory",
895
+ value=True,
896
+ info="Create a unique backstory for this character"
897
+ )
898
+
899
+ personality_input = gr.Textbox(
900
+ label="Personality Guidance (Optional)",
901
+ placeholder="A mysterious ranger who protects the forest...",
902
+ lines=3,
903
+ info="Guide AI in creating personality (if enabled)"
904
+ )
905
+
906
+ stats_method = gr.Radio(
907
+ choices=["standard_array", "roll", "point_buy"],
908
+ label="Ability Score Method",
909
+ value="standard_array",
910
+ info="How to generate ability scores"
911
+ )
912
+
913
+ create_btn = gr.Button("βš”οΈ Create Character", variant="primary", size="lg")
914
+
915
+ gr.Markdown("---")
916
+
917
+ with gr.Row():
918
+ character_output = gr.Markdown(label="Character Sheet")
919
+ status_output = gr.Textbox(label="Status", lines=8)
920
+
921
+ # Toggle custom background visibility
922
+ def toggle_custom_background(background_choice):
923
+ return gr.update(visible=background_choice == "Custom (enter below)")
924
+
925
+ # Update skin tone options when race changes
926
+ def update_skin_tone_choices(race: str):
927
+ try:
928
+ race_enum = DnDRace(race)
929
+ skin_tones = RACE_SKIN_TONES.get(race_enum, RACE_SKIN_TONES[DnDRace.HUMAN])
930
+ return gr.update(choices=skin_tones, value=skin_tones[0] if skin_tones else None)
931
+ except:
932
+ return gr.update(choices=RACE_SKIN_TONES[DnDRace.HUMAN], value=RACE_SKIN_TONES[DnDRace.HUMAN][0])
933
+
934
+ background_dropdown.change(
935
+ fn=toggle_custom_background,
936
+ inputs=[background_dropdown],
937
+ outputs=[custom_background_input]
938
+ )
939
+
940
+ race_dropdown.change(
941
+ fn=update_skin_tone_choices,
942
+ inputs=[race_dropdown],
943
+ outputs=[skin_tone_dropdown]
944
+ )
945
+
946
+ # Generate name action - includes alignment for more thematic names
947
+ generate_name_btn.click(
948
+ fn=self.generate_name_ui,
949
+ inputs=[race_dropdown, class_dropdown, gender_dropdown, alignment_dropdown],
950
+ outputs=[name_input]
951
+ )
952
+
953
+ # Create character action
954
+ create_btn.click(
955
+ fn=self.create_character_ui,
956
+ inputs=[
957
+ name_input,
958
+ race_dropdown,
959
+ class_dropdown,
960
+ level_slider,
961
+ gender_dropdown,
962
+ skin_tone_dropdown,
963
+ alignment_dropdown,
964
+ background_dropdown,
965
+ custom_background_input,
966
+ personality_input,
967
+ stats_method,
968
+ use_ai_background,
969
+ ],
970
+ outputs=[character_output, status_output]
971
+ )
972
+
973
+ # Tab 2: Load Character
974
+ with gr.Tab("Load Character"):
975
+ gr.Markdown("### Load Saved Character")
976
+
977
+ load_char_refresh_btn = gr.Button("πŸ”„ Refresh Character List", variant="secondary")
978
+
979
+ character_dropdown = gr.Dropdown(
980
+ choices=[],
981
+ label="Select Character",
982
+ info="Choose a character from the list (type to search)",
983
+ allow_custom_value=False,
984
+ interactive=True
985
+ )
986
+
987
+ with gr.Row():
988
+ load_btn = gr.Button("πŸ“‚ Load Character", variant="primary")
989
+ list_btn = gr.Button("πŸ“‹ List All Characters")
990
+
991
+ gr.Markdown("---")
992
+
993
+ with gr.Row():
994
+ loaded_character_output = gr.Markdown(label="Character Sheet")
995
+ load_status_output = gr.Textbox(label="Status", lines=6)
996
+
997
+ # Refresh character dropdown
998
+ def refresh_character_dropdown():
999
+ choices = self.get_character_dropdown_choices()
1000
+ return gr.update(choices=choices, value=None)
1001
+
1002
+ load_char_refresh_btn.click(
1003
+ fn=refresh_character_dropdown,
1004
+ inputs=[],
1005
+ outputs=[character_dropdown]
1006
+ )
1007
+
1008
+ # Load character action - convert dropdown label to ID
1009
+ def load_character_from_dropdown(label):
1010
+ char_id = self.get_character_id_from_label(label)
1011
+ return self.load_character_ui(char_id)
1012
+
1013
+ load_btn.click(
1014
+ fn=load_character_from_dropdown,
1015
+ inputs=[character_dropdown],
1016
+ outputs=[loaded_character_output, load_status_output]
1017
+ )
1018
+
1019
+ # List characters action
1020
+ list_btn.click(
1021
+ fn=self.list_characters_ui,
1022
+ inputs=[],
1023
+ outputs=[loaded_character_output, load_status_output]
1024
+ )
1025
+
1026
+ # Tab 3: Manage Characters
1027
+ with gr.Tab("Manage Characters"):
1028
+ gr.Markdown("### Character Management")
1029
+
1030
+ delete_refresh_btn = gr.Button("πŸ”„ Refresh Character List", variant="secondary")
1031
+
1032
+ delete_character_dropdown = gr.Dropdown(
1033
+ choices=[],
1034
+ label="Select Character to Delete",
1035
+ info="⚠️ Warning: This action cannot be undone! (type to search)",
1036
+ allow_custom_value=False,
1037
+ interactive=True
1038
+ )
1039
+
1040
+ delete_btn = gr.Button("πŸ—‘οΈ Delete Character", variant="stop")
1041
+
1042
+ delete_status_output = gr.Textbox(label="Status", lines=3)
1043
+
1044
+ # Refresh delete character dropdown
1045
+ def refresh_delete_dropdown():
1046
+ choices = self.get_character_dropdown_choices()
1047
+ return gr.update(choices=choices, value=None)
1048
+
1049
+ delete_refresh_btn.click(
1050
+ fn=refresh_delete_dropdown,
1051
+ inputs=[],
1052
+ outputs=[delete_character_dropdown]
1053
+ )
1054
+
1055
+ # Delete character action - convert dropdown label to ID
1056
+ def delete_character_from_dropdown(label):
1057
+ char_id = self.get_character_id_from_label(label)
1058
+ return self.delete_character_ui(char_id)
1059
+
1060
+ delete_btn.click(
1061
+ fn=delete_character_from_dropdown,
1062
+ inputs=[delete_character_dropdown],
1063
+ outputs=[delete_status_output]
1064
+ )
1065
+
1066
+ gr.Markdown("---")
1067
+
1068
+ with gr.Accordion("Quick Actions", open=False):
1069
+ quick_list_btn = gr.Button("πŸ“‹ List All Characters")
1070
+ quick_list_output = gr.Markdown(label="Character List")
1071
+ quick_status = gr.Textbox(label="Status", lines=2)
1072
+
1073
+ quick_list_btn.click(
1074
+ fn=self.list_characters_ui,
1075
+ inputs=[],
1076
+ outputs=[quick_list_output, quick_status]
1077
+ )
1078
+
1079
+ # Tab 4: Generate Portrait
1080
+ with gr.Tab("Generate Portrait"):
1081
+ gr.Markdown("""
1082
+ ### 🎨 AI Character Portrait Generator
1083
+ Generate stunning character portraits using DALL-E 3 or HuggingFace!
1084
+ """)
1085
+
1086
+ with gr.Row():
1087
+ with gr.Column():
1088
+ portrait_refresh_btn = gr.Button("πŸ”„ Refresh Character List", variant="secondary")
1089
+
1090
+ portrait_character_dropdown = gr.Dropdown(
1091
+ choices=[],
1092
+ label="Select Character",
1093
+ info="Choose a character to generate portrait for (type to search)",
1094
+ allow_custom_value=False,
1095
+ interactive=True
1096
+ )
1097
+
1098
+ portrait_provider = gr.Radio(
1099
+ choices=["auto", "openai", "huggingface"],
1100
+ label="Image Provider",
1101
+ value="auto",
1102
+ info="Auto: Try OpenAI first, fallback to HuggingFace if needed"
1103
+ )
1104
+
1105
+ portrait_style = gr.Dropdown(
1106
+ choices=[
1107
+ "fantasy art",
1108
+ "digital painting",
1109
+ "anime style",
1110
+ "oil painting",
1111
+ "watercolor",
1112
+ "comic book art",
1113
+ "concept art"
1114
+ ],
1115
+ label="Art Style",
1116
+ value="fantasy art",
1117
+ info="Choose the artistic style"
1118
+ )
1119
+
1120
+ portrait_quality = gr.Radio(
1121
+ choices=["standard", "hd"],
1122
+ label="Image Quality (OpenAI only)",
1123
+ value="standard",
1124
+ info="HD costs more tokens (OpenAI only)"
1125
+ )
1126
+
1127
+ generate_portrait_btn = gr.Button(
1128
+ "🎨 Generate Portrait",
1129
+ variant="primary",
1130
+ size="lg"
1131
+ )
1132
+
1133
+ portrait_status = gr.Textbox(
1134
+ label="Status",
1135
+ lines=4
1136
+ )
1137
+
1138
+ with gr.Column():
1139
+ portrait_output = gr.Image(
1140
+ label="Generated Portrait",
1141
+ type="filepath",
1142
+ height=512
1143
+ )
1144
+
1145
+ gr.Markdown("""
1146
+ **Providers:**
1147
+ - **OpenAI DALL-E 3**: High quality, costs $0.04/image (standard) or $0.08/image (HD)
1148
+ - **HuggingFace (Free!)**: Stable Diffusion XL, ~100 requests/day on free tier
1149
+ - **Auto**: Tries OpenAI first, automatically falls back to HuggingFace if billing issues
1150
+
1151
+ Portraits are automatically saved to `data/portraits/` directory.
1152
+
1153
+ **Tips:**
1154
+ - Use "auto" mode for seamless fallback
1155
+ - OpenAI HD quality produces better results but costs 2x
1156
+ - HuggingFace is free but may have a 30-60s warm-up time
1157
+ - Different styles work better for different races/classes
1158
+ """)
1159
+
1160
+ # Refresh portrait character dropdown
1161
+ def refresh_portrait_dropdown():
1162
+ choices = self.get_character_dropdown_choices()
1163
+ return gr.update(choices=choices, value=None)
1164
+
1165
+ portrait_refresh_btn.click(
1166
+ fn=refresh_portrait_dropdown,
1167
+ inputs=[],
1168
+ outputs=[portrait_character_dropdown]
1169
+ )
1170
+
1171
+ # Generate portrait action - convert dropdown label to ID
1172
+ def generate_portrait_from_dropdown(label, style, quality, provider):
1173
+ char_id = self.get_character_id_from_label(label)
1174
+ return self.generate_portrait_ui(char_id, style, quality, provider)
1175
+
1176
+ generate_portrait_btn.click(
1177
+ fn=generate_portrait_from_dropdown,
1178
+ inputs=[portrait_character_dropdown, portrait_style, portrait_quality, portrait_provider],
1179
+ outputs=[portrait_output, portrait_status]
1180
+ )
1181
+
1182
+ # Tab 5: Export Character Sheet
1183
+ with gr.Tab("Export Character Sheet"):
1184
+ gr.Markdown("""
1185
+ ### πŸ“„ Export Character Sheets
1186
+ Export your characters to formatted character sheets in multiple formats!
1187
+ """)
1188
+
1189
+ with gr.Row():
1190
+ with gr.Column():
1191
+ export_char_refresh_btn = gr.Button("πŸ”„ Refresh Character List", variant="secondary")
1192
+
1193
+ export_character_dropdown = gr.Dropdown(
1194
+ choices=[],
1195
+ label="Select Character",
1196
+ info="Choose a character to export (type to search)",
1197
+ allow_custom_value=False,
1198
+ interactive=True
1199
+ )
1200
+
1201
+ export_format = gr.Radio(
1202
+ choices=["markdown", "json", "html"],
1203
+ label="Export Format",
1204
+ value="markdown",
1205
+ info="Choose the format for your character sheet"
1206
+ )
1207
+
1208
+ with gr.Row():
1209
+ preview_btn = gr.Button("πŸ‘οΈ Preview", variant="secondary")
1210
+ export_btn = gr.Button("πŸ’Ύ Export to File", variant="primary")
1211
+
1212
+ export_status = gr.Textbox(
1213
+ label="Status",
1214
+ lines=6
1215
+ )
1216
+
1217
+ with gr.Column():
1218
+ preview_output = gr.Markdown(
1219
+ label="Preview",
1220
+ value="Character sheet preview will appear here..."
1221
+ )
1222
+
1223
+ gr.Markdown("""
1224
+ ### Format Descriptions
1225
+
1226
+ **Markdown (.md)**
1227
+ - Clean, readable text format with tables
1228
+ - Perfect for sharing in Discord, GitHub, or note apps
1229
+ - Includes all character stats, features, and background
1230
+ - Easy to read and edit
1231
+
1232
+ **JSON (.json)**
1233
+ - Structured data format
1234
+ - Perfect for importing into other tools or programs
1235
+ - Contains all character data in a machine-readable format
1236
+ - Great for backup or data transfer
1237
+
1238
+ **HTML (.html)**
1239
+ - Styled character sheet that can be opened in a browser
1240
+ - Print-ready format (mimics official D&D character sheet)
1241
+ - Beautiful parchment styling with maroon borders
1242
+ - Can be converted to PDF using browser's print function
1243
+
1244
+ All exports are saved to the `data/exports/` directory.
1245
+ """)
1246
+
1247
+ # Refresh export character dropdown
1248
+ def refresh_export_dropdown():
1249
+ choices = self.get_character_dropdown_choices()
1250
+ return gr.update(choices=choices, value=None)
1251
+
1252
+ export_char_refresh_btn.click(
1253
+ fn=refresh_export_dropdown,
1254
+ inputs=[],
1255
+ outputs=[export_character_dropdown]
1256
+ )
1257
+
1258
+ # Preview action - convert dropdown label to ID
1259
+ def preview_from_dropdown(label, format):
1260
+ char_id = self.get_character_id_from_label(label)
1261
+ return self.preview_export_ui(char_id, format)
1262
+
1263
+ preview_btn.click(
1264
+ fn=preview_from_dropdown,
1265
+ inputs=[export_character_dropdown, export_format],
1266
+ outputs=[preview_output, export_status]
1267
+ )
1268
+
1269
+ # Export action - convert dropdown label to ID
1270
+ def export_from_dropdown(label, format):
1271
+ char_id = self.get_character_id_from_label(label)
1272
+ return self.export_character_sheet_ui(char_id, format)
1273
+
1274
+ export_btn.click(
1275
+ fn=export_from_dropdown,
1276
+ inputs=[export_character_dropdown, export_format],
1277
+ outputs=[export_status]
1278
+ )
1279
+
1280
+ # Tab 6: Campaign Management
1281
+ with gr.Tab("Campaign Management"):
1282
+ gr.Markdown("""
1283
+ ### 🎲 Campaign Management
1284
+ Create and manage your D&D campaigns, track sessions, and record events!
1285
+ """)
1286
+
1287
+ with gr.Tabs():
1288
+ # Sub-tab: Synthesize Campaign
1289
+ with gr.Tab("πŸ€– Synthesize Campaign"):
1290
+ gr.Markdown("""
1291
+ ### Campaign Synthesis
1292
+ Select characters and automatically create a custom campaign tailored to your party!
1293
+ """)
1294
+
1295
+ # Load character button
1296
+ load_characters_btn = gr.Button("πŸ”„ Load Available Characters", variant="secondary")
1297
+
1298
+ with gr.Row():
1299
+ with gr.Column():
1300
+ synth_character_select = gr.CheckboxGroup(
1301
+ choices=[],
1302
+ label="Select Characters for Campaign",
1303
+ info="Choose characters to include in your campaign"
1304
+ )
1305
+
1306
+ synth_gm_name = gr.Textbox(
1307
+ label="Game Master Name",
1308
+ placeholder="Your name",
1309
+ info="DM/GM running the campaign"
1310
+ )
1311
+
1312
+ synth_notes = gr.Textbox(
1313
+ label="Additional Notes (Optional)",
1314
+ placeholder="Any specific themes, settings, or elements you'd like included...",
1315
+ lines=4,
1316
+ info="Guide the campaign creation process"
1317
+ )
1318
+
1319
+ synthesize_btn = gr.Button("✨ Synthesize Campaign", variant="primary", size="lg")
1320
+
1321
+ with gr.Column():
1322
+ gr.Markdown("""
1323
+ ### How It Works
1324
+
1325
+ 1. **Load Characters**: Click "Load Available Characters" to see all your characters
1326
+ 2. **Select Party**: Check the boxes for characters you want in the campaign
1327
+ 3. **Add Context**: Optionally provide DM notes about themes or preferences
1328
+ 4. **Analyze Party**: The system analyzes:
1329
+ - Character backstories and motivations
1330
+ - Party composition and level
1331
+ - Alignments and backgrounds
1332
+ - Character personalities
1333
+ 5. **Campaign Created**: Get a fully-fledged campaign that:
1334
+ - Ties into character backstories
1335
+ - Provides appropriate challenges
1336
+ - Creates opportunities for each character
1337
+ - Includes factions, villains, and mysteries
1338
+
1339
+ **Example Party:**
1340
+ - Thorin Ironforge (Dwarf Fighter, Level 3)
1341
+ - Elara Moonwhisper (Elf Wizard, Level 3)
1342
+ - Grimm Shadowstep (Halfling Rogue, Level 3)
1343
+
1344
+ The system will create a campaign that weaves their stories together!
1345
+ """)
1346
+
1347
+ synth_status = gr.Textbox(label="Campaign Details", lines=20)
1348
+
1349
+ # Load characters button handler
1350
+ def update_character_choices():
1351
+ choices = self.get_character_choices_ui()
1352
+ if not choices:
1353
+ return gr.update(choices=[], value=[])
1354
+ return gr.update(choices=choices, value=[])
1355
+
1356
+ load_characters_btn.click(
1357
+ fn=update_character_choices,
1358
+ inputs=[],
1359
+ outputs=[synth_character_select]
1360
+ )
1361
+
1362
+ synthesize_btn.click(
1363
+ fn=self.synthesize_campaign_ui,
1364
+ inputs=[synth_character_select, synth_gm_name, synth_notes],
1365
+ outputs=[synth_status]
1366
+ )
1367
+
1368
+ # Sub-tab: Create Campaign
1369
+ with gr.Tab("Create Campaign"):
1370
+ gr.Markdown("### Create New Campaign")
1371
+
1372
+ with gr.Row():
1373
+ with gr.Column():
1374
+ campaign_name = gr.Textbox(
1375
+ label="Campaign Name",
1376
+ placeholder="The Shattered Crown",
1377
+ info="Name of your campaign"
1378
+ )
1379
+
1380
+ campaign_theme = gr.Dropdown(
1381
+ choices=[theme.value for theme in CampaignTheme],
1382
+ label="Campaign Theme",
1383
+ value="High Fantasy",
1384
+ info="Select the theme of your campaign"
1385
+ )
1386
+
1387
+ campaign_gm = gr.Textbox(
1388
+ label="Game Master Name",
1389
+ placeholder="Your name",
1390
+ info="DM/GM running the campaign"
1391
+ )
1392
+
1393
+ campaign_party_size = gr.Slider(
1394
+ minimum=1,
1395
+ maximum=10,
1396
+ value=4,
1397
+ step=1,
1398
+ label="Party Size",
1399
+ info="Expected number of players"
1400
+ )
1401
+
1402
+ campaign_level_range = gr.Textbox(
1403
+ label="Level Range",
1404
+ value="1-5",
1405
+ placeholder="1-5",
1406
+ info="Expected level range for the campaign"
1407
+ )
1408
+
1409
+ with gr.Column():
1410
+ campaign_world = gr.Textbox(
1411
+ label="World Name",
1412
+ placeholder="Forgotten Realms",
1413
+ info="Name of your world/realm"
1414
+ )
1415
+
1416
+ campaign_starting = gr.Textbox(
1417
+ label="Starting Location",
1418
+ placeholder="Phandalin",
1419
+ info="Where the adventure begins"
1420
+ )
1421
+
1422
+ campaign_setting = gr.Textbox(
1423
+ label="Setting Description",
1424
+ placeholder="A war-torn kingdom...",
1425
+ lines=3,
1426
+ info="Describe the campaign setting"
1427
+ )
1428
+
1429
+ campaign_summary = gr.Textbox(
1430
+ label="Campaign Summary",
1431
+ placeholder="The adventurers must...",
1432
+ lines=3,
1433
+ info="Brief campaign hook/summary"
1434
+ )
1435
+
1436
+ campaign_conflict = gr.Textbox(
1437
+ label="Main Conflict",
1438
+ placeholder="A succession crisis threatens the kingdom",
1439
+ lines=2,
1440
+ info="Central conflict/tension"
1441
+ )
1442
+
1443
+ create_campaign_btn = gr.Button("βš”οΈ Create Campaign", variant="primary", size="lg")
1444
+
1445
+ campaign_create_status = gr.Textbox(label="Status", lines=8)
1446
+
1447
+ create_campaign_btn.click(
1448
+ fn=self.create_campaign_ui,
1449
+ inputs=[
1450
+ campaign_name,
1451
+ campaign_theme,
1452
+ campaign_setting,
1453
+ campaign_summary,
1454
+ campaign_conflict,
1455
+ campaign_gm,
1456
+ campaign_world,
1457
+ campaign_starting,
1458
+ campaign_level_range,
1459
+ campaign_party_size
1460
+ ],
1461
+ outputs=[campaign_create_status]
1462
+ )
1463
+
1464
+ # Sub-tab: Manage Campaign
1465
+ with gr.Tab("Manage Campaign"):
1466
+ gr.Markdown("### Manage Campaign")
1467
+
1468
+ manage_campaign_refresh_btn = gr.Button("πŸ”„ Refresh Campaign List", variant="secondary")
1469
+
1470
+ manage_campaign_dropdown = gr.Dropdown(
1471
+ choices=[],
1472
+ label="Select Campaign",
1473
+ info="Choose a campaign to view (type to search)",
1474
+ allow_custom_value=False,
1475
+ interactive=True
1476
+ )
1477
+
1478
+ with gr.Row():
1479
+ load_campaign_btn = gr.Button("πŸ“‚ Load Campaign", variant="primary")
1480
+ list_campaigns_btn = gr.Button("πŸ“‹ List All Campaigns")
1481
+
1482
+ gr.Markdown("---")
1483
+
1484
+ with gr.Row():
1485
+ campaign_details = gr.Markdown(label="Campaign Details")
1486
+ campaign_status = gr.Textbox(label="Status", lines=6)
1487
+
1488
+ # Refresh campaign dropdown
1489
+ def refresh_manage_campaign_dropdown():
1490
+ choices = self.get_campaign_dropdown_choices()
1491
+ return gr.update(choices=choices, value=None)
1492
+
1493
+ manage_campaign_refresh_btn.click(
1494
+ fn=refresh_manage_campaign_dropdown,
1495
+ inputs=[],
1496
+ outputs=[manage_campaign_dropdown]
1497
+ )
1498
+
1499
+ # Load campaign - convert dropdown label to ID
1500
+ def load_campaign_from_dropdown(label):
1501
+ campaign_id = self.get_campaign_id_from_label(label)
1502
+ return self.load_campaign_ui(campaign_id)
1503
+
1504
+ load_campaign_btn.click(
1505
+ fn=load_campaign_from_dropdown,
1506
+ inputs=[manage_campaign_dropdown],
1507
+ outputs=[campaign_details, campaign_status]
1508
+ )
1509
+
1510
+ list_campaigns_btn.click(
1511
+ fn=self.list_campaigns_ui,
1512
+ inputs=[],
1513
+ outputs=[campaign_details, campaign_status]
1514
+ )
1515
+
1516
+ # Sub-tab: Add Characters
1517
+ with gr.Tab("Add Characters"):
1518
+ gr.Markdown("### Add Characters to Campaign")
1519
+
1520
+ add_char_refresh_btn = gr.Button("πŸ”„ Refresh Lists", variant="secondary")
1521
+
1522
+ with gr.Row():
1523
+ add_char_campaign_dropdown = gr.Dropdown(
1524
+ choices=[],
1525
+ label="Select Campaign",
1526
+ info="Choose the campaign to add characters to (type to search)",
1527
+ allow_custom_value=False,
1528
+ interactive=True
1529
+ )
1530
+
1531
+ add_char_character_dropdown = gr.Dropdown(
1532
+ choices=[],
1533
+ label="Select Character",
1534
+ info="Choose the character to add (type to search)",
1535
+ allow_custom_value=False,
1536
+ interactive=True
1537
+ )
1538
+
1539
+ add_char_btn = gr.Button("βž• Add Character to Campaign", variant="primary")
1540
+
1541
+ add_char_status = gr.Textbox(label="Status", lines=4)
1542
+
1543
+ # Refresh both dropdowns
1544
+ def refresh_add_char_dropdowns():
1545
+ campaign_choices = self.get_campaign_dropdown_choices()
1546
+ character_choices = self.get_character_dropdown_choices()
1547
+ return (
1548
+ gr.update(choices=campaign_choices, value=None),
1549
+ gr.update(choices=character_choices, value=None)
1550
+ )
1551
+
1552
+ add_char_refresh_btn.click(
1553
+ fn=refresh_add_char_dropdowns,
1554
+ inputs=[],
1555
+ outputs=[add_char_campaign_dropdown, add_char_character_dropdown]
1556
+ )
1557
+
1558
+ # Add character - convert dropdown labels to IDs
1559
+ def add_char_from_dropdowns(campaign_label, character_label):
1560
+ campaign_id = self.get_campaign_id_from_label(campaign_label)
1561
+ character_id = self.get_character_id_from_label(character_label)
1562
+ return self.add_character_to_campaign_ui(campaign_id, character_id)
1563
+
1564
+ add_char_btn.click(
1565
+ fn=add_char_from_dropdowns,
1566
+ inputs=[add_char_campaign_dropdown, add_char_character_dropdown],
1567
+ outputs=[add_char_status]
1568
+ )
1569
+
1570
+ gr.Markdown("""
1571
+ **Tip:** Click "Refresh Lists" to load your campaigns and characters.
1572
+ """)
1573
+
1574
+ # Sub-tab: Session Tracking
1575
+ with gr.Tab("Session Tracking"):
1576
+ gr.Markdown("### Track Campaign Sessions")
1577
+
1578
+ session_refresh_btn = gr.Button("πŸ”„ Refresh Campaign List", variant="secondary")
1579
+
1580
+ session_campaign_dropdown = gr.Dropdown(
1581
+ choices=[],
1582
+ label="Select Campaign",
1583
+ info="Choose the campaign for session tracking (type to search)",
1584
+ allow_custom_value=False,
1585
+ interactive=True
1586
+ )
1587
+
1588
+ start_session_btn = gr.Button("🎬 Start New Session", variant="primary")
1589
+
1590
+ session_status = gr.Textbox(label="Status", lines=4)
1591
+
1592
+ # Refresh session campaign dropdown
1593
+ def refresh_session_dropdown():
1594
+ choices = self.get_campaign_dropdown_choices()
1595
+ return gr.update(choices=choices, value=None)
1596
+
1597
+ session_refresh_btn.click(
1598
+ fn=refresh_session_dropdown,
1599
+ inputs=[],
1600
+ outputs=[session_campaign_dropdown]
1601
+ )
1602
+
1603
+ # Start session - convert dropdown label to ID
1604
+ def start_session_from_dropdown(label):
1605
+ campaign_id = self.get_campaign_id_from_label(label)
1606
+ return self.start_session_ui(campaign_id)
1607
+
1608
+ start_session_btn.click(
1609
+ fn=start_session_from_dropdown,
1610
+ inputs=[session_campaign_dropdown],
1611
+ outputs=[session_status]
1612
+ )
1613
+
1614
+ gr.Markdown("---")
1615
+
1616
+ gr.Markdown("### πŸ€– Auto-Generate Next Session")
1617
+ gr.Markdown("""
1618
+ **Autonomous Feature:** AI analyzes your campaign and automatically generates a complete session plan.
1619
+
1620
+ This includes:
1621
+ - Opening scene narration
1622
+ - Key encounters (combat, social, exploration)
1623
+ - NPCs featured in the session
1624
+ - Locations to visit
1625
+ - Plot developments
1626
+ - Potential outcomes and rewards
1627
+ """)
1628
+
1629
+ auto_session_refresh_btn = gr.Button("πŸ”„ Refresh Campaign List", variant="secondary")
1630
+
1631
+ auto_session_campaign_dropdown = gr.Dropdown(
1632
+ choices=[],
1633
+ label="Select Campaign",
1634
+ info="Choose campaign to generate next session for",
1635
+ allow_custom_value=False,
1636
+ interactive=True
1637
+ )
1638
+
1639
+ auto_generate_session_btn = gr.Button("✨ Auto-Generate Next Session", variant="primary")
1640
+
1641
+ auto_session_output = gr.Textbox(label="Generated Session Plan", lines=20)
1642
+
1643
+ # Refresh auto-session campaign dropdown
1644
+ def refresh_auto_session_dropdown():
1645
+ choices = self.get_campaign_dropdown_choices()
1646
+ return gr.update(choices=choices, value=None)
1647
+
1648
+ auto_session_refresh_btn.click(
1649
+ fn=refresh_auto_session_dropdown,
1650
+ inputs=[],
1651
+ outputs=[auto_session_campaign_dropdown]
1652
+ )
1653
+
1654
+ # Auto-generate session
1655
+ def auto_generate_session_from_dropdown(label):
1656
+ campaign_id = self.get_campaign_id_from_label(label)
1657
+ return self.auto_generate_session_ui(campaign_id)
1658
+
1659
+ auto_generate_session_btn.click(
1660
+ fn=auto_generate_session_from_dropdown,
1661
+ inputs=[auto_session_campaign_dropdown],
1662
+ outputs=[auto_session_output]
1663
+ )
1664
+
1665
+ gr.Markdown("---")
1666
+
1667
+ gr.Markdown("### Add Session Event")
1668
+
1669
+ event_refresh_btn = gr.Button("πŸ”„ Refresh Campaign List", variant="secondary")
1670
+
1671
+ event_campaign_dropdown = gr.Dropdown(
1672
+ choices=[],
1673
+ label="Select Campaign",
1674
+ info="Choose the campaign to add event to (type to search)",
1675
+ allow_custom_value=False,
1676
+ interactive=True
1677
+ )
1678
+
1679
+ event_type = gr.Dropdown(
1680
+ choices=["Combat", "Social", "Exploration", "Discovery", "Plot Development", "Character Moment", "NPC Interaction", "Quest Update"],
1681
+ label="Event Type",
1682
+ value="Combat",
1683
+ info="Type of event"
1684
+ )
1685
+
1686
+ event_title = gr.Textbox(
1687
+ label="Event Title",
1688
+ placeholder="Battle at the Bridge",
1689
+ info="Short title for the event"
1690
+ )
1691
+
1692
+ event_description = gr.Textbox(
1693
+ label="Event Description",
1694
+ placeholder="The party encountered a group of bandits...",
1695
+ lines=4,
1696
+ info="Detailed description of what happened"
1697
+ )
1698
+
1699
+ event_importance = gr.Slider(
1700
+ minimum=1,
1701
+ maximum=5,
1702
+ value=3,
1703
+ step=1,
1704
+ label="Importance",
1705
+ info="How important is this event? (1-5 stars)"
1706
+ )
1707
+
1708
+ add_event_btn = gr.Button("πŸ“ Add Event", variant="primary")
1709
+
1710
+ event_status = gr.Textbox(label="Status", lines=6)
1711
+
1712
+ # Refresh event campaign dropdown
1713
+ def refresh_event_dropdown():
1714
+ choices = self.get_campaign_dropdown_choices()
1715
+ return gr.update(choices=choices, value=None)
1716
+
1717
+ event_refresh_btn.click(
1718
+ fn=refresh_event_dropdown,
1719
+ inputs=[],
1720
+ outputs=[event_campaign_dropdown]
1721
+ )
1722
+
1723
+ # Add event - convert dropdown label to ID
1724
+ def add_event_from_dropdown(campaign_label, event_type_val, title, description, importance):
1725
+ campaign_id = self.get_campaign_id_from_label(campaign_label)
1726
+ return self.add_event_ui(campaign_id, event_type_val, title, description, importance)
1727
+
1728
+ add_event_btn.click(
1729
+ fn=add_event_from_dropdown,
1730
+ inputs=[
1731
+ event_campaign_dropdown,
1732
+ event_type,
1733
+ event_title,
1734
+ event_description,
1735
+ event_importance
1736
+ ],
1737
+ outputs=[event_status]
1738
+ )
1739
+
1740
+ # Tab 7: About
1741
+ with gr.Tab("About"):
1742
+ gr.Markdown("""
1743
+ ## About D'n'D Campaign Manager
1744
+
1745
+ **Version:** 2.0.0
1746
+ **Built for:** Gradio + Anthropic MCP Hackathon
1747
+
1748
+ ### Features
1749
+ - 🎲 Complete D&D 5e character creation
1750
+ - πŸ“– Automated name and backstory generation
1751
+ - 🎨 Character portrait generation (DALL-E 3 / HuggingFace SDXL)
1752
+ - πŸ“Š Multiple ability score methods (Standard Array, Roll, Point Buy)
1753
+ - πŸ’Ύ Database persistence
1754
+ - πŸ“„ Markdown character sheet export
1755
+ - βœ… Full data validation
1756
+
1757
+ ### Stat Methods
1758
+ - **Standard Array:** 15, 14, 13, 12, 10, 8 (balanced)
1759
+ - **Roll:** 4d6 drop lowest (random)
1760
+ - **Point Buy:** 27 points (customizable)
1761
+
1762
+ ### Supported Races
1763
+ Human, Elf, Dwarf, Halfling, Dragonborn, Gnome, Half-Elf, Half-Orc, Tiefling
1764
+
1765
+ ### Supported Classes
1766
+ Barbarian, Bard, Cleric, Druid, Fighter, Monk, Paladin, Ranger, Rogue, Sorcerer, Warlock, Wizard
1767
+
1768
+ ### Tech Stack
1769
+ - **AI:** Anthropic Claude / Google Gemini / OpenAI DALL-E 3
1770
+ - **Framework:** Gradio
1771
+ - **Database:** SQLite
1772
+ - **Validation:** Pydantic
1773
+
1774
+ ---
1775
+
1776
+ *Built with ❀️ for the TTRPG community*
1777
+ """)
1778
+
1779
+ gr.Markdown("""
1780
+ ---
1781
+ ### Tips
1782
+ - Enable "Use AI" options for more creative and detailed characters
1783
+ - Use Standard Array for balanced characters
1784
+ - Use Roll for random variation
1785
+ - Save your character ID to load it later
1786
+ - Check the character list to find previously created characters
1787
+ """)
1788
+
1789
+ # Auto-populate dropdowns on interface load
1790
+ def populate_all_dropdowns():
1791
+ """Populate all dropdowns when interface loads"""
1792
+ campaign_choices = self.get_campaign_dropdown_choices()
1793
+ character_choices = self.get_character_dropdown_choices()
1794
+
1795
+ return [
1796
+ gr.update(choices=character_choices), # character_dropdown
1797
+ gr.update(choices=character_choices), # delete_character_dropdown
1798
+ gr.update(choices=character_choices), # portrait_character_dropdown
1799
+ gr.update(choices=character_choices), # export_character_dropdown
1800
+ gr.update(choices=campaign_choices), # manage_campaign_dropdown
1801
+ gr.update(choices=campaign_choices), # add_char_campaign_dropdown
1802
+ gr.update(choices=character_choices), # add_char_character_dropdown
1803
+ gr.update(choices=campaign_choices), # session_campaign_dropdown
1804
+ gr.update(choices=campaign_choices), # auto_session_campaign_dropdown
1805
+ gr.update(choices=campaign_choices), # event_campaign_dropdown
1806
+ ]
1807
+
1808
+ interface.load(
1809
+ fn=populate_all_dropdowns,
1810
+ inputs=[],
1811
+ outputs=[
1812
+ character_dropdown,
1813
+ delete_character_dropdown,
1814
+ portrait_character_dropdown,
1815
+ export_character_dropdown,
1816
+ manage_campaign_dropdown,
1817
+ add_char_campaign_dropdown,
1818
+ add_char_character_dropdown,
1819
+ session_campaign_dropdown,
1820
+ auto_session_campaign_dropdown,
1821
+ event_campaign_dropdown,
1822
+ ]
1823
+ )
1824
+
1825
+ return interface
1826
+
1827
+
1828
+ def launch_ui():
1829
+ """Launch the Gradio interface"""
1830
+ ui = CharacterCreatorUI()
1831
+ interface = ui.create_interface()
1832
+
1833
+ interface.launch(
1834
+ server_name="0.0.0.0",
1835
+ server_port=7860,
1836
+ share=False,
1837
+ show_error=True
1838
+ )
1839
+
1840
+
1841
+ if __name__ == "__main__":
1842
+ launch_ui()
src/ui/components/__init__.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ """
2
+ Reusable UI components for D'n'D Campaign Manager
3
+ """
4
+
5
+ from .dropdown_manager import DropdownManager
6
+
7
+ __all__ = ["DropdownManager"]
src/ui/components/dropdown_manager.py ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Dropdown Manager for D'n'D Campaign Manager
3
+ Handles dropdown creation and auto-population logic
4
+ """
5
+
6
+ import gradio as gr
7
+ from typing import List, Tuple, Optional
8
+ from src.agents.character_agent import CharacterAgent
9
+ from src.agents.campaign_agent import CampaignAgent
10
+
11
+
12
+ class DropdownManager:
13
+ """Manages dropdowns and their auto-population"""
14
+
15
+ def __init__(self, character_agent: CharacterAgent, campaign_agent: CampaignAgent):
16
+ self.character_agent = character_agent
17
+ self.campaign_agent = campaign_agent
18
+
19
+ def get_character_dropdown_choices(self) -> List[str]:
20
+ """Get character choices for dropdown (returns labels)"""
21
+ try:
22
+ characters = self.character_agent.list_characters()
23
+ if not characters:
24
+ return []
25
+
26
+ # Create dropdown choices with nice labels
27
+ choices = []
28
+ for char in characters:
29
+ label = f"{char.name} ({char.race.value} {char.character_class.value}, Lvl {char.level})"
30
+ choices.append(label)
31
+
32
+ return choices
33
+ except Exception as e:
34
+ return []
35
+
36
+ def get_character_choices_for_checkboxgroup(self) -> List[Tuple[str, str]]:
37
+ """Get list of characters for checkbox selection (label, value pairs)"""
38
+ try:
39
+ characters = self.character_agent.list_characters()
40
+ if not characters:
41
+ return []
42
+
43
+ # Create choices as "Name (Race Class, Level X)" -> ID
44
+ choices = []
45
+ for char in characters:
46
+ label = f"{char.name} ({char.race.value} {char.character_class.value}, Level {char.level})"
47
+ choices.append((label, char.id))
48
+
49
+ return choices
50
+ except Exception as e:
51
+ return []
52
+
53
+ def get_character_id_from_label(self, label: str) -> str:
54
+ """Extract character ID from dropdown label"""
55
+ try:
56
+ # Parse the label to get character name
57
+ if not label:
58
+ return ""
59
+
60
+ name = label.split(" (")[0] if " (" in label else label
61
+
62
+ # Find character by name
63
+ characters = self.character_agent.list_characters()
64
+ for char in characters:
65
+ if char.name == name:
66
+ return char.id
67
+
68
+ return ""
69
+ except Exception as e:
70
+ return ""
71
+
72
+ def get_campaign_dropdown_choices(self) -> List[str]:
73
+ """Get campaign choices for dropdown"""
74
+ try:
75
+ campaigns = self.campaign_agent.list_campaigns()
76
+ if not campaigns:
77
+ return []
78
+
79
+ choices = []
80
+ for campaign in campaigns:
81
+ label = f"{campaign.name} ({campaign.theme.value}, Session {campaign.current_session})"
82
+ choices.append(label)
83
+
84
+ return choices
85
+ except Exception as e:
86
+ return []
87
+
88
+ def get_campaign_id_from_label(self, label: str) -> str:
89
+ """Extract campaign ID from dropdown label"""
90
+ try:
91
+ if not label:
92
+ return ""
93
+
94
+ name = label.split(" (")[0] if " (" in label else label
95
+
96
+ campaigns = self.campaign_agent.list_campaigns()
97
+ for campaign in campaigns:
98
+ if campaign.name == name:
99
+ return campaign.id
100
+
101
+ return ""
102
+ except Exception as e:
103
+ return ""
104
+
105
+ def refresh_character_dropdown(self) -> gr.update:
106
+ """Refresh character dropdown choices"""
107
+ choices = self.get_character_dropdown_choices()
108
+ return gr.update(choices=choices, value=None)
109
+
110
+ def refresh_campaign_dropdown(self) -> gr.update:
111
+ """Refresh campaign dropdown choices"""
112
+ choices = self.get_campaign_dropdown_choices()
113
+ return gr.update(choices=choices, value=None)
114
+
115
+ def refresh_character_checkboxgroup(self) -> gr.update:
116
+ """Refresh character checkbox group choices"""
117
+ choices = self.get_character_choices_for_checkboxgroup()
118
+ if not choices:
119
+ return gr.update(choices=[], value=[])
120
+ return gr.update(choices=choices, value=[])
src/ui/tabs/__init__.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tab modules for D'n'D Campaign Manager UI
3
+ """
4
+
5
+ from .about_tab import AboutTab
6
+ from .character_create_tab import CharacterCreateTab
7
+ from .character_load_tab import CharacterLoadTab
8
+ from .character_manage_tab import CharacterManageTab
9
+ from .character_portrait_tab import CharacterPortraitTab
10
+ from .character_export_tab import CharacterExportTab
11
+ from .campaign_create_tab import CampaignCreateTab
12
+ from .campaign_manage_tab import CampaignManageTab
13
+ from .campaign_add_chars_tab import CampaignAddCharsTab
14
+ from .campaign_synthesize_tab import CampaignSynthesizeTab
15
+ from .session_tracking_tab import SessionTrackingTab
16
+
17
+ __all__ = [
18
+ "AboutTab",
19
+ "CharacterCreateTab",
20
+ "CharacterLoadTab",
21
+ "CharacterManageTab",
22
+ "CharacterPortraitTab",
23
+ "CharacterExportTab",
24
+ "CampaignCreateTab",
25
+ "CampaignManageTab",
26
+ "CampaignAddCharsTab",
27
+ "CampaignSynthesizeTab",
28
+ "SessionTrackingTab",
29
+ ]
src/ui/tabs/about_tab.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ About Tab for D'n'D Campaign Manager
3
+ """
4
+
5
+ import gradio as gr
6
+
7
+
8
+ class AboutTab:
9
+ """About tab displaying application information"""
10
+
11
+ def __init__(self):
12
+ pass
13
+
14
+ def create(self) -> None:
15
+ """Create and return the About tab component"""
16
+ with gr.Tab("About"):
17
+ gr.Markdown("""
18
+ ## About D'n'D Campaign Manager
19
+
20
+ **Version:** 2.0.0
21
+ **Built for:** Gradio + Anthropic MCP Hackathon
22
+
23
+ ### Features
24
+ - 🎲 Complete D&D 5e character creation
25
+ - πŸ“– Automated name and backstory generation
26
+ - 🎨 Character portrait generation (DALL-E 3 / HuggingFace SDXL)
27
+ - πŸ“Š Multiple ability score methods (Standard Array, Roll, Point Buy)
28
+ - πŸ’Ύ Database persistence
29
+ - πŸ“„ Markdown character sheet export
30
+ - βœ… Full data validation
31
+
32
+ ### Stat Methods
33
+ - **Standard Array:** 15, 14, 13, 12, 10, 8 (balanced)
34
+ - **Roll:** 4d6 drop lowest (random)
35
+ - **Point Buy:** 27 points (customizable)
36
+
37
+ ### Supported Races
38
+ Human, Elf, Dwarf, Halfling, Dragonborn, Gnome, Half-Elf, Half-Orc, Tiefling
39
+
40
+ ### Supported Classes
41
+ Barbarian, Bard, Cleric, Druid, Fighter, Monk, Paladin, Ranger, Rogue, Sorcerer, Warlock, Wizard
42
+
43
+ ### Tech Stack
44
+ - **AI:** Anthropic Claude / Google Gemini / OpenAI DALL-E 3
45
+ - **Framework:** Gradio
46
+ - **Database:** SQLite
47
+ - **Validation:** Pydantic
48
+
49
+ ---
50
+
51
+ *Built with ❀️ for the TTRPG community*
52
+ """)
src/ui/tabs/campaign_add_chars_tab.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Campaign Add Characters Tab for D'n'D Campaign Manager
3
+ """
4
+
5
+ import gradio as gr
6
+ from src.agents.character_agent import CharacterAgent
7
+ from src.agents.campaign_agent import CampaignAgent
8
+ from src.ui.components.dropdown_manager import DropdownManager
9
+
10
+
11
+ class CampaignAddCharsTab:
12
+ """Add Characters to Campaign tab"""
13
+
14
+ def __init__(
15
+ self,
16
+ character_agent: CharacterAgent,
17
+ campaign_agent: CampaignAgent,
18
+ dropdown_manager: DropdownManager
19
+ ):
20
+ self.character_agent = character_agent
21
+ self.campaign_agent = campaign_agent
22
+ self.dropdown_manager = dropdown_manager
23
+
24
+ def add_character_to_campaign_ui(self, campaign_id: str, character_id: str) -> str:
25
+ """Add a character to a campaign"""
26
+ try:
27
+ if not campaign_id.strip() or not character_id.strip():
28
+ return "❌ Error: Please provide both campaign ID and character ID"
29
+
30
+ # Verify character exists
31
+ character = self.character_agent.load_character(character_id)
32
+ if not character:
33
+ return f"❌ Character not found: {character_id}"
34
+
35
+ # Add to campaign
36
+ success = self.campaign_agent.add_character_to_campaign(campaign_id, character_id)
37
+
38
+ if success:
39
+ return f"βœ… Added {character.name} to campaign!"
40
+ else:
41
+ return f"❌ Campaign not found: {campaign_id}"
42
+
43
+ except Exception as e:
44
+ return f"❌ Error: {str(e)}"
45
+
46
+ def create(self) -> tuple:
47
+ """Create and return the Add Characters to Campaign tab component"""
48
+ with gr.Tab("Add Characters"):
49
+ gr.Markdown("### Add Characters to Campaign")
50
+
51
+ add_char_refresh_btn = gr.Button("πŸ”„ Refresh Lists", variant="secondary")
52
+
53
+ with gr.Row():
54
+ add_char_campaign_dropdown = gr.Dropdown(
55
+ choices=[],
56
+ label="Select Campaign",
57
+ info="Choose the campaign to add characters to (type to search)",
58
+ allow_custom_value=False,
59
+ interactive=True
60
+ )
61
+
62
+ add_char_character_dropdown = gr.Dropdown(
63
+ choices=[],
64
+ label="Select Character",
65
+ info="Choose the character to add (type to search)",
66
+ allow_custom_value=False,
67
+ interactive=True
68
+ )
69
+
70
+ add_char_btn = gr.Button("βž• Add Character to Campaign", variant="primary")
71
+
72
+ add_char_status = gr.Textbox(label="Status", lines=4)
73
+
74
+ # Refresh both dropdowns
75
+ def refresh_add_char_dropdowns():
76
+ campaign_choices = self.dropdown_manager.get_campaign_dropdown_choices()
77
+ character_choices = self.dropdown_manager.get_character_dropdown_choices()
78
+ return (
79
+ gr.update(choices=campaign_choices, value=None),
80
+ gr.update(choices=character_choices, value=None)
81
+ )
82
+
83
+ add_char_refresh_btn.click(
84
+ fn=refresh_add_char_dropdowns,
85
+ inputs=[],
86
+ outputs=[add_char_campaign_dropdown, add_char_character_dropdown]
87
+ )
88
+
89
+ # Add character - convert dropdown labels to IDs
90
+ def add_char_from_dropdowns(campaign_label, character_label):
91
+ campaign_id = self.dropdown_manager.get_campaign_id_from_label(campaign_label)
92
+ character_id = self.dropdown_manager.get_character_id_from_label(character_label)
93
+ return self.add_character_to_campaign_ui(campaign_id, character_id)
94
+
95
+ add_char_btn.click(
96
+ fn=add_char_from_dropdowns,
97
+ inputs=[add_char_campaign_dropdown, add_char_character_dropdown],
98
+ outputs=[add_char_status]
99
+ )
100
+
101
+ gr.Markdown("""
102
+ **Tip:** Click "Refresh Lists" to load your campaigns and characters.
103
+ """)
104
+
105
+ # Return both dropdowns for auto-population
106
+ return add_char_campaign_dropdown, add_char_character_dropdown
src/ui/tabs/campaign_create_tab.py ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Campaign Create Tab for D'n'D Campaign Manager
3
+ """
4
+
5
+ import gradio as gr
6
+ import traceback
7
+ from src.agents.campaign_agent import CampaignAgent
8
+ from src.models.campaign import CampaignTheme
9
+
10
+
11
+ class CampaignCreateTab:
12
+ """Create Campaign tab for creating new campaigns"""
13
+
14
+ def __init__(self, campaign_agent: CampaignAgent):
15
+ self.campaign_agent = campaign_agent
16
+
17
+ def create_campaign_ui(
18
+ self,
19
+ name: str,
20
+ theme: str,
21
+ setting: str,
22
+ summary: str,
23
+ main_conflict: str,
24
+ game_master: str,
25
+ world_name: str,
26
+ starting_location: str,
27
+ level_range: str,
28
+ party_size: int
29
+ ) -> str:
30
+ """Create a new campaign"""
31
+ try:
32
+ if not name.strip():
33
+ return "❌ Error: Please provide a campaign name"
34
+
35
+ campaign = self.campaign_agent.create_campaign(
36
+ name=name,
37
+ theme=theme,
38
+ setting=setting,
39
+ summary=summary,
40
+ main_conflict=main_conflict,
41
+ game_master=game_master,
42
+ world_name=world_name,
43
+ starting_location=starting_location,
44
+ level_range=level_range,
45
+ party_size=party_size
46
+ )
47
+
48
+ return f"""βœ… Campaign Created Successfully!
49
+
50
+ **ID:** {campaign.id}
51
+ **Name:** {campaign.name}
52
+ **Theme:** {campaign.theme.value}
53
+ **Setting:** {campaign.setting}
54
+
55
+ Campaign has been saved to database.
56
+ Use the campaign ID to manage characters and sessions."""
57
+
58
+ except Exception as e:
59
+ return f"❌ Error creating campaign:\n\n{str(e)}\n\n{traceback.format_exc()}"
60
+
61
+ def create(self) -> None:
62
+ """Create and return the Create Campaign tab component"""
63
+ with gr.Tab("Create Campaign"):
64
+ gr.Markdown("### Create New Campaign")
65
+
66
+ with gr.Row():
67
+ with gr.Column():
68
+ campaign_name = gr.Textbox(
69
+ label="Campaign Name",
70
+ placeholder="The Shattered Crown",
71
+ info="Name of your campaign"
72
+ )
73
+
74
+ campaign_theme = gr.Dropdown(
75
+ choices=[theme.value for theme in CampaignTheme],
76
+ label="Campaign Theme",
77
+ value="High Fantasy",
78
+ info="Select the theme of your campaign"
79
+ )
80
+
81
+ campaign_gm = gr.Textbox(
82
+ label="Game Master Name",
83
+ placeholder="Your name",
84
+ info="DM/GM running the campaign"
85
+ )
86
+
87
+ campaign_party_size = gr.Slider(
88
+ minimum=1,
89
+ maximum=10,
90
+ value=4,
91
+ step=1,
92
+ label="Party Size",
93
+ info="Expected number of players"
94
+ )
95
+
96
+ campaign_level_range = gr.Textbox(
97
+ label="Level Range",
98
+ value="1-5",
99
+ placeholder="1-5",
100
+ info="Expected level range for the campaign"
101
+ )
102
+
103
+ with gr.Column():
104
+ campaign_world = gr.Textbox(
105
+ label="World Name",
106
+ placeholder="Forgotten Realms",
107
+ info="Name of your world/realm"
108
+ )
109
+
110
+ campaign_starting = gr.Textbox(
111
+ label="Starting Location",
112
+ placeholder="Phandalin",
113
+ info="Where the adventure begins"
114
+ )
115
+
116
+ campaign_setting = gr.Textbox(
117
+ label="Setting Description",
118
+ placeholder="A war-torn kingdom...",
119
+ lines=3,
120
+ info="Describe the campaign setting"
121
+ )
122
+
123
+ campaign_summary = gr.Textbox(
124
+ label="Campaign Summary",
125
+ placeholder="The adventurers must...",
126
+ lines=3,
127
+ info="Brief campaign hook/summary"
128
+ )
129
+
130
+ campaign_conflict = gr.Textbox(
131
+ label="Main Conflict",
132
+ placeholder="A succession crisis threatens the kingdom",
133
+ lines=2,
134
+ info="Central conflict/tension"
135
+ )
136
+
137
+ create_campaign_btn = gr.Button("βš”οΈ Create Campaign", variant="primary", size="lg")
138
+
139
+ campaign_create_status = gr.Textbox(label="Status", lines=8)
140
+
141
+ create_campaign_btn.click(
142
+ fn=self.create_campaign_ui,
143
+ inputs=[
144
+ campaign_name,
145
+ campaign_theme,
146
+ campaign_setting,
147
+ campaign_summary,
148
+ campaign_conflict,
149
+ campaign_gm,
150
+ campaign_world,
151
+ campaign_starting,
152
+ campaign_level_range,
153
+ campaign_party_size
154
+ ],
155
+ outputs=[campaign_create_status]
156
+ )
src/ui/tabs/campaign_manage_tab.py ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Campaign Manage Tab for D'n'D Campaign Manager
3
+ """
4
+
5
+ import gradio as gr
6
+ from typing import Tuple
7
+ from src.agents.campaign_agent import CampaignAgent
8
+ from src.ui.components.dropdown_manager import DropdownManager
9
+
10
+
11
+ class CampaignManageTab:
12
+ """Manage Campaign tab for viewing and managing campaigns"""
13
+
14
+ def __init__(self, campaign_agent: CampaignAgent, dropdown_manager: DropdownManager):
15
+ self.campaign_agent = campaign_agent
16
+ self.dropdown_manager = dropdown_manager
17
+
18
+ def list_campaigns_ui(self, active_only: bool = False) -> Tuple[str, str]:
19
+ """List all campaigns"""
20
+ try:
21
+ campaigns = self.campaign_agent.list_campaigns(active_only=active_only)
22
+
23
+ if not campaigns:
24
+ return "", "No campaigns found in database."
25
+
26
+ # Create table
27
+ markdown = "# Campaigns\n\n"
28
+ markdown += "| Name | Theme | Session | Status | ID |\n"
29
+ markdown += "|------|-------|---------|--------|----|\n"
30
+
31
+ for campaign in campaigns[-20:]: # Last 20 campaigns
32
+ status = "Active" if campaign.is_active else "Inactive"
33
+ markdown += f"| {campaign.name} | {campaign.theme.value} | {campaign.current_session} | {status} | `{campaign.id}` |\n"
34
+
35
+ status = f"βœ… Found {len(campaigns)} campaign(s)"
36
+ return markdown, status
37
+
38
+ except Exception as e:
39
+ return "", f"❌ Error listing campaigns: {e}"
40
+
41
+ def load_campaign_ui(self, campaign_id: str) -> Tuple[str, str]:
42
+ """Load campaign details"""
43
+ try:
44
+ if not campaign_id.strip():
45
+ return "", "❌ Error: Please provide a campaign ID"
46
+
47
+ campaign = self.campaign_agent.load_campaign(campaign_id)
48
+
49
+ if campaign:
50
+ markdown = campaign.to_markdown()
51
+ status = f"βœ… Loaded campaign: {campaign.name}"
52
+ return markdown, status
53
+ else:
54
+ return "", f"❌ Campaign not found: {campaign_id}"
55
+
56
+ except Exception as e:
57
+ return "", f"❌ Error loading campaign: {e}"
58
+
59
+ def delete_campaign_ui(self, campaign_id: str) -> str:
60
+ """Delete a campaign"""
61
+ try:
62
+ if not campaign_id.strip():
63
+ return "❌ Error: Please select a campaign to delete"
64
+
65
+ # Load campaign first to get name
66
+ campaign = self.campaign_agent.load_campaign(campaign_id)
67
+ if not campaign:
68
+ return f"❌ Campaign not found: {campaign_id}"
69
+
70
+ campaign_name = campaign.name
71
+
72
+ # Delete the campaign
73
+ success = self.campaign_agent.delete_campaign(campaign_id)
74
+
75
+ if success:
76
+ return f"""βœ… **Campaign deleted successfully!**
77
+
78
+ **Campaign:** {campaign_name}
79
+ **ID:** {campaign_id}
80
+
81
+ The campaign and all associated data (events, session notes) have been permanently removed.
82
+
83
+ πŸ’‘ **Tip:** Refresh the campaign list to see updated campaigns."""
84
+ else:
85
+ return f"❌ Failed to delete campaign: {campaign_id}"
86
+
87
+ except Exception as e:
88
+ return f"❌ Error deleting campaign: {str(e)}"
89
+
90
+ def create(self) -> gr.Dropdown:
91
+ """Create and return the Manage Campaign tab component"""
92
+ with gr.Tab("Manage Campaign"):
93
+ gr.Markdown("### Manage Campaign")
94
+
95
+ manage_campaign_refresh_btn = gr.Button("πŸ”„ Refresh Campaign List", variant="secondary")
96
+
97
+ manage_campaign_dropdown = gr.Dropdown(
98
+ choices=[],
99
+ label="Select Campaign",
100
+ info="Choose a campaign to view (type to search)",
101
+ allow_custom_value=False,
102
+ interactive=True
103
+ )
104
+
105
+ with gr.Row():
106
+ load_campaign_btn = gr.Button("πŸ“‚ Load Campaign", variant="primary")
107
+ list_campaigns_btn = gr.Button("πŸ“‹ List All Campaigns")
108
+ delete_campaign_btn = gr.Button("πŸ—‘οΈ Delete Campaign", variant="stop")
109
+
110
+ gr.Markdown("---")
111
+
112
+ with gr.Row():
113
+ campaign_details = gr.Markdown(label="Campaign Details")
114
+ campaign_status = gr.Textbox(label="Status", lines=6)
115
+
116
+ # Refresh campaign dropdown
117
+ manage_campaign_refresh_btn.click(
118
+ fn=self.dropdown_manager.refresh_campaign_dropdown,
119
+ inputs=[],
120
+ outputs=[manage_campaign_dropdown]
121
+ )
122
+
123
+ # Load campaign - convert dropdown label to ID
124
+ def load_campaign_from_dropdown(label):
125
+ campaign_id = self.dropdown_manager.get_campaign_id_from_label(label)
126
+ return self.load_campaign_ui(campaign_id)
127
+
128
+ load_campaign_btn.click(
129
+ fn=load_campaign_from_dropdown,
130
+ inputs=[manage_campaign_dropdown],
131
+ outputs=[campaign_details, campaign_status]
132
+ )
133
+
134
+ list_campaigns_btn.click(
135
+ fn=self.list_campaigns_ui,
136
+ inputs=[],
137
+ outputs=[campaign_details, campaign_status]
138
+ )
139
+
140
+ # Delete campaign - convert dropdown label to ID and refresh list
141
+ def delete_campaign_from_dropdown(label):
142
+ campaign_id = self.dropdown_manager.get_campaign_id_from_label(label)
143
+ status = self.delete_campaign_ui(campaign_id)
144
+ # Refresh dropdown choices after deletion
145
+ updated_choices = self.dropdown_manager.refresh_campaign_dropdown()
146
+ return status, updated_choices
147
+
148
+ delete_campaign_btn.click(
149
+ fn=delete_campaign_from_dropdown,
150
+ inputs=[manage_campaign_dropdown],
151
+ outputs=[campaign_status, manage_campaign_dropdown]
152
+ )
153
+
154
+ # Return the dropdown for auto-population
155
+ return manage_campaign_dropdown
src/ui/tabs/campaign_synthesize_tab.py ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Campaign Synthesize Tab for D'n'D Campaign Manager
3
+ """
4
+
5
+ import gradio as gr
6
+ import traceback
7
+ from src.agents.character_agent import CharacterAgent
8
+ from src.agents.campaign_agent import CampaignAgent
9
+ from src.ui.components.dropdown_manager import DropdownManager
10
+
11
+
12
+ class CampaignSynthesizeTab:
13
+ """Synthesize Campaign tab for AI-powered campaign generation from characters"""
14
+
15
+ def __init__(
16
+ self,
17
+ character_agent: CharacterAgent,
18
+ campaign_agent: CampaignAgent,
19
+ dropdown_manager: DropdownManager
20
+ ):
21
+ self.character_agent = character_agent
22
+ self.campaign_agent = campaign_agent
23
+ self.dropdown_manager = dropdown_manager
24
+
25
+ def synthesize_campaign_ui(
26
+ self,
27
+ selected_character_ids: list,
28
+ game_master: str,
29
+ additional_notes: str
30
+ ) -> str:
31
+ """Synthesize a campaign from selected characters"""
32
+ try:
33
+ # Check if any characters selected
34
+ if not selected_character_ids:
35
+ return "❌ Error: Please select at least one character"
36
+
37
+ # Load all characters
38
+ characters = []
39
+ for char_id in selected_character_ids:
40
+ char = self.character_agent.load_character(char_id)
41
+ if char:
42
+ characters.append(char)
43
+
44
+ if not characters:
45
+ return "❌ Error: No valid characters found"
46
+
47
+ # Synthesize campaign
48
+ campaign = self.campaign_agent.synthesize_campaign_from_characters(
49
+ characters=characters,
50
+ game_master=game_master,
51
+ additional_notes=additional_notes
52
+ )
53
+
54
+ # Create response with character list
55
+ char_list = "\n".join([f"- {char.name} (Level {char.level} {char.race.value} {char.character_class.value})" for char in characters])
56
+
57
+ # Build comprehensive output with all campaign details
58
+ output = [f"""βœ… Campaign Synthesized Successfully!
59
+
60
+ **Campaign ID:** {campaign.id}
61
+ **Campaign Name:** {campaign.name}
62
+ **Theme:** {campaign.theme.value}
63
+ **World:** {campaign.world_name}
64
+ **Starting Location:** {campaign.starting_location}
65
+
66
+ **Party Members ({len(characters)}):**
67
+ {char_list}
68
+
69
+ **Level Range:** {campaign.level_range}
70
+
71
+ ---
72
+
73
+ ## Campaign Overview
74
+
75
+ **Summary:**
76
+ {campaign.summary}
77
+
78
+ **Main Conflict:**
79
+ {campaign.main_conflict}
80
+
81
+ **Current Story Arc:**
82
+ {campaign.current_arc if campaign.current_arc else "See detailed notes below"}
83
+ """]
84
+
85
+ # Add factions if present
86
+ if campaign.key_factions:
87
+ output.append("\n## Key Factions\n")
88
+ for faction in campaign.key_factions:
89
+ output.append(f"- {faction}\n")
90
+
91
+ # Add villains if present
92
+ if campaign.major_villains:
93
+ output.append("\n## Major Villains\n")
94
+ for villain in campaign.major_villains:
95
+ output.append(f"- {villain}\n")
96
+
97
+ # Add mysteries if present
98
+ if campaign.central_mysteries:
99
+ output.append("\n## Central Mysteries\n")
100
+ for mystery in campaign.central_mysteries:
101
+ output.append(f"- {mystery}\n")
102
+
103
+ # Add detailed campaign notes (includes character connections, hooks, sessions, NPCs, locations)
104
+ if campaign.notes:
105
+ output.append("\n---\n\n")
106
+ output.append(campaign.notes)
107
+
108
+ output.append(f"""
109
+
110
+ ---
111
+
112
+ βœ… **Campaign Created!** All characters have been added to the campaign.
113
+
114
+ πŸ’‘ **Next Steps:**
115
+ - View full details in the "Manage Campaign" tab
116
+ - Start your first session in "Session Tracking"
117
+ - Add campaign events as your story unfolds""")
118
+
119
+ return "".join(output)
120
+
121
+ except Exception as e:
122
+ return f"❌ Error synthesizing campaign:\n\n{str(e)}\n\n{traceback.format_exc()}"
123
+
124
+ def create(self) -> None:
125
+ """Create and return the Synthesize Campaign tab component"""
126
+ with gr.Tab("πŸ€– Synthesize Campaign"):
127
+ gr.Markdown("""
128
+ ### Campaign Synthesis
129
+ Select characters and automatically create a custom campaign tailored to your party!
130
+ """)
131
+
132
+ # Load character button
133
+ load_characters_btn = gr.Button("πŸ”„ Load Available Characters", variant="secondary")
134
+
135
+ with gr.Row():
136
+ with gr.Column():
137
+ synth_character_select = gr.CheckboxGroup(
138
+ choices=[],
139
+ label="Select Characters for Campaign",
140
+ info="Choose characters to include in your campaign"
141
+ )
142
+
143
+ synth_gm_name = gr.Textbox(
144
+ label="Game Master Name",
145
+ placeholder="Your name",
146
+ info="DM/GM running the campaign"
147
+ )
148
+
149
+ synth_notes = gr.Textbox(
150
+ label="Additional Notes (Optional)",
151
+ placeholder="Any specific themes, settings, or elements you'd like included...",
152
+ lines=4,
153
+ info="Guide the campaign creation process"
154
+ )
155
+
156
+ synthesize_btn = gr.Button("✨ Synthesize Campaign", variant="primary", size="lg")
157
+
158
+ with gr.Column():
159
+ gr.Markdown("""
160
+ ### How It Works
161
+
162
+ 1. **Load Characters**: Click "Load Available Characters" to see all your characters
163
+ 2. **Select Party**: Check the boxes for characters you want in the campaign
164
+ 3. **Add Context**: Optionally provide DM notes about themes or preferences
165
+ 4. **Analyze Party**: The system analyzes:
166
+ - Character backstories and motivations
167
+ - Party composition and level
168
+ - Alignments and backgrounds
169
+ - Character personalities
170
+ 5. **Campaign Created**: Get a fully-fledged campaign that:
171
+ - Ties into character backstories
172
+ - Provides appropriate challenges
173
+ - Creates opportunities for each character
174
+ - Includes factions, villains, and mysteries
175
+
176
+ **Example Party:**
177
+ - Thorin Ironforge (Dwarf Fighter, Level 3)
178
+ - Elara Moonwhisper (Elf Wizard, Level 3)
179
+ - Grimm Shadowstep (Halfling Rogue, Level 3)
180
+
181
+ The system will create a campaign that weaves their stories together!
182
+ """)
183
+
184
+ synth_status = gr.Textbox(label="Campaign Details", lines=20)
185
+
186
+ # Load characters button handler
187
+ load_characters_btn.click(
188
+ fn=self.dropdown_manager.refresh_character_checkboxgroup,
189
+ inputs=[],
190
+ outputs=[synth_character_select]
191
+ )
192
+
193
+ synthesize_btn.click(
194
+ fn=self.synthesize_campaign_ui,
195
+ inputs=[synth_character_select, synth_gm_name, synth_notes],
196
+ outputs=[synth_status]
197
+ )
src/ui/tabs/character_create_tab.py ADDED
@@ -0,0 +1,348 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Character Create Tab for D'n'D Campaign Manager
3
+ """
4
+
5
+ import gradio as gr
6
+ from typing import Tuple
7
+ import traceback
8
+ from src.agents.character_agent import CharacterAgent
9
+ from src.models.character import DnDRace, DnDClass, Alignment
10
+ from src.utils.validators import get_available_races, get_available_classes
11
+ from src.utils.image_generator import RACE_SKIN_TONES
12
+
13
+
14
+ class CharacterCreateTab:
15
+ """Create Character tab for D&D character creation"""
16
+
17
+ def __init__(self, character_agent: CharacterAgent):
18
+ self.character_agent = character_agent
19
+
20
+ def _get_alignment_description(self, alignment: str) -> str:
21
+ """Get personality guidance based on alignment"""
22
+ descriptions = {
23
+ "Lawful Good": "a strong sense of justice, honor, and desire to help others within the rules",
24
+ "Neutral Good": "genuine kindness and desire to help, but flexibility in how they achieve good",
25
+ "Chaotic Good": "rebellious goodness, fighting for freedom and helping others by breaking unjust rules",
26
+ "Lawful Neutral": "strict adherence to law, order, and tradition above good or evil",
27
+ "True Neutral": "balance and pragmatism, avoiding extreme positions",
28
+ "Chaotic Neutral": "unpredictability, freedom-loving nature, and self-interest",
29
+ "Lawful Evil": "tyrannical control, following their own code while causing harm",
30
+ "Neutral Evil": "pure self-interest and willingness to harm others for personal gain",
31
+ "Chaotic Evil": "destructive chaos, cruelty, and disregard for any rules or others' wellbeing"
32
+ }
33
+ return descriptions.get(alignment, "their moral compass")
34
+
35
+ def generate_name_ui(
36
+ self,
37
+ race: str,
38
+ character_class: str,
39
+ gender: str,
40
+ alignment: str,
41
+ ) -> str:
42
+ """
43
+ Generate a character name using AI
44
+ Alignment can influence name generation (e.g., darker names for evil characters)
45
+ """
46
+ try:
47
+ race_enum = DnDRace(race)
48
+ class_enum = DnDClass(character_class)
49
+
50
+ # Add alignment hint to the generation prompt
51
+ alignment_hint = None
52
+ if "Evil" in alignment:
53
+ alignment_hint = "with a darker, more menacing tone"
54
+ elif "Good" in alignment:
55
+ alignment_hint = "with a heroic, noble tone"
56
+ elif alignment == "Chaotic Neutral":
57
+ alignment_hint = "with a wild, unpredictable feel"
58
+
59
+ # Build custom prompt if we have alignment influence
60
+ if alignment_hint:
61
+ prompt = f"""Generate a single fantasy character name for a D&D character.
62
+
63
+ Race: {race_enum.value}
64
+ Class: {class_enum.value}
65
+ Gender: {gender if gender != "Not specified" else "any"}
66
+ Alignment: {alignment} - name should reflect this {alignment_hint}
67
+
68
+ Requirements:
69
+ - Just the name, nothing else
70
+ - Make it sound appropriate for the race, gender, and alignment
71
+ - {alignment_hint}
72
+ - Make it memorable and fitting for an adventurer
73
+ - 2-3 words maximum
74
+
75
+ Examples:
76
+ - Evil: "Malakai Shadowbane", "Drusilla Nightwhisper"
77
+ - Good: "Elara Lightbringer", "Theron Brightheart"
78
+ - Chaotic: "Raven Wildfire", "Zephyr Stormblade"
79
+
80
+ Generate only the name:"""
81
+
82
+ name = self.character_agent.ai_client.generate_creative(prompt).strip()
83
+ name = name.split('\n')[0].strip('"\'')
84
+ return name
85
+ else:
86
+ # Use standard generation
87
+ name = self.character_agent.generate_name(
88
+ race=race_enum,
89
+ character_class=class_enum,
90
+ gender=gender if gender != "Not specified" else None
91
+ )
92
+ return name
93
+
94
+ except Exception as e:
95
+ return f"Error: {str(e)}"
96
+
97
+ def create_character_ui(
98
+ self,
99
+ name: str,
100
+ race: str,
101
+ character_class: str,
102
+ level: int,
103
+ gender: str,
104
+ skin_tone: str,
105
+ alignment: str,
106
+ background_dropdown: str,
107
+ custom_background: str,
108
+ personality_prompt: str,
109
+ stats_method: str,
110
+ use_ai_background: bool,
111
+ ) -> Tuple[str, str]:
112
+ """
113
+ Create character with UI inputs
114
+
115
+ Returns:
116
+ Tuple of (character_sheet_markdown, status_message)
117
+ """
118
+ try:
119
+ # Validate inputs
120
+ if not name.strip():
121
+ return "", "❌ Error: Please provide a character name (use 'Generate Name' button or type one)"
122
+
123
+ if level < 1 or level > 20:
124
+ return "", "❌ Error: Level must be between 1 and 20"
125
+
126
+ # Convert race, class, and alignment
127
+ try:
128
+ race_enum = DnDRace(race)
129
+ class_enum = DnDClass(character_class)
130
+ alignment_enum = Alignment(alignment)
131
+ except ValueError as e:
132
+ return "", f"❌ Error: Invalid race, class, or alignment - {e}"
133
+
134
+ # Determine final background type
135
+ if background_dropdown == "Custom (enter below)":
136
+ final_background = custom_background.strip() if custom_background.strip() else "Adventurer"
137
+ else:
138
+ final_background = background_dropdown
139
+
140
+ # Create character with gender AND alignment in personality prompt
141
+ gender_hint = f"Character is {gender}. " if gender != "Not specified" else ""
142
+ alignment_hint = f"Character's alignment is {alignment}, so their personality and backstory should reflect {self._get_alignment_description(alignment)}. "
143
+
144
+ full_personality_prompt = gender_hint + alignment_hint + (personality_prompt if personality_prompt else "")
145
+
146
+ character = self.character_agent.create_character(
147
+ name=name,
148
+ race=race_enum,
149
+ character_class=class_enum,
150
+ level=level,
151
+ background_type=final_background,
152
+ personality_prompt=full_personality_prompt if use_ai_background else None,
153
+ stats_method=stats_method,
154
+ )
155
+
156
+ # Override alignment, gender, and skin tone if user specified
157
+ character.alignment = alignment_enum
158
+ character.gender = gender if gender != "Not specified" else None
159
+ character.skin_tone = skin_tone if skin_tone else None
160
+
161
+ # Generate markdown
162
+ markdown = character.to_markdown()
163
+
164
+ status = f"""βœ… Character Created Successfully!
165
+
166
+ **ID:** {character.id}
167
+ **Name:** {character.name}
168
+ **Race:** {character.race.value}
169
+ **Class:** {character.character_class.value}
170
+ **Level:** {character.level}
171
+
172
+ Character has been saved to database."""
173
+
174
+ return markdown, status
175
+
176
+ except Exception as e:
177
+ error_msg = f"❌ Error creating character:\n\n{str(e)}\n\n{traceback.format_exc()}"
178
+ return "", error_msg
179
+
180
+ def create(self) -> None:
181
+ """Create and return the Create Character tab component"""
182
+ with gr.Tab("Create Character"):
183
+ gr.Markdown("### Character Creation")
184
+
185
+ with gr.Row():
186
+ with gr.Column():
187
+ gr.Markdown("#### Basic Information")
188
+
189
+ with gr.Row():
190
+ name_input = gr.Textbox(
191
+ label="Character Name",
192
+ placeholder="Thorin Ironforge",
193
+ info="Type a name or generate one below",
194
+ scale=3
195
+ )
196
+
197
+ race_dropdown = gr.Dropdown(
198
+ choices=get_available_races(),
199
+ label="Race",
200
+ value="Human",
201
+ info="Character's race"
202
+ )
203
+
204
+ class_dropdown = gr.Dropdown(
205
+ choices=get_available_classes(),
206
+ label="Class",
207
+ value="Fighter",
208
+ info="Character's class"
209
+ )
210
+
211
+ gender_dropdown = gr.Dropdown(
212
+ choices=["Male", "Female", "Non-binary", "Not specified"],
213
+ label="Gender",
214
+ value="Not specified",
215
+ info="Character's gender"
216
+ )
217
+
218
+ skin_tone_dropdown = gr.Dropdown(
219
+ choices=RACE_SKIN_TONES[DnDRace.HUMAN], # Default to Human
220
+ label="Skin Tone / Color",
221
+ value=None,
222
+ info="Select appropriate color for the race"
223
+ )
224
+
225
+ generate_name_btn = gr.Button("🎲 Generate Name", variant="secondary", size="sm")
226
+
227
+ level_slider = gr.Slider(
228
+ minimum=1,
229
+ maximum=20,
230
+ value=1,
231
+ step=1,
232
+ label="Level",
233
+ info="Character level (1-20)"
234
+ )
235
+
236
+ alignment_dropdown = gr.Dropdown(
237
+ choices=[
238
+ "Lawful Good", "Neutral Good", "Chaotic Good",
239
+ "Lawful Neutral", "True Neutral", "Chaotic Neutral",
240
+ "Lawful Evil", "Neutral Evil", "Chaotic Evil"
241
+ ],
242
+ label="Alignment",
243
+ value="True Neutral",
244
+ info="Character's moral alignment"
245
+ )
246
+
247
+ with gr.Column():
248
+ gr.Markdown("#### Background & Personality")
249
+
250
+ background_dropdown = gr.Dropdown(
251
+ choices=[
252
+ "Acolyte", "Charlatan", "Criminal", "Entertainer",
253
+ "Folk Hero", "Guild Artisan", "Hermit", "Noble",
254
+ "Outlander", "Sage", "Sailor", "Soldier",
255
+ "Urchin", "Custom (enter below)"
256
+ ],
257
+ label="Background Type",
258
+ value="Soldier",
259
+ info="Select from D&D 5e backgrounds or choose Custom"
260
+ )
261
+
262
+ custom_background_input = gr.Textbox(
263
+ label="Custom Background",
264
+ placeholder="Enter your custom background...",
265
+ value="",
266
+ visible=False,
267
+ info="Only used if 'Custom' is selected above"
268
+ )
269
+
270
+ use_ai_background = gr.Checkbox(
271
+ label="Generate detailed backstory",
272
+ value=True,
273
+ info="Create a unique backstory for this character"
274
+ )
275
+
276
+ personality_input = gr.Textbox(
277
+ label="Personality Guidance (Optional)",
278
+ placeholder="A mysterious ranger who protects the forest...",
279
+ lines=3,
280
+ info="Guide AI in creating personality (if enabled)"
281
+ )
282
+
283
+ stats_method = gr.Radio(
284
+ choices=["standard_array", "roll", "point_buy"],
285
+ label="Ability Score Method",
286
+ value="standard_array",
287
+ info="How to generate ability scores"
288
+ )
289
+
290
+ create_btn = gr.Button("βš”οΈ Create Character", variant="primary", size="lg")
291
+
292
+ gr.Markdown("---")
293
+
294
+ with gr.Row():
295
+ character_output = gr.Markdown(label="Character Sheet")
296
+ status_output = gr.Textbox(label="Status", lines=8)
297
+
298
+ # Toggle custom background visibility
299
+ def toggle_custom_background(background_choice):
300
+ return gr.update(visible=background_choice == "Custom (enter below)")
301
+
302
+ # Update skin tone options when race changes
303
+ def update_skin_tone_choices(race: str):
304
+ try:
305
+ race_enum = DnDRace(race)
306
+ skin_tones = RACE_SKIN_TONES.get(race_enum, RACE_SKIN_TONES[DnDRace.HUMAN])
307
+ return gr.update(choices=skin_tones, value=skin_tones[0] if skin_tones else None)
308
+ except:
309
+ return gr.update(choices=RACE_SKIN_TONES[DnDRace.HUMAN], value=RACE_SKIN_TONES[DnDRace.HUMAN][0])
310
+
311
+ background_dropdown.change(
312
+ fn=toggle_custom_background,
313
+ inputs=[background_dropdown],
314
+ outputs=[custom_background_input]
315
+ )
316
+
317
+ race_dropdown.change(
318
+ fn=update_skin_tone_choices,
319
+ inputs=[race_dropdown],
320
+ outputs=[skin_tone_dropdown]
321
+ )
322
+
323
+ # Generate name action - includes alignment for more thematic names
324
+ generate_name_btn.click(
325
+ fn=self.generate_name_ui,
326
+ inputs=[race_dropdown, class_dropdown, gender_dropdown, alignment_dropdown],
327
+ outputs=[name_input]
328
+ )
329
+
330
+ # Create character action
331
+ create_btn.click(
332
+ fn=self.create_character_ui,
333
+ inputs=[
334
+ name_input,
335
+ race_dropdown,
336
+ class_dropdown,
337
+ level_slider,
338
+ gender_dropdown,
339
+ skin_tone_dropdown,
340
+ alignment_dropdown,
341
+ background_dropdown,
342
+ custom_background_input,
343
+ personality_input,
344
+ stats_method,
345
+ use_ai_background,
346
+ ],
347
+ outputs=[character_output, status_output]
348
+ )
src/ui/tabs/character_export_tab.py ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Character Export Tab for D'n'D Campaign Manager
3
+ """
4
+
5
+ import gradio as gr
6
+ from typing import Tuple
7
+ import traceback
8
+ from src.agents.character_agent import CharacterAgent
9
+ from src.ui.components.dropdown_manager import DropdownManager
10
+ from src.utils.character_sheet_exporter import CharacterSheetExporter
11
+
12
+
13
+ class CharacterExportTab:
14
+ """Export Character Sheet tab for exporting characters to various formats"""
15
+
16
+ def __init__(self, character_agent: CharacterAgent, dropdown_manager: DropdownManager):
17
+ self.character_agent = character_agent
18
+ self.dropdown_manager = dropdown_manager
19
+ self.exporter = CharacterSheetExporter()
20
+
21
+ def export_character_sheet_ui(
22
+ self,
23
+ character_id: str,
24
+ export_format: str = "markdown"
25
+ ) -> str:
26
+ """
27
+ Export character sheet to file
28
+
29
+ Returns:
30
+ Status message with file path
31
+ """
32
+ try:
33
+ if not character_id.strip():
34
+ return "❌ Error: Please provide a character ID"
35
+
36
+ # Load character
37
+ character = self.character_agent.load_character(character_id)
38
+ if not character:
39
+ return f"❌ Character not found: {character_id}"
40
+
41
+ # Export to selected format
42
+ file_path = self.exporter.save_export(character, format=export_format)
43
+
44
+ return f"""βœ… Character sheet exported successfully!
45
+
46
+ **Character:** {character.name}
47
+ **Format:** {export_format.upper()}
48
+ **File:** {file_path}
49
+
50
+ You can find the exported file in the data/exports/ directory."""
51
+
52
+ except Exception as e:
53
+ return f"❌ Error exporting character sheet:\n\n{str(e)}\n\n{traceback.format_exc()}"
54
+
55
+ def preview_export_ui(
56
+ self,
57
+ character_id: str,
58
+ export_format: str = "markdown"
59
+ ) -> Tuple[str, str]:
60
+ """
61
+ Preview character sheet export without saving
62
+
63
+ Returns:
64
+ Tuple of (preview_content, status_message)
65
+ """
66
+ try:
67
+ if not character_id.strip():
68
+ return "", "❌ Error: Please provide a character ID"
69
+
70
+ # Load character
71
+ character = self.character_agent.load_character(character_id)
72
+ if not character:
73
+ return "", f"❌ Character not found: {character_id}"
74
+
75
+ # Generate preview based on format
76
+ if export_format == "markdown":
77
+ preview = self.exporter.export_to_markdown(character)
78
+ elif export_format == "json":
79
+ preview = f"```json\n{self.exporter.export_to_json(character)}\n```"
80
+ elif export_format == "html":
81
+ preview = f"```html\n{self.exporter.export_to_html(character)}\n```"
82
+ else:
83
+ return "", f"❌ Unknown format: {export_format}"
84
+
85
+ status = f"βœ… Preview generated for {character.name}"
86
+ return preview, status
87
+
88
+ except Exception as e:
89
+ return "", f"❌ Error generating preview:\n\n{str(e)}\n\n{traceback.format_exc()}"
90
+
91
+ def create(self) -> gr.Dropdown:
92
+ """Create and return the Export Character Sheet tab component"""
93
+ with gr.Tab("Export Character Sheet"):
94
+ gr.Markdown("""
95
+ ### πŸ“„ Export Character Sheets
96
+ Export your characters to formatted character sheets in multiple formats!
97
+ """)
98
+
99
+ with gr.Row():
100
+ with gr.Column():
101
+ export_char_refresh_btn = gr.Button("πŸ”„ Refresh Character List", variant="secondary")
102
+
103
+ export_character_dropdown = gr.Dropdown(
104
+ choices=[],
105
+ label="Select Character",
106
+ info="Choose a character to export (type to search)",
107
+ allow_custom_value=False,
108
+ interactive=True
109
+ )
110
+
111
+ export_format = gr.Radio(
112
+ choices=["markdown", "json", "html"],
113
+ label="Export Format",
114
+ value="markdown",
115
+ info="Choose the format for your character sheet"
116
+ )
117
+
118
+ with gr.Row():
119
+ preview_btn = gr.Button("πŸ‘οΈ Preview", variant="secondary")
120
+ export_btn = gr.Button("πŸ’Ύ Export to File", variant="primary")
121
+
122
+ export_status = gr.Textbox(
123
+ label="Status",
124
+ lines=6
125
+ )
126
+
127
+ with gr.Column():
128
+ preview_output = gr.Markdown(
129
+ label="Preview",
130
+ value="Character sheet preview will appear here..."
131
+ )
132
+
133
+ gr.Markdown("""
134
+ ### Format Descriptions
135
+
136
+ **Markdown (.md)**
137
+ - Clean, readable text format with tables
138
+ - Perfect for sharing in Discord, GitHub, or note apps
139
+ - Includes all character stats, features, and background
140
+ - Easy to read and edit
141
+
142
+ **JSON (.json)**
143
+ - Structured data format
144
+ - Perfect for importing into other tools or programs
145
+ - Contains all character data in a machine-readable format
146
+ - Great for backup or data transfer
147
+
148
+ **HTML (.html)**
149
+ - Styled character sheet that can be opened in a browser
150
+ - Print-ready format (mimics official D&D character sheet)
151
+ - Beautiful parchment styling with maroon borders
152
+ - Can be converted to PDF using browser's print function
153
+
154
+ All exports are saved to the `data/exports/` directory.
155
+ """)
156
+
157
+ # Refresh export character dropdown
158
+ export_char_refresh_btn.click(
159
+ fn=self.dropdown_manager.refresh_character_dropdown,
160
+ inputs=[],
161
+ outputs=[export_character_dropdown]
162
+ )
163
+
164
+ # Preview action - convert dropdown label to ID
165
+ def preview_from_dropdown(label, format):
166
+ char_id = self.dropdown_manager.get_character_id_from_label(label)
167
+ return self.preview_export_ui(char_id, format)
168
+
169
+ preview_btn.click(
170
+ fn=preview_from_dropdown,
171
+ inputs=[export_character_dropdown, export_format],
172
+ outputs=[preview_output, export_status]
173
+ )
174
+
175
+ # Export action - convert dropdown label to ID
176
+ def export_from_dropdown(label, format):
177
+ char_id = self.dropdown_manager.get_character_id_from_label(label)
178
+ return self.export_character_sheet_ui(char_id, format)
179
+
180
+ export_btn.click(
181
+ fn=export_from_dropdown,
182
+ inputs=[export_character_dropdown, export_format],
183
+ outputs=[export_status]
184
+ )
185
+
186
+ # Return the dropdown for auto-population
187
+ return export_character_dropdown
src/ui/tabs/character_load_tab.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Character Load Tab for D'n'D Campaign Manager
3
+ """
4
+
5
+ import gradio as gr
6
+ from typing import Tuple
7
+ from src.agents.character_agent import CharacterAgent
8
+ from src.ui.components.dropdown_manager import DropdownManager
9
+
10
+
11
+ class CharacterLoadTab:
12
+ """Load Character tab for viewing saved characters"""
13
+
14
+ def __init__(self, character_agent: CharacterAgent, dropdown_manager: DropdownManager):
15
+ self.character_agent = character_agent
16
+ self.dropdown_manager = dropdown_manager
17
+
18
+ def load_character_ui(self, character_id: str) -> Tuple[str, str]:
19
+ """Load character by ID"""
20
+ try:
21
+ if not character_id.strip():
22
+ return "", "❌ Error: Please provide a character ID"
23
+
24
+ character = self.character_agent.load_character(character_id)
25
+
26
+ if character:
27
+ markdown = character.to_markdown()
28
+ status = f"βœ… Loaded character: {character.name}"
29
+ return markdown, status
30
+ else:
31
+ return "", f"❌ Character not found: {character_id}"
32
+
33
+ except Exception as e:
34
+ return "", f"❌ Error loading character: {e}"
35
+
36
+ def list_characters_ui(self) -> Tuple[str, str]:
37
+ """List all saved characters"""
38
+ try:
39
+ characters = self.character_agent.list_characters()
40
+
41
+ if not characters:
42
+ return "", "No characters found in database."
43
+
44
+ # Create table
45
+ markdown = "# Saved Characters\n\n"
46
+ markdown += "| Name | Race | Class | Level | ID |\n"
47
+ markdown += "|------|------|-------|-------|----|\n"
48
+
49
+ for char in characters[-20:]: # Last 20 characters
50
+ markdown += f"| {char.name} | {char.race.value} | {char.character_class.value} | {char.level} | `{char.id}` |\n"
51
+
52
+ status = f"βœ… Found {len(characters)} character(s)"
53
+ return markdown, status
54
+
55
+ except Exception as e:
56
+ return "", f"❌ Error listing characters: {e}"
57
+
58
+ def create(self) -> gr.Dropdown:
59
+ """Create and return the Load Character tab component"""
60
+ with gr.Tab("Load Character"):
61
+ gr.Markdown("### Load Saved Character")
62
+
63
+ load_char_refresh_btn = gr.Button("πŸ”„ Refresh Character List", variant="secondary")
64
+
65
+ character_dropdown = gr.Dropdown(
66
+ choices=[],
67
+ label="Select Character",
68
+ info="Choose a character from the list (type to search)",
69
+ allow_custom_value=False,
70
+ interactive=True
71
+ )
72
+
73
+ with gr.Row():
74
+ load_btn = gr.Button("πŸ“‚ Load Character", variant="primary")
75
+ list_btn = gr.Button("πŸ“‹ List All Characters")
76
+
77
+ gr.Markdown("---")
78
+
79
+ with gr.Row():
80
+ loaded_character_output = gr.Markdown(label="Character Sheet")
81
+ load_status_output = gr.Textbox(label="Status", lines=6)
82
+
83
+ # Refresh character dropdown
84
+ load_char_refresh_btn.click(
85
+ fn=self.dropdown_manager.refresh_character_dropdown,
86
+ inputs=[],
87
+ outputs=[character_dropdown]
88
+ )
89
+
90
+ # Load character action - convert dropdown label to ID
91
+ def load_character_from_dropdown(label):
92
+ char_id = self.dropdown_manager.get_character_id_from_label(label)
93
+ return self.load_character_ui(char_id)
94
+
95
+ load_btn.click(
96
+ fn=load_character_from_dropdown,
97
+ inputs=[character_dropdown],
98
+ outputs=[loaded_character_output, load_status_output]
99
+ )
100
+
101
+ # List characters action
102
+ list_btn.click(
103
+ fn=self.list_characters_ui,
104
+ inputs=[],
105
+ outputs=[loaded_character_output, load_status_output]
106
+ )
107
+
108
+ # Return the dropdown for auto-population
109
+ return character_dropdown
src/ui/tabs/character_manage_tab.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Character Manage Tab for D'n'D Campaign Manager
3
+ """
4
+
5
+ import gradio as gr
6
+ from typing import Tuple
7
+ from src.agents.character_agent import CharacterAgent
8
+ from src.ui.components.dropdown_manager import DropdownManager
9
+
10
+
11
+ class CharacterManageTab:
12
+ """Manage Characters tab for deleting and viewing characters"""
13
+
14
+ def __init__(self, character_agent: CharacterAgent, dropdown_manager: DropdownManager):
15
+ self.character_agent = character_agent
16
+ self.dropdown_manager = dropdown_manager
17
+
18
+ def delete_character_ui(self, character_id: str) -> str:
19
+ """Delete character by ID"""
20
+ try:
21
+ if not character_id.strip():
22
+ return "❌ Error: Please provide a character ID"
23
+
24
+ # Check if exists
25
+ character = self.character_agent.load_character(character_id)
26
+ if not character:
27
+ return f"❌ Character not found: {character_id}"
28
+
29
+ # Delete
30
+ self.character_agent.delete_character(character_id)
31
+ return f"βœ… Deleted character: {character.name} ({character_id})"
32
+
33
+ except Exception as e:
34
+ return f"❌ Error deleting character: {e}"
35
+
36
+ def list_characters_ui(self) -> Tuple[str, str]:
37
+ """List all saved characters"""
38
+ try:
39
+ characters = self.character_agent.list_characters()
40
+
41
+ if not characters:
42
+ return "", "No characters found in database."
43
+
44
+ # Create table
45
+ markdown = "# Saved Characters\n\n"
46
+ markdown += "| Name | Race | Class | Level | ID |\n"
47
+ markdown += "|------|------|-------|-------|----|\n"
48
+
49
+ for char in characters[-20:]: # Last 20 characters
50
+ markdown += f"| {char.name} | {char.race.value} | {char.character_class.value} | {char.level} | `{char.id}` |\n"
51
+
52
+ status = f"βœ… Found {len(characters)} character(s)"
53
+ return markdown, status
54
+
55
+ except Exception as e:
56
+ return "", f"❌ Error listing characters: {e}"
57
+
58
+ def create(self) -> gr.Dropdown:
59
+ """Create and return the Manage Characters tab component"""
60
+ with gr.Tab("Manage Characters"):
61
+ gr.Markdown("### Character Management")
62
+
63
+ delete_refresh_btn = gr.Button("πŸ”„ Refresh Character List", variant="secondary")
64
+
65
+ delete_character_dropdown = gr.Dropdown(
66
+ choices=[],
67
+ label="Select Character to Delete",
68
+ info="⚠️ Warning: This action cannot be undone! (type to search)",
69
+ allow_custom_value=False,
70
+ interactive=True
71
+ )
72
+
73
+ delete_btn = gr.Button("πŸ—‘οΈ Delete Character", variant="stop")
74
+
75
+ delete_status_output = gr.Textbox(label="Status", lines=3)
76
+
77
+ # Refresh delete character dropdown
78
+ delete_refresh_btn.click(
79
+ fn=self.dropdown_manager.refresh_character_dropdown,
80
+ inputs=[],
81
+ outputs=[delete_character_dropdown]
82
+ )
83
+
84
+ # Delete character action - convert dropdown label to ID and refresh list
85
+ def delete_character_from_dropdown(label):
86
+ char_id = self.dropdown_manager.get_character_id_from_label(label)
87
+ status = self.delete_character_ui(char_id)
88
+ # Refresh dropdown choices after deletion
89
+ updated_choices = self.dropdown_manager.refresh_character_dropdown()
90
+ return status, updated_choices
91
+
92
+ delete_btn.click(
93
+ fn=delete_character_from_dropdown,
94
+ inputs=[delete_character_dropdown],
95
+ outputs=[delete_status_output, delete_character_dropdown]
96
+ )
97
+
98
+ gr.Markdown("---")
99
+
100
+ with gr.Accordion("Quick Actions", open=False):
101
+ quick_list_btn = gr.Button("πŸ“‹ List All Characters")
102
+ quick_list_output = gr.Markdown(label="Character List")
103
+ quick_status = gr.Textbox(label="Status", lines=2)
104
+
105
+ quick_list_btn.click(
106
+ fn=self.list_characters_ui,
107
+ inputs=[],
108
+ outputs=[quick_list_output, quick_status]
109
+ )
110
+
111
+ # Return the dropdown for auto-population
112
+ return delete_character_dropdown
src/ui/tabs/character_portrait_tab.py ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Character Portrait Tab for D'n'D Campaign Manager
3
+ """
4
+
5
+ import gradio as gr
6
+ from typing import Optional, Tuple
7
+ from src.agents.character_agent import CharacterAgent
8
+ from src.ui.components.dropdown_manager import DropdownManager
9
+
10
+
11
+ class CharacterPortraitTab:
12
+ """Generate Portrait tab for AI character portrait generation"""
13
+
14
+ def __init__(self, character_agent: CharacterAgent, dropdown_manager: DropdownManager):
15
+ self.character_agent = character_agent
16
+ self.dropdown_manager = dropdown_manager
17
+
18
+ def generate_portrait_ui(
19
+ self,
20
+ character_id: str,
21
+ style: str = "fantasy art",
22
+ quality: str = "standard",
23
+ provider: str = "auto"
24
+ ) -> Tuple[Optional[str], str]:
25
+ """
26
+ Generate character portrait
27
+
28
+ Returns:
29
+ Tuple of (image_path, status_message)
30
+ """
31
+ try:
32
+ if not character_id.strip():
33
+ return None, "❌ Error: Please provide a character ID"
34
+
35
+ # Load character
36
+ character = self.character_agent.load_character(character_id)
37
+ if not character:
38
+ return None, f"❌ Character not found: {character_id}"
39
+
40
+ # Generate portrait
41
+ file_path, status = self.character_agent.generate_portrait(
42
+ character=character,
43
+ style=style,
44
+ quality=quality,
45
+ provider=provider
46
+ )
47
+
48
+ return file_path, status
49
+
50
+ except Exception as e:
51
+ import traceback
52
+ error_msg = f"❌ Error generating portrait:\n\n{str(e)}\n\n{traceback.format_exc()}"
53
+ return None, error_msg
54
+
55
+ def create(self) -> gr.Dropdown:
56
+ """Create and return the Generate Portrait tab component"""
57
+ with gr.Tab("Generate Portrait"):
58
+ gr.Markdown("""
59
+ ### 🎨 AI Character Portrait Generator
60
+ Generate stunning character portraits using DALL-E 3 or HuggingFace!
61
+ """)
62
+
63
+ with gr.Row():
64
+ with gr.Column():
65
+ portrait_refresh_btn = gr.Button("πŸ”„ Refresh Character List", variant="secondary")
66
+
67
+ portrait_character_dropdown = gr.Dropdown(
68
+ choices=[],
69
+ label="Select Character",
70
+ info="Choose a character to generate portrait for (type to search)",
71
+ allow_custom_value=False,
72
+ interactive=True
73
+ )
74
+
75
+ portrait_provider = gr.Radio(
76
+ choices=["auto", "openai", "huggingface"],
77
+ label="Image Provider",
78
+ value="auto",
79
+ info="Auto: Try OpenAI first, fallback to HuggingFace if needed"
80
+ )
81
+
82
+ portrait_style = gr.Dropdown(
83
+ choices=[
84
+ "fantasy art",
85
+ "digital painting",
86
+ "anime style",
87
+ "oil painting",
88
+ "watercolor",
89
+ "comic book art",
90
+ "concept art"
91
+ ],
92
+ label="Art Style",
93
+ value="fantasy art",
94
+ info="Choose the artistic style"
95
+ )
96
+
97
+ portrait_quality = gr.Radio(
98
+ choices=["standard", "hd"],
99
+ label="Image Quality (OpenAI only)",
100
+ value="standard",
101
+ info="HD costs more tokens (OpenAI only)"
102
+ )
103
+
104
+ generate_portrait_btn = gr.Button(
105
+ "🎨 Generate Portrait",
106
+ variant="primary",
107
+ size="lg"
108
+ )
109
+
110
+ portrait_status = gr.Textbox(
111
+ label="Status",
112
+ lines=4
113
+ )
114
+
115
+ with gr.Column():
116
+ portrait_output = gr.Image(
117
+ label="Generated Portrait",
118
+ type="filepath",
119
+ height=512
120
+ )
121
+
122
+ gr.Markdown("""
123
+ **Providers:**
124
+ - **OpenAI DALL-E 3**: High quality, costs $0.04/image (standard) or $0.08/image (HD)
125
+ - **HuggingFace (Free!)**: Stable Diffusion XL, ~100 requests/day on free tier
126
+ - **Auto**: Tries OpenAI first, automatically falls back to HuggingFace if billing issues
127
+
128
+ Portraits are automatically saved to `data/portraits/` directory.
129
+
130
+ **Tips:**
131
+ - Use "auto" mode for seamless fallback
132
+ - OpenAI HD quality produces better results but costs 2x
133
+ - HuggingFace is free but may have a 30-60s warm-up time
134
+ - Different styles work better for different races/classes
135
+ """)
136
+
137
+ # Refresh portrait character dropdown
138
+ portrait_refresh_btn.click(
139
+ fn=self.dropdown_manager.refresh_character_dropdown,
140
+ inputs=[],
141
+ outputs=[portrait_character_dropdown]
142
+ )
143
+
144
+ # Generate portrait action - convert dropdown label to ID
145
+ def generate_portrait_from_dropdown(label, style, quality, provider):
146
+ char_id = self.dropdown_manager.get_character_id_from_label(label)
147
+ return self.generate_portrait_ui(char_id, style, quality, provider)
148
+
149
+ generate_portrait_btn.click(
150
+ fn=generate_portrait_from_dropdown,
151
+ inputs=[portrait_character_dropdown, portrait_style, portrait_quality, portrait_provider],
152
+ outputs=[portrait_output, portrait_status]
153
+ )
154
+
155
+ # Return the dropdown for auto-population
156
+ return portrait_character_dropdown
src/ui/tabs/session_tracking_tab.py ADDED
@@ -0,0 +1,599 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Session Tracking Tab for D'n'D Campaign Manager
3
+ """
4
+
5
+ import gradio as gr
6
+ import traceback
7
+ from src.agents.campaign_agent import CampaignAgent
8
+ from src.ui.components.dropdown_manager import DropdownManager
9
+
10
+
11
+ class SessionTrackingTab:
12
+ """Session Tracking tab for managing campaign sessions and events"""
13
+
14
+ def __init__(self, campaign_agent: CampaignAgent, dropdown_manager: DropdownManager):
15
+ self.campaign_agent = campaign_agent
16
+ self.dropdown_manager = dropdown_manager
17
+
18
+ def start_session_ui(self, campaign_id: str) -> str:
19
+ """Start a new session"""
20
+ try:
21
+ if not campaign_id.strip():
22
+ return "❌ Error: Please provide a campaign ID"
23
+
24
+ campaign = self.campaign_agent.load_campaign(campaign_id)
25
+ if not campaign:
26
+ return f"❌ Campaign not found: {campaign_id}"
27
+
28
+ self.campaign_agent.start_new_session(campaign_id)
29
+
30
+ return f"""βœ… Started Session {campaign.current_session + 1}!
31
+
32
+ **Campaign:** {campaign.name}
33
+ **New Session Number:** {campaign.current_session + 1}
34
+ **Total Sessions:** {campaign.total_sessions + 1}"""
35
+
36
+ except Exception as e:
37
+ return f"❌ Error: {str(e)}"
38
+
39
+ def get_session_notes_status(self, campaign_id: str) -> str:
40
+ """Get status of uploaded session notes for a campaign"""
41
+ try:
42
+ if not campaign_id.strip():
43
+ return ""
44
+
45
+ campaign = self.campaign_agent.load_campaign(campaign_id)
46
+ if not campaign:
47
+ return ""
48
+
49
+ # Get all session notes
50
+ all_notes = self.campaign_agent.get_session_notes(campaign_id)
51
+
52
+ if not all_notes:
53
+ return """
54
+ πŸ“Š **Session Notes Status:** No notes uploaded yet
55
+
56
+ πŸ’‘ **Tip:** Upload your session notes below to get better AI-generated sessions!
57
+ AI will use your notes to create sessions that respond to what actually happened."""
58
+
59
+ # Build status message
60
+ status = "πŸ“Š **Session Notes Available:**\n\n"
61
+ for note in sorted(all_notes, key=lambda x: x.session_number):
62
+ char_count = len(note.notes)
63
+ status += f"βœ… Session {note.session_number} - {char_count} characters"
64
+ if note.file_name:
65
+ status += f" ({note.file_name})"
66
+ status += "\n"
67
+
68
+ status += f"\nπŸ’‘ AI will use these {len(all_notes)} session note(s) to generate contextual next sessions!"
69
+
70
+ return status
71
+
72
+ except Exception as e:
73
+ return f"❌ Error checking notes: {str(e)}"
74
+
75
+ def auto_generate_session_ui(self, campaign_id: str) -> str:
76
+ """Auto-generate next session using AI"""
77
+ try:
78
+ if not campaign_id.strip():
79
+ return "❌ Error: Please select a campaign"
80
+
81
+ campaign = self.campaign_agent.load_campaign(campaign_id)
82
+ if not campaign:
83
+ return f"❌ Campaign not found: {campaign_id}"
84
+
85
+ # Generate session using autonomous AI
86
+ session_data = self.campaign_agent.auto_generate_next_session(campaign_id)
87
+
88
+ if 'error' in session_data:
89
+ return f"❌ Error: {session_data['error']}"
90
+
91
+ # Format output for display
92
+ output = []
93
+ output.append(f"# πŸ€– Auto-Generated Session {session_data.get('session_number', 'N/A')}")
94
+ output.append(f"\n**Campaign:** {campaign.name}")
95
+ output.append(f"\n**Session Title:** {session_data.get('session_title', 'Untitled')}")
96
+ output.append(f"\n---\n")
97
+
98
+ # Opening Scene
99
+ if 'opening_scene' in session_data:
100
+ output.append(f"## 🎬 Opening Scene\n\n{session_data['opening_scene']}\n\n")
101
+
102
+ # Key Encounters
103
+ if 'key_encounters' in session_data and session_data['key_encounters']:
104
+ output.append("## βš”οΈ Key Encounters\n\n")
105
+ for i, encounter in enumerate(session_data['key_encounters'], 1):
106
+ output.append(f"{i}. {encounter}\n")
107
+ output.append("\n")
108
+
109
+ # NPCs Featured
110
+ if 'npcs_featured' in session_data and session_data['npcs_featured']:
111
+ output.append("## πŸ‘₯ NPCs Featured\n\n")
112
+ for npc in session_data['npcs_featured']:
113
+ output.append(f"- {npc}\n")
114
+ output.append("\n")
115
+
116
+ # Locations
117
+ if 'locations' in session_data and session_data['locations']:
118
+ output.append("## πŸ—ΊοΈ Locations\n\n")
119
+ for loc in session_data['locations']:
120
+ output.append(f"- {loc}\n")
121
+ output.append("\n")
122
+
123
+ # Plot Developments
124
+ if 'plot_developments' in session_data and session_data['plot_developments']:
125
+ output.append("## πŸ“– Plot Developments\n\n")
126
+ for i, dev in enumerate(session_data['plot_developments'], 1):
127
+ output.append(f"{i}. {dev}\n")
128
+ output.append("\n")
129
+
130
+ # Potential Outcomes
131
+ if 'potential_outcomes' in session_data and session_data['potential_outcomes']:
132
+ output.append("## 🎲 Potential Outcomes\n\n")
133
+ for i, outcome in enumerate(session_data['potential_outcomes'], 1):
134
+ output.append(f"{i}. {outcome}\n")
135
+ output.append("\n")
136
+
137
+ # Rewards
138
+ if 'rewards' in session_data and session_data['rewards']:
139
+ output.append("## πŸ’° Rewards\n\n")
140
+ for reward in session_data['rewards']:
141
+ output.append(f"- {reward}\n")
142
+ output.append("\n")
143
+
144
+ # Cliffhanger
145
+ if 'cliffhanger' in session_data and session_data['cliffhanger']:
146
+ output.append(f"## 🎭 Cliffhanger\n\n{session_data['cliffhanger']}\n\n")
147
+
148
+ output.append("---\n\n")
149
+ output.append("βœ… **Session plan generated successfully!**\n\n")
150
+ output.append("πŸ’‘ **Next Steps:**\n")
151
+ output.append("- Review the session plan above\n")
152
+ output.append("- Adjust encounters/NPCs as needed for your table\n")
153
+ output.append("- Copy relevant sections to your session notes\n")
154
+ output.append("- Start the session when ready!\n")
155
+
156
+ return "".join(output)
157
+
158
+ except Exception as e:
159
+ return f"❌ Error generating session:\n\n{str(e)}\n\n{traceback.format_exc()}"
160
+
161
+ def save_session_notes_ui(
162
+ self,
163
+ campaign_id: str,
164
+ session_number: int,
165
+ file_path: str,
166
+ notes_text: str
167
+ ) -> str:
168
+ """Save session notes from file upload or text input"""
169
+ try:
170
+ if not campaign_id.strip():
171
+ return "❌ Please select a campaign"
172
+
173
+ campaign = self.campaign_agent.load_campaign(campaign_id)
174
+ if not campaign:
175
+ return f"❌ Campaign not found: {campaign_id}"
176
+
177
+ # Use file content if uploaded, otherwise use text area
178
+ content = ""
179
+ file_name = None
180
+ file_type = None
181
+
182
+ if file_path:
183
+ try:
184
+ from src.utils.file_parsers import parse_uploaded_file, get_file_info
185
+ from pathlib import Path
186
+
187
+ content = parse_uploaded_file(file_path)
188
+ file_info = get_file_info(file_path)
189
+ file_name = file_info['name']
190
+ file_type = file_info['extension']
191
+
192
+ except Exception as e:
193
+ return f"❌ Error parsing file: {str(e)}"
194
+
195
+ elif notes_text.strip():
196
+ content = notes_text
197
+ else:
198
+ return "❌ Please upload a file or paste notes"
199
+
200
+ # Save to database
201
+ try:
202
+ self.campaign_agent.save_session_notes(
203
+ campaign_id=campaign_id,
204
+ session_number=int(session_number),
205
+ notes=content,
206
+ file_name=file_name,
207
+ file_type=file_type
208
+ )
209
+
210
+ # Get updated session notes count
211
+ all_notes = self.campaign_agent.get_session_notes(campaign_id)
212
+ notes_count = len(all_notes)
213
+
214
+ # Build success message with context
215
+ message = f"""βœ… **Session notes saved successfully!**
216
+
217
+ **Campaign:** {campaign.name}
218
+ **Session:** {session_number}
219
+ **Content length:** {len(content)} characters
220
+ **Source:** {'πŸ“Ž File upload' if file_path else '✍️ Direct paste'}
221
+ {f'**File:** {file_name}' if file_name else ''}
222
+
223
+ ---
224
+
225
+ πŸ“Š **Your Campaign Now Has:**
226
+ - {notes_count} session{'s' if notes_count != 1 else ''} with uploaded notes
227
+ - Session {session_number} notes just added
228
+
229
+ ---
230
+
231
+ 🎯 **What You Can Do Next:**
232
+
233
+ 1. **Generate Session {int(session_number) + 1}:**
234
+ - Scroll up to **"Step 1: πŸ€– Auto-Generate Next Session"**
235
+ - Select "{campaign.name}"
236
+ - Click "✨ Auto-Generate Next Session"
237
+ - AI will use your Session {session_number} notes to create contextual content!
238
+
239
+ 2. **Upload More Sessions:**
240
+ - Have notes from other sessions? Upload them too!
241
+ - More notes = better AI-generated sessions
242
+
243
+ 3. **Review Your Notes:**
244
+ - Your notes are saved and will be used automatically
245
+ - AI analyzes: player choices, NPCs, unresolved hooks, consequences
246
+
247
+ ---
248
+
249
+ πŸ’‘ **How It Works:**
250
+ When you generate the next session (Step 1), the AI will:
251
+ βœ… Read your uploaded notes (last 2-3 sessions)
252
+ βœ… Build on what actually happened at your table
253
+ βœ… Address unresolved plot hooks you mentioned
254
+ βœ… Create encounters that respond to player decisions
255
+
256
+ **Ready to generate Session {int(session_number) + 1}? Scroll up to Step 1!** ⬆️"""
257
+
258
+ return message
259
+
260
+ except Exception as e:
261
+ return f"❌ Error saving notes: {str(e)}"
262
+
263
+ except Exception as e:
264
+ return f"❌ Error: {str(e)}\n\n{traceback.format_exc()}"
265
+
266
+ def add_event_ui(
267
+ self,
268
+ campaign_id: str,
269
+ event_type: str,
270
+ title: str,
271
+ description: str,
272
+ importance: int
273
+ ) -> str:
274
+ """Add an event to the campaign"""
275
+ try:
276
+ if not campaign_id.strip():
277
+ return "❌ Error: Please provide a campaign ID"
278
+
279
+ if not title.strip() or not description.strip():
280
+ return "❌ Error: Please provide event title and description"
281
+
282
+ event = self.campaign_agent.add_event(
283
+ campaign_id=campaign_id,
284
+ event_type=event_type,
285
+ title=title,
286
+ description=description,
287
+ importance=importance
288
+ )
289
+
290
+ if event:
291
+ return f"""βœ… Event Added!
292
+
293
+ **Title:** {title}
294
+ **Type:** {event_type}
295
+ **Importance:** {'⭐' * importance}
296
+
297
+ Event has been recorded in campaign history."""
298
+ else:
299
+ return f"❌ Campaign not found: {campaign_id}"
300
+
301
+ except Exception as e:
302
+ return f"❌ Error: {str(e)}"
303
+
304
+ def create(self) -> tuple:
305
+ """Create and return the Session Tracking tab component"""
306
+ with gr.Tab("Session Tracking"):
307
+ gr.Markdown("""
308
+ # 🎲 Session Tracking & Planning
309
+
310
+ **Workflow:** Auto-generate next session β†’ Play the session β†’ Upload notes afterward
311
+
312
+ ---
313
+ """)
314
+
315
+ # SECTION 1: Auto-Generate Next Session
316
+ gr.Markdown("## Step 1: πŸ€– Auto-Generate Next Session")
317
+ gr.Markdown("""
318
+ **Before playing:** Let the AI create your session plan!
319
+
320
+ **Autonomous Feature:** AI analyzes your campaign and automatically generates a complete session plan.
321
+
322
+ This includes:
323
+ - Opening scene narration
324
+ - Key encounters (combat, social, exploration)
325
+ - NPCs featured in the session
326
+ - Locations to visit
327
+ - Plot developments
328
+ - Potential outcomes and rewards
329
+
330
+ πŸ’‘ **Tip:** The AI uses your uploaded session notes from previous sessions to create contextual, story-driven content!
331
+ """)
332
+
333
+ auto_session_refresh_btn = gr.Button("πŸ”„ Refresh Campaign List", variant="secondary")
334
+
335
+ auto_session_campaign_dropdown = gr.Dropdown(
336
+ choices=[],
337
+ label="Select Campaign",
338
+ info="Choose campaign to generate next session for",
339
+ allow_custom_value=False,
340
+ interactive=True
341
+ )
342
+
343
+ # Session notes status display
344
+ session_notes_status = gr.Markdown(
345
+ value="",
346
+ label="Session Notes Status"
347
+ )
348
+
349
+ auto_generate_session_btn = gr.Button("✨ Auto-Generate Next Session", variant="primary")
350
+
351
+ auto_session_output = gr.Textbox(label="Generated Session Plan", lines=20)
352
+
353
+ # Refresh auto-session campaign dropdown
354
+ auto_session_refresh_btn.click(
355
+ fn=self.dropdown_manager.refresh_campaign_dropdown,
356
+ inputs=[],
357
+ outputs=[auto_session_campaign_dropdown]
358
+ )
359
+
360
+ # Update session notes status when campaign is selected
361
+ def update_notes_status(campaign_label):
362
+ campaign_id = self.dropdown_manager.get_campaign_id_from_label(campaign_label)
363
+ return self.get_session_notes_status(campaign_id)
364
+
365
+ auto_session_campaign_dropdown.change(
366
+ fn=update_notes_status,
367
+ inputs=[auto_session_campaign_dropdown],
368
+ outputs=[session_notes_status]
369
+ )
370
+
371
+ # Auto-generate session
372
+ def auto_generate_session_from_dropdown(label):
373
+ campaign_id = self.dropdown_manager.get_campaign_id_from_label(label)
374
+ return self.auto_generate_session_ui(campaign_id)
375
+
376
+ auto_generate_session_btn.click(
377
+ fn=auto_generate_session_from_dropdown,
378
+ inputs=[auto_session_campaign_dropdown],
379
+ outputs=[auto_session_output]
380
+ )
381
+
382
+ gr.Markdown("---")
383
+
384
+ # SECTION 2: Start New Session
385
+ gr.Markdown("## Step 2: 🎬 Start New Session")
386
+ gr.Markdown("""
387
+ **Ready to play?** Start your session here!
388
+
389
+ This will:
390
+ - Increment the session counter
391
+ - Track that a new session has begun
392
+ - Prepare for event logging
393
+
394
+ πŸ’‘ **When to use:** Right before you start playing with your group.
395
+ """)
396
+
397
+ session_refresh_btn = gr.Button("πŸ”„ Refresh Campaign List", variant="secondary")
398
+
399
+ session_campaign_dropdown = gr.Dropdown(
400
+ choices=[],
401
+ label="Select Campaign",
402
+ info="Choose the campaign for session tracking (type to search)",
403
+ allow_custom_value=False,
404
+ interactive=True
405
+ )
406
+
407
+ start_session_btn = gr.Button("🎬 Start New Session", variant="primary")
408
+
409
+ session_status = gr.Textbox(label="Status", lines=4)
410
+
411
+ # Refresh session campaign dropdown
412
+ session_refresh_btn.click(
413
+ fn=self.dropdown_manager.refresh_campaign_dropdown,
414
+ inputs=[],
415
+ outputs=[session_campaign_dropdown]
416
+ )
417
+
418
+ # Start session - convert dropdown label to ID
419
+ def start_session_from_dropdown(label):
420
+ campaign_id = self.dropdown_manager.get_campaign_id_from_label(label)
421
+ return self.start_session_ui(campaign_id)
422
+
423
+ start_session_btn.click(
424
+ fn=start_session_from_dropdown,
425
+ inputs=[session_campaign_dropdown],
426
+ outputs=[session_status]
427
+ )
428
+
429
+ gr.Markdown("---")
430
+
431
+ # SECTION 3: Upload Session Notes
432
+ gr.Markdown("## Step 3: πŸ“ Upload Session Notes (After Playing)")
433
+ gr.Markdown("""
434
+ **Just finished a session?** Upload your DM notes here!
435
+
436
+ The AI will use your notes to generate better, more contextual next sessions.
437
+
438
+ **Supported formats:** .txt, .md, .docx, .pdf
439
+
440
+ **What to include:**
441
+ - What actually happened in the session
442
+ - Player choices and consequences
443
+ - Improvised content that worked well
444
+ - Unresolved plot hooks
445
+ - NPC interactions and developments
446
+
447
+ πŸ’‘ **Why this matters:** When you generate the next session (Step 1), the AI reads these notes to create content that responds to your actual gameplay!
448
+ """)
449
+
450
+ notes_refresh_btn = gr.Button("πŸ”„ Refresh Campaign List", variant="secondary")
451
+
452
+ notes_campaign_dropdown = gr.Dropdown(
453
+ choices=[],
454
+ label="Select Campaign",
455
+ info="Choose campaign to upload notes for"
456
+ )
457
+
458
+ notes_session_number = gr.Number(
459
+ label="Session Number",
460
+ value=1,
461
+ precision=0,
462
+ info="Which session are these notes for?"
463
+ )
464
+
465
+ notes_file_upload = gr.File(
466
+ label="Upload Session Notes File (Optional)",
467
+ file_types=['.txt', '.md', '.docx', '.pdf'],
468
+ type='filepath'
469
+ )
470
+
471
+ notes_text_area = gr.Textbox(
472
+ label="Or Paste Notes Directly",
473
+ lines=15,
474
+ placeholder="""Session 3 - The Lost Temple
475
+
476
+ The party arrived at the temple ruins after a week of travel...
477
+
478
+ Key moments:
479
+ - Grimm discovered a hidden passage behind the altar
480
+ - Elara deciphered ancient runes warning of a curse
481
+ - Combat with temple guardians (party took heavy damage, used most healing)
482
+ - Found the Crystal of Shadows but Thorin decided not to take it
483
+ - NPC Velorin revealed he's been following them - claims to protect the crystal
484
+
485
+ Unresolved:
486
+ - Why was Velorin really following them?
487
+ - What happens if they don't take the crystal? Will someone else?
488
+ - The guardian mentioned "the ritual" before dying - what ritual?
489
+ - Party suspects Velorin isn't telling the whole truth
490
+
491
+ Next session setup:
492
+ - Party needs to rest and heal
493
+ - Velorin wants to talk
494
+ - Strange shadows gathering outside temple""",
495
+ info="Freeform notes about what happened in the session"
496
+ )
497
+
498
+ save_notes_btn = gr.Button("πŸ’Ύ Save Session Notes", variant="primary")
499
+ notes_status = gr.Textbox(label="Status", lines=6)
500
+
501
+ # Refresh notes campaign dropdown
502
+ notes_refresh_btn.click(
503
+ fn=self.dropdown_manager.refresh_campaign_dropdown,
504
+ inputs=[],
505
+ outputs=[notes_campaign_dropdown]
506
+ )
507
+
508
+ # Save notes event handler
509
+ def save_notes_from_dropdown(campaign_label, session_num, file_path, notes_text):
510
+ campaign_id = self.dropdown_manager.get_campaign_id_from_label(campaign_label)
511
+ return self.save_session_notes_ui(campaign_id, session_num, file_path, notes_text)
512
+
513
+ save_notes_btn.click(
514
+ fn=save_notes_from_dropdown,
515
+ inputs=[notes_campaign_dropdown, notes_session_number, notes_file_upload, notes_text_area],
516
+ outputs=[notes_status]
517
+ )
518
+
519
+ gr.Markdown("---")
520
+
521
+ # SECTION 4: Add Session Event (Manual Tracking)
522
+ gr.Markdown("## Step 4: πŸ“‹ Add Session Event (Optional)")
523
+ gr.Markdown("""
524
+ **Want to manually track specific events?** Add them here!
525
+
526
+ This is optional - use it if you want to log specific moments during or after your session.
527
+
528
+ πŸ’‘ **Note:** This is separate from session notes. Use this for quick event logging, and use session notes (Step 3) for comprehensive session summaries.
529
+ """)
530
+
531
+ event_refresh_btn = gr.Button("πŸ”„ Refresh Campaign List", variant="secondary")
532
+
533
+ event_campaign_dropdown = gr.Dropdown(
534
+ choices=[],
535
+ label="Select Campaign",
536
+ info="Choose the campaign to add event to (type to search)",
537
+ allow_custom_value=False,
538
+ interactive=True
539
+ )
540
+
541
+ event_type = gr.Dropdown(
542
+ choices=["Combat", "Social", "Exploration", "Discovery", "Plot Development", "Character Moment", "NPC Interaction", "Quest Update"],
543
+ label="Event Type",
544
+ value="Combat",
545
+ info="Type of event"
546
+ )
547
+
548
+ event_title = gr.Textbox(
549
+ label="Event Title",
550
+ placeholder="Battle at the Bridge",
551
+ info="Short title for the event"
552
+ )
553
+
554
+ event_description = gr.Textbox(
555
+ label="Event Description",
556
+ placeholder="The party encountered a group of bandits...",
557
+ lines=4,
558
+ info="Detailed description of what happened"
559
+ )
560
+
561
+ event_importance = gr.Slider(
562
+ minimum=1,
563
+ maximum=5,
564
+ value=3,
565
+ step=1,
566
+ label="Importance",
567
+ info="How important is this event? (1-5 stars)"
568
+ )
569
+
570
+ add_event_btn = gr.Button("πŸ“ Add Event", variant="primary")
571
+
572
+ event_status = gr.Textbox(label="Status", lines=6)
573
+
574
+ # Refresh event campaign dropdown
575
+ event_refresh_btn.click(
576
+ fn=self.dropdown_manager.refresh_campaign_dropdown,
577
+ inputs=[],
578
+ outputs=[event_campaign_dropdown]
579
+ )
580
+
581
+ # Add event - convert dropdown label to ID
582
+ def add_event_from_dropdown(campaign_label, event_type_val, title, description, importance):
583
+ campaign_id = self.dropdown_manager.get_campaign_id_from_label(campaign_label)
584
+ return self.add_event_ui(campaign_id, event_type_val, title, description, importance)
585
+
586
+ add_event_btn.click(
587
+ fn=add_event_from_dropdown,
588
+ inputs=[
589
+ event_campaign_dropdown,
590
+ event_type,
591
+ event_title,
592
+ event_description,
593
+ event_importance
594
+ ],
595
+ outputs=[event_status]
596
+ )
597
+
598
+ # Return dropdowns for auto-population
599
+ return session_campaign_dropdown, auto_session_campaign_dropdown, notes_campaign_dropdown, event_campaign_dropdown
src/ui/utils/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """
2
+ Utility functions for D'n'D Campaign Manager UI
3
+ """
4
+
5
+ __all__ = []
src/utils/__init__.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Utility functions and helpers
3
+ """
4
+
5
+ from .ai_client import AIClient, get_ai_client
6
+ from .dice import DiceRoller
7
+ from .database import Database, get_database
8
+ from .validators import validate_character, validate_campaign
9
+ from .image_generator import ImageGenerator, get_image_generator
10
+
11
+ __all__ = [
12
+ "AIClient",
13
+ "get_ai_client",
14
+ "DiceRoller",
15
+ "Database",
16
+ "get_database",
17
+ "validate_character",
18
+ "validate_campaign",
19
+ "ImageGenerator",
20
+ "get_image_generator",
21
+ ]
src/utils/ai_client.py ADDED
@@ -0,0 +1,294 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ AI Client Manager - Unified interface for multiple LLM providers
3
+ Supports Anthropic Claude, Google Gemini, and OpenAI
4
+ """
5
+
6
+ from typing import Optional, Dict, Any, List
7
+ from enum import Enum
8
+ import anthropic
9
+ import google.generativeai as genai
10
+ from openai import OpenAI
11
+
12
+ from src.config import config
13
+
14
+
15
+ class Provider(str, Enum):
16
+ """Supported AI providers"""
17
+ ANTHROPIC = "anthropic"
18
+ GOOGLE = "google"
19
+ OPENAI = "openai"
20
+
21
+
22
+ class AIClient:
23
+ """Unified AI client supporting multiple providers"""
24
+
25
+ def __init__(self):
26
+ """Initialize all available clients"""
27
+ self.anthropic_client: Optional[anthropic.Anthropic] = None
28
+ self.google_client: Optional[Any] = None
29
+ self.openai_client: Optional[OpenAI] = None
30
+
31
+ self._initialize_clients()
32
+
33
+ def _initialize_clients(self):
34
+ """Initialize available AI clients based on API keys"""
35
+ # Anthropic Claude
36
+ if config.anthropic_api_key:
37
+ try:
38
+ self.anthropic_client = anthropic.Anthropic(
39
+ api_key=config.anthropic_api_key
40
+ )
41
+ except Exception as e:
42
+ print(f"Failed to initialize Anthropic client: {e}")
43
+
44
+ # Google Gemini
45
+ if config.google_api_key:
46
+ try:
47
+ genai.configure(api_key=config.google_api_key)
48
+ self.google_client = genai
49
+ except Exception as e:
50
+ print(f"Failed to initialize Google client: {e}")
51
+
52
+ # OpenAI
53
+ if config.openai_api_key:
54
+ try:
55
+ self.openai_client = OpenAI(api_key=config.openai_api_key)
56
+ except Exception as e:
57
+ print(f"Failed to initialize OpenAI client: {e}")
58
+
59
+ def generate(
60
+ self,
61
+ prompt: str,
62
+ provider: Optional[Provider] = None,
63
+ model: Optional[str] = None,
64
+ temperature: float = 0.7,
65
+ max_tokens: int = 2000,
66
+ system_prompt: Optional[str] = None,
67
+ ) -> str:
68
+ """
69
+ Generate text using specified provider
70
+
71
+ Args:
72
+ prompt: User prompt
73
+ provider: AI provider to use (defaults to config)
74
+ model: Model name (defaults to config)
75
+ temperature: Sampling temperature
76
+ max_tokens: Maximum tokens to generate
77
+ system_prompt: System prompt for context
78
+
79
+ Returns:
80
+ Generated text
81
+ """
82
+ # Use defaults from config if not specified
83
+ if provider is None:
84
+ provider = Provider(config.model.primary_provider)
85
+ if model is None:
86
+ model = config.model.primary_model
87
+
88
+ # Route to appropriate provider
89
+ if provider == Provider.ANTHROPIC:
90
+ return self._generate_anthropic(prompt, model, temperature, max_tokens, system_prompt)
91
+ elif provider == Provider.GOOGLE:
92
+ return self._generate_google(prompt, model, temperature, max_tokens, system_prompt)
93
+ elif provider == Provider.OPENAI:
94
+ return self._generate_openai(prompt, model, temperature, max_tokens, system_prompt)
95
+ else:
96
+ raise ValueError(f"Unsupported provider: {provider}")
97
+
98
+ def _generate_anthropic(
99
+ self,
100
+ prompt: str,
101
+ model: str,
102
+ temperature: float,
103
+ max_tokens: int,
104
+ system_prompt: Optional[str],
105
+ ) -> str:
106
+ """Generate using Anthropic Claude"""
107
+ if not self.anthropic_client:
108
+ raise RuntimeError("Anthropic client not initialized")
109
+
110
+ messages = [{"role": "user", "content": prompt}]
111
+
112
+ kwargs = {
113
+ "model": model,
114
+ "messages": messages,
115
+ "temperature": temperature,
116
+ "max_tokens": max_tokens,
117
+ }
118
+
119
+ if system_prompt:
120
+ kwargs["system"] = system_prompt
121
+
122
+ try:
123
+ response = self.anthropic_client.messages.create(**kwargs)
124
+ return response.content[0].text
125
+ except Exception as e:
126
+ raise RuntimeError(f"Anthropic API error: {e}")
127
+
128
+ def _generate_google(
129
+ self,
130
+ prompt: str,
131
+ model: str,
132
+ temperature: float,
133
+ max_tokens: int,
134
+ system_prompt: Optional[str],
135
+ ) -> str:
136
+ """Generate using Google Gemini"""
137
+ if not self.google_client:
138
+ raise RuntimeError("Google client not initialized")
139
+
140
+ try:
141
+ gemini_model = self.google_client.GenerativeModel(model)
142
+
143
+ generation_config = {
144
+ "temperature": temperature,
145
+ "max_output_tokens": max_tokens,
146
+ }
147
+
148
+ # Combine system prompt and user prompt
149
+ full_prompt = prompt
150
+ if system_prompt:
151
+ full_prompt = f"{system_prompt}\n\n{prompt}"
152
+
153
+ response = gemini_model.generate_content(
154
+ full_prompt,
155
+ generation_config=generation_config
156
+ )
157
+
158
+ return response.text
159
+ except Exception as e:
160
+ raise RuntimeError(f"Google API error: {e}")
161
+
162
+ def _generate_openai(
163
+ self,
164
+ prompt: str,
165
+ model: str,
166
+ temperature: float,
167
+ max_tokens: int,
168
+ system_prompt: Optional[str],
169
+ ) -> str:
170
+ """Generate using OpenAI"""
171
+ if not self.openai_client:
172
+ raise RuntimeError("OpenAI client not initialized")
173
+
174
+ messages = []
175
+ if system_prompt:
176
+ messages.append({"role": "system", "content": system_prompt})
177
+ messages.append({"role": "user", "content": prompt})
178
+
179
+ try:
180
+ response = self.openai_client.chat.completions.create(
181
+ model=model,
182
+ messages=messages,
183
+ temperature=temperature,
184
+ max_tokens=max_tokens,
185
+ )
186
+ return response.choices[0].message.content
187
+ except Exception as e:
188
+ raise RuntimeError(f"OpenAI API error: {e}")
189
+
190
+ def generate_with_memory(
191
+ self,
192
+ prompt: str,
193
+ context: str,
194
+ provider: Optional[Provider] = None,
195
+ model: Optional[str] = None,
196
+ ) -> str:
197
+ """
198
+ Generate with long context using memory model (Gemini 2.0)
199
+
200
+ Args:
201
+ prompt: User prompt
202
+ context: Long context/memory to include
203
+ provider: Provider to use (defaults to memory provider)
204
+ model: Model to use (defaults to memory model)
205
+
206
+ Returns:
207
+ Generated text
208
+ """
209
+ # Use memory provider by default
210
+ if provider is None:
211
+ provider = Provider(config.model.memory_provider)
212
+ if model is None:
213
+ model = config.model.memory_model
214
+
215
+ # Combine context and prompt
216
+ full_prompt = f"""# Campaign Context
217
+ {context}
218
+
219
+ # Current Query
220
+ {prompt}
221
+
222
+ Please answer based on the campaign context provided."""
223
+
224
+ return self.generate(
225
+ prompt=full_prompt,
226
+ provider=provider,
227
+ model=model,
228
+ temperature=config.model.balanced_temp,
229
+ max_tokens=config.model.max_tokens_memory,
230
+ )
231
+
232
+ def generate_creative(self, prompt: str, system_prompt: Optional[str] = None) -> str:
233
+ """Generate creative content (characters, stories, etc.)"""
234
+ return self.generate(
235
+ prompt=prompt,
236
+ temperature=config.model.creative_temp,
237
+ max_tokens=config.model.max_tokens_generation,
238
+ system_prompt=system_prompt,
239
+ )
240
+
241
+ def generate_precise(self, prompt: str, system_prompt: Optional[str] = None) -> str:
242
+ """Generate precise content (rules, stats, etc.)"""
243
+ return self.generate(
244
+ prompt=prompt,
245
+ temperature=config.model.precise_temp,
246
+ max_tokens=config.model.max_tokens_generation,
247
+ system_prompt=system_prompt,
248
+ )
249
+
250
+ def is_available(self, provider: Provider) -> bool:
251
+ """Check if provider is available"""
252
+ if provider == Provider.ANTHROPIC:
253
+ return self.anthropic_client is not None
254
+ elif provider == Provider.GOOGLE:
255
+ return self.google_client is not None
256
+ elif provider == Provider.OPENAI:
257
+ return self.openai_client is not None
258
+ return False
259
+
260
+
261
+ # Global client instance
262
+ _client: Optional[AIClient] = None
263
+
264
+
265
+ def get_ai_client() -> AIClient:
266
+ """Get or create global AI client instance"""
267
+ global _client
268
+ if _client is None:
269
+ _client = AIClient()
270
+ return _client
271
+
272
+
273
+ # Convenience functions
274
+ def generate_text(
275
+ prompt: str,
276
+ temperature: float = 0.7,
277
+ max_tokens: int = 2000,
278
+ system_prompt: Optional[str] = None,
279
+ ) -> str:
280
+ """Quick text generation"""
281
+ client = get_ai_client()
282
+ return client.generate(prompt, temperature=temperature, max_tokens=max_tokens, system_prompt=system_prompt)
283
+
284
+
285
+ def generate_creative_text(prompt: str, system_prompt: Optional[str] = None) -> str:
286
+ """Quick creative text generation"""
287
+ client = get_ai_client()
288
+ return client.generate_creative(prompt, system_prompt=system_prompt)
289
+
290
+
291
+ def generate_precise_text(prompt: str, system_prompt: Optional[str] = None) -> str:
292
+ """Quick precise text generation"""
293
+ client = get_ai_client()
294
+ return client.generate_precise(prompt, system_prompt=system_prompt)
src/utils/character_sheet_exporter.py ADDED
@@ -0,0 +1,567 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Character Sheet Export System - Generate D&D 5e Character Sheets
3
+ Supports multiple formats: Markdown, JSON, PDF (future), PNG (future)
4
+ """
5
+
6
+ from typing import Optional
7
+ from pathlib import Path
8
+ from datetime import datetime
9
+
10
+ from src.models.character import Character, HIT_DICE_BY_CLASS
11
+
12
+
13
+ class CharacterSheetExporter:
14
+ """Export D&D characters to various formats"""
15
+
16
+ def __init__(self):
17
+ self.output_dir = Path("data/exports")
18
+ self.output_dir.mkdir(parents=True, exist_ok=True)
19
+
20
+ def export_to_markdown(self, character: Character, include_portrait: bool = True) -> str:
21
+ """
22
+ Export character to detailed D&D 5e markdown format
23
+
24
+ Args:
25
+ character: Character to export
26
+ include_portrait: Whether to include portrait reference
27
+
28
+ Returns:
29
+ Markdown formatted character sheet
30
+ """
31
+ md = []
32
+
33
+ # Header with character name
34
+ md.append(f"# {character.name}")
35
+ md.append(f"*Level {character.level} {character.race.value} {character.character_class.value}*")
36
+ md.append(f"**{character.alignment.value}**")
37
+
38
+ if character.gender:
39
+ md.append(f"*{character.gender}*")
40
+
41
+ md.append("")
42
+ md.append("---")
43
+ md.append("")
44
+
45
+ # Portrait reference
46
+ if include_portrait and character.portrait_url:
47
+ md.append(f"![Character Portrait]({character.portrait_url})")
48
+ md.append("")
49
+
50
+ # Core Combat Stats
51
+ md.append("## βš”οΈ Combat Statistics")
52
+ md.append("")
53
+ md.append(f"**Armor Class:** {character.armor_class} ")
54
+ md.append(f"**Hit Points:** {character.current_hit_points} / {character.max_hit_points} ")
55
+ md.append(f"**Hit Dice:** {character.level}d{HIT_DICE_BY_CLASS.get(character.character_class, 8)} ")
56
+ md.append(f"**Proficiency Bonus:** +{character.proficiency_bonus} ")
57
+ md.append(f"**Initiative:** {character.stats.dexterity_modifier:+d} (Dex) ")
58
+ md.append(f"**Speed:** 30 ft. ")
59
+ md.append(f"**Experience Points:** {character.experience_points} XP")
60
+ md.append("")
61
+
62
+ # Ability Scores - Clean vertical list format
63
+ md.append("## πŸ’ͺ Ability Scores")
64
+ md.append("")
65
+
66
+ str_mod = character.stats.strength_modifier
67
+ dex_mod = character.stats.dexterity_modifier
68
+ con_mod = character.stats.constitution_modifier
69
+ int_mod = character.stats.intelligence_modifier
70
+ wis_mod = character.stats.wisdom_modifier
71
+ cha_mod = character.stats.charisma_modifier
72
+
73
+ md.append(f"- **Strength:** {character.stats.strength} ({str_mod:+d})")
74
+ md.append(f"- **Dexterity:** {character.stats.dexterity} ({dex_mod:+d})")
75
+ md.append(f"- **Constitution:** {character.stats.constitution} ({con_mod:+d})")
76
+ md.append(f"- **Intelligence:** {character.stats.intelligence} ({int_mod:+d})")
77
+ md.append(f"- **Wisdom:** {character.stats.wisdom} ({wis_mod:+d})")
78
+ md.append(f"- **Charisma:** {character.stats.charisma} ({cha_mod:+d})")
79
+ md.append("")
80
+
81
+ # Saving Throws - Clean vertical list
82
+ proficient_saves = [p for p in character.proficiencies if p.startswith("Saving Throws:")]
83
+ if proficient_saves:
84
+ md.append("## πŸ›‘οΈ Saving Throws")
85
+ md.append("")
86
+
87
+ # Determine which saves get proficiency bonus
88
+ prof_str = "Strength" in proficient_saves[0]
89
+ prof_dex = "Dexterity" in proficient_saves[0]
90
+ prof_con = "Constitution" in proficient_saves[0]
91
+ prof_int = "Intelligence" in proficient_saves[0]
92
+ prof_wis = "Wisdom" in proficient_saves[0]
93
+ prof_cha = "Charisma" in proficient_saves[0]
94
+
95
+ prof_bonus = character.proficiency_bonus
96
+
97
+ str_save = str_mod + (prof_bonus if prof_str else 0)
98
+ dex_save = dex_mod + (prof_bonus if prof_dex else 0)
99
+ con_save = con_mod + (prof_bonus if prof_con else 0)
100
+ int_save = int_mod + (prof_bonus if prof_int else 0)
101
+ wis_save = wis_mod + (prof_bonus if prof_wis else 0)
102
+ cha_save = cha_mod + (prof_bonus if prof_cha else 0)
103
+
104
+ md.append(f"- **Strength:** {str_save:+d}{' βœ“' if prof_str else ''}")
105
+ md.append(f"- **Dexterity:** {dex_save:+d}{' βœ“' if prof_dex else ''}")
106
+ md.append(f"- **Constitution:** {con_save:+d}{' βœ“' if prof_con else ''}")
107
+ md.append(f"- **Intelligence:** {int_save:+d}{' βœ“' if prof_int else ''}")
108
+ md.append(f"- **Wisdom:** {wis_save:+d}{' βœ“' if prof_wis else ''}")
109
+ md.append(f"- **Charisma:** {cha_save:+d}{' βœ“' if prof_cha else ''}")
110
+ md.append("")
111
+ md.append("*βœ“ = Proficient (includes +{} proficiency bonus)*".format(prof_bonus))
112
+ md.append("")
113
+
114
+ # Proficiencies - separate into categories for clarity
115
+ md.append("## 🎯 Proficiencies")
116
+ md.append("")
117
+
118
+ if character.proficiencies:
119
+ # Group proficiencies by type
120
+ skills = [p for p in character.proficiencies if p.startswith("Choose") or p.startswith("Background")]
121
+ armor_weapons = [p for p in character.proficiencies if any(x in p for x in ["armor", "Weapons:", "weapons", "shields", "Tools:"])]
122
+ saves_list = [p for p in character.proficiencies if p.startswith("Saving Throws:")]
123
+
124
+ if skills:
125
+ md.append("### Skills")
126
+ for skill in skills:
127
+ md.append(f"- {skill}")
128
+ md.append("")
129
+
130
+ if armor_weapons:
131
+ md.append("### Armor, Weapons & Tools")
132
+ for item in armor_weapons:
133
+ md.append(f"- {item}")
134
+ md.append("")
135
+ else:
136
+ md.append("*No proficiencies listed*")
137
+ md.append("")
138
+
139
+ # Background
140
+ md.append("## πŸ“– Background & Personality")
141
+ md.append("")
142
+ md.append(f"**Background**: {character.background.background_type}")
143
+ md.append("")
144
+
145
+ if character.background.personality_traits:
146
+ md.append("**Personality Traits**:")
147
+ for trait in character.background.personality_traits:
148
+ md.append(f"- {trait}")
149
+ md.append("")
150
+
151
+ if character.background.ideals:
152
+ md.append(f"**Ideals**: {character.background.ideals}")
153
+ md.append("")
154
+
155
+ if character.background.bonds:
156
+ md.append(f"**Bonds**: {character.background.bonds}")
157
+ md.append("")
158
+
159
+ if character.background.flaws:
160
+ md.append(f"**Flaws**: {character.background.flaws}")
161
+ md.append("")
162
+
163
+ if character.background.backstory:
164
+ md.append("**Backstory**:")
165
+ md.append("")
166
+ md.append(character.background.backstory)
167
+ md.append("")
168
+
169
+ # Class Features
170
+ md.append("## βš”οΈ Class Features")
171
+ md.append("")
172
+ if character.features:
173
+ for feature in character.features:
174
+ md.append(f"- **{feature}**")
175
+ else:
176
+ md.append("*No class features listed*")
177
+ md.append("")
178
+
179
+ # Equipment
180
+ md.append("## πŸŽ’ Equipment")
181
+ md.append("")
182
+ if character.equipment:
183
+ for item in character.equipment:
184
+ md.append(f"- {item}")
185
+ else:
186
+ md.append("*No equipment listed*")
187
+ md.append("")
188
+
189
+ # Spells (if applicable)
190
+ if character.spells:
191
+ md.append("## ✨ Spells")
192
+ md.append("")
193
+ for spell in character.spells:
194
+ md.append(f"- {spell}")
195
+ md.append("")
196
+
197
+ # Notes
198
+ if character.notes:
199
+ md.append("## πŸ“ Notes")
200
+ md.append("")
201
+ md.append(character.notes)
202
+ md.append("")
203
+
204
+ # Footer
205
+ md.append("---")
206
+ md.append(f"*Character ID: {character.id}*")
207
+ md.append(f"*Created: {character.created_at.strftime('%Y-%m-%d %H:%M')}*")
208
+ md.append(f"*Last Updated: {character.updated_at.strftime('%Y-%m-%d %H:%M')}*")
209
+
210
+ return "\n".join(md)
211
+
212
+ def export_to_json(self, character: Character) -> str:
213
+ """
214
+ Export character to JSON format
215
+
216
+ Args:
217
+ character: Character to export
218
+
219
+ Returns:
220
+ JSON string
221
+ """
222
+ import json
223
+
224
+ char_dict = character.model_dump()
225
+
226
+ # Convert enums to strings for JSON serialization
227
+ char_dict['race'] = character.race.value
228
+ char_dict['character_class'] = character.character_class.value
229
+ char_dict['alignment'] = character.alignment.value
230
+
231
+ # Convert datetime objects
232
+ char_dict['created_at'] = character.created_at.isoformat()
233
+ char_dict['updated_at'] = character.updated_at.isoformat()
234
+
235
+ return json.dumps(char_dict, indent=2)
236
+
237
+ def export_to_html(self, character: Character) -> str:
238
+ """
239
+ Export character to styled HTML format (suitable for PDF conversion)
240
+
241
+ Args:
242
+ character: Character to export
243
+
244
+ Returns:
245
+ HTML string with embedded CSS
246
+ """
247
+ html = f"""<!DOCTYPE html>
248
+ <html>
249
+ <head>
250
+ <meta charset="UTF-8">
251
+ <title>{character.name} - D&D 5e Character Sheet</title>
252
+ <style>
253
+ @page {{
254
+ size: letter;
255
+ margin: 0.5in;
256
+ }}
257
+
258
+ body {{
259
+ font-family: 'Bookman Old Style', 'Book Antiqua', serif;
260
+ max-width: 8.5in;
261
+ margin: 0 auto;
262
+ padding: 20px;
263
+ background: #f9f6f1;
264
+ color: #1a1a1a;
265
+ }}
266
+
267
+ .header {{
268
+ text-align: center;
269
+ border-bottom: 3px solid #8b0000;
270
+ padding-bottom: 10px;
271
+ margin-bottom: 20px;
272
+ }}
273
+
274
+ h1 {{
275
+ font-size: 32px;
276
+ margin: 0;
277
+ color: #8b0000;
278
+ text-transform: uppercase;
279
+ letter-spacing: 2px;
280
+ }}
281
+
282
+ .subtitle {{
283
+ font-size: 18px;
284
+ font-style: italic;
285
+ color: #444;
286
+ margin: 5px 0;
287
+ }}
288
+
289
+ .section {{
290
+ background: white;
291
+ border: 2px solid #8b0000;
292
+ border-radius: 8px;
293
+ padding: 15px;
294
+ margin: 15px 0;
295
+ box-shadow: 2px 2px 5px rgba(0,0,0,0.1);
296
+ }}
297
+
298
+ .section-title {{
299
+ font-size: 20px;
300
+ font-weight: bold;
301
+ color: #8b0000;
302
+ border-bottom: 2px solid #8b0000;
303
+ padding-bottom: 5px;
304
+ margin-bottom: 10px;
305
+ text-transform: uppercase;
306
+ }}
307
+
308
+ .stats-grid {{
309
+ display: grid;
310
+ grid-template-columns: repeat(3, 1fr);
311
+ gap: 10px;
312
+ margin: 10px 0;
313
+ }}
314
+
315
+ .stat-box {{
316
+ text-align: center;
317
+ background: #f0e6d6;
318
+ border: 2px solid #8b0000;
319
+ border-radius: 5px;
320
+ padding: 10px;
321
+ }}
322
+
323
+ .stat-label {{
324
+ font-weight: bold;
325
+ font-size: 12px;
326
+ color: #8b0000;
327
+ text-transform: uppercase;
328
+ }}
329
+
330
+ .stat-value {{
331
+ font-size: 24px;
332
+ font-weight: bold;
333
+ color: #1a1a1a;
334
+ }}
335
+
336
+ .abilities-grid {{
337
+ display: grid;
338
+ grid-template-columns: repeat(6, 1fr);
339
+ gap: 10px;
340
+ margin: 10px 0;
341
+ }}
342
+
343
+ .ability-box {{
344
+ text-align: center;
345
+ background: #f0e6d6;
346
+ border: 2px solid #8b0000;
347
+ border-radius: 50%;
348
+ padding: 15px 5px;
349
+ aspect-ratio: 1;
350
+ display: flex;
351
+ flex-direction: column;
352
+ justify-content: center;
353
+ align-items: center;
354
+ }}
355
+
356
+ .ability-name {{
357
+ font-weight: bold;
358
+ font-size: 10px;
359
+ color: #8b0000;
360
+ }}
361
+
362
+ .ability-score {{
363
+ font-size: 20px;
364
+ font-weight: bold;
365
+ }}
366
+
367
+ .ability-modifier {{
368
+ font-size: 14px;
369
+ color: #666;
370
+ }}
371
+
372
+ .list-item {{
373
+ padding: 5px 0;
374
+ border-bottom: 1px dotted #ccc;
375
+ }}
376
+
377
+ .list-item:last-child {{
378
+ border-bottom: none;
379
+ }}
380
+
381
+ .portrait {{
382
+ max-width: 200px;
383
+ max-height: 200px;
384
+ border: 3px solid #8b0000;
385
+ border-radius: 10px;
386
+ margin: 10px auto;
387
+ display: block;
388
+ }}
389
+
390
+ @media print {{
391
+ body {{
392
+ background: white;
393
+ }}
394
+ .section {{
395
+ page-break-inside: avoid;
396
+ }}
397
+ }}
398
+ </style>
399
+ </head>
400
+ <body>
401
+ <div class="header">
402
+ <h1>{character.name}</h1>
403
+ <div class="subtitle">Level {character.level} {character.race.value} {character.character_class.value}</div>
404
+ <div class="subtitle">{character.alignment.value}</div>
405
+ {f'<div class="subtitle">{character.gender}</div>' if character.gender else ''}
406
+ </div>
407
+ """
408
+
409
+ # Portrait
410
+ if character.portrait_url:
411
+ html += f' <img src="{character.portrait_url}" class="portrait" alt="Character Portrait">\n\n'
412
+
413
+ # Core Stats
414
+ html += ' <div class="section">\n'
415
+ html += ' <div class="section-title">Core Statistics</div>\n'
416
+ html += ' <div class="stats-grid">\n'
417
+ html += f' <div class="stat-box"><div class="stat-label">Armor Class</div><div class="stat-value">{character.armor_class}</div></div>\n'
418
+ html += f' <div class="stat-box"><div class="stat-label">Hit Points</div><div class="stat-value">{character.current_hit_points}/{character.max_hit_points}</div></div>\n'
419
+ html += f' <div class="stat-box"><div class="stat-label">Hit Dice</div><div class="stat-value">{character.level}d{HIT_DICE_BY_CLASS.get(character.character_class, 8)}</div></div>\n'
420
+ html += f' <div class="stat-box"><div class="stat-label">Prof. Bonus</div><div class="stat-value">+{character.proficiency_bonus}</div></div>\n'
421
+ html += f' <div class="stat-box"><div class="stat-label">Speed</div><div class="stat-value">30 ft</div></div>\n'
422
+ html += f' <div class="stat-box"><div class="stat-label">Initiative</div><div class="stat-value">{character.stats.dexterity_modifier:+d}</div></div>\n'
423
+ html += ' </div>\n'
424
+ html += ' </div>\n\n'
425
+
426
+ # Ability Scores
427
+ html += ' <div class="section">\n'
428
+ html += ' <div class="section-title">Ability Scores</div>\n'
429
+ html += ' <div class="abilities-grid">\n'
430
+
431
+ abilities = [
432
+ ("STR", character.stats.strength, character.stats.strength_modifier),
433
+ ("DEX", character.stats.dexterity, character.stats.dexterity_modifier),
434
+ ("CON", character.stats.constitution, character.stats.constitution_modifier),
435
+ ("INT", character.stats.intelligence, character.stats.intelligence_modifier),
436
+ ("WIS", character.stats.wisdom, character.stats.wisdom_modifier),
437
+ ("CHA", character.stats.charisma, character.stats.charisma_modifier),
438
+ ]
439
+
440
+ for name, score, mod in abilities:
441
+ html += f' <div class="ability-box">\n'
442
+ html += f' <div class="ability-name">{name}</div>\n'
443
+ html += f' <div class="ability-score">{score}</div>\n'
444
+ html += f' <div class="ability-modifier">({mod:+d})</div>\n'
445
+ html += f' </div>\n'
446
+
447
+ html += ' </div>\n'
448
+ html += ' </div>\n\n'
449
+
450
+ # Skills & Proficiencies
451
+ if character.proficiencies:
452
+ html += ' <div class="section">\n'
453
+ html += ' <div class="section-title">Skills & Proficiencies</div>\n'
454
+ for prof in character.proficiencies:
455
+ html += f' <div class="list-item">βœ“ {prof}</div>\n'
456
+ html += ' </div>\n\n'
457
+
458
+ # Features
459
+ if character.features:
460
+ html += ' <div class="section">\n'
461
+ html += ' <div class="section-title">Class Features</div>\n'
462
+ for feature in character.features:
463
+ html += f' <div class="list-item"><strong>{feature}</strong></div>\n'
464
+ html += ' </div>\n\n'
465
+
466
+ # Equipment
467
+ if character.equipment:
468
+ html += ' <div class="section">\n'
469
+ html += ' <div class="section-title">Equipment</div>\n'
470
+ for item in character.equipment:
471
+ html += f' <div class="list-item">{item}</div>\n'
472
+ html += ' </div>\n\n'
473
+
474
+ # Background
475
+ html += ' <div class="section">\n'
476
+ html += ' <div class="section-title">Background & Personality</div>\n'
477
+ html += f' <p><strong>Background:</strong> {character.background.background_type}</p>\n'
478
+
479
+ if character.background.personality_traits:
480
+ html += ' <p><strong>Personality Traits:</strong></p><ul>\n'
481
+ for trait in character.background.personality_traits:
482
+ html += f' <li>{trait}</li>\n'
483
+ html += ' </ul>\n'
484
+
485
+ if character.background.ideals:
486
+ html += f' <p><strong>Ideals:</strong> {character.background.ideals}</p>\n'
487
+
488
+ if character.background.bonds:
489
+ html += f' <p><strong>Bonds:</strong> {character.background.bonds}</p>\n'
490
+
491
+ if character.background.flaws:
492
+ html += f' <p><strong>Flaws:</strong> {character.background.flaws}</p>\n'
493
+
494
+ if character.background.backstory:
495
+ html += f' <p><strong>Backstory:</strong></p>\n'
496
+ html += f' <p>{character.background.backstory}</p>\n'
497
+
498
+ html += ' </div>\n\n'
499
+
500
+ # Spells
501
+ if character.spells:
502
+ html += ' <div class="section">\n'
503
+ html += ' <div class="section-title">Spells</div>\n'
504
+ for spell in character.spells:
505
+ html += f' <div class="list-item">{spell}</div>\n'
506
+ html += ' </div>\n\n'
507
+
508
+ # Footer
509
+ html += ' <div style="text-align: center; margin-top: 30px; font-size: 12px; color: #666;">\n'
510
+ html += f' <p>Character ID: {character.id}</p>\n'
511
+ html += f' <p>Created: {character.created_at.strftime("%Y-%m-%d %H:%M")} | '
512
+ html += f'Last Updated: {character.updated_at.strftime("%Y-%m-%d %H:%M")}</p>\n'
513
+ html += ' <p><em>Generated by D\'n\'D Campaign Manager</em></p>\n'
514
+ html += ' </div>\n'
515
+
516
+ html += '</body>\n</html>'
517
+
518
+ return html
519
+
520
+ def save_export(self, character: Character, format: str = "markdown") -> str:
521
+ """
522
+ Save character sheet to file
523
+
524
+ Args:
525
+ character: Character to export
526
+ format: Export format ('markdown', 'json', 'html')
527
+
528
+ Returns:
529
+ Path to saved file
530
+ """
531
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
532
+ safe_name = "".join(c if c.isalnum() or c in (' ', '_') else '_' for c in character.name)
533
+ safe_name = safe_name.replace(' ', '_')
534
+
535
+ if format == "markdown":
536
+ content = self.export_to_markdown(character)
537
+ filename = f"{safe_name}_{timestamp}.md"
538
+ extension = ".md"
539
+ elif format == "json":
540
+ content = self.export_to_json(character)
541
+ filename = f"{safe_name}_{timestamp}.json"
542
+ extension = ".json"
543
+ elif format == "html":
544
+ content = self.export_to_html(character)
545
+ filename = f"{safe_name}_{timestamp}.html"
546
+ extension = ".html"
547
+ else:
548
+ raise ValueError(f"Unknown format: {format}")
549
+
550
+ file_path = self.output_dir / filename
551
+
552
+ with open(file_path, 'w', encoding='utf-8') as f:
553
+ f.write(content)
554
+
555
+ return str(file_path)
556
+
557
+
558
+ # Global instance
559
+ _exporter: Optional[CharacterSheetExporter] = None
560
+
561
+
562
+ def get_exporter() -> CharacterSheetExporter:
563
+ """Get or create global exporter instance"""
564
+ global _exporter
565
+ if _exporter is None:
566
+ _exporter = CharacterSheetExporter()
567
+ return _exporter
src/utils/database.py ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Database utilities for SQLite persistence
3
+ Simple key-value storage with JSON serialization
4
+ """
5
+
6
+ import json
7
+ import sqlite3
8
+ from pathlib import Path
9
+ from typing import Optional, Any, List, Dict
10
+ from datetime import datetime
11
+
12
+ from src.config import config
13
+
14
+
15
+ class Database:
16
+ """Simple SQLite database for campaign data"""
17
+
18
+ def __init__(self, db_path: Optional[Path] = None):
19
+ """Initialize database connection"""
20
+ self.db_path = db_path or config.database.db_path
21
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
22
+ self.conn: Optional[sqlite3.Connection] = None
23
+ self._initialize()
24
+
25
+ def _initialize(self):
26
+ """Initialize database and create tables"""
27
+ self.conn = sqlite3.connect(str(self.db_path), check_same_thread=False)
28
+ self.conn.row_factory = sqlite3.Row
29
+
30
+ # Create tables
31
+ self._create_tables()
32
+
33
+ def _create_tables(self):
34
+ """Create database tables"""
35
+ cursor = self.conn.cursor()
36
+
37
+ # Generic key-value store with type
38
+ cursor.execute("""
39
+ CREATE TABLE IF NOT EXISTS entities (
40
+ id TEXT PRIMARY KEY,
41
+ type TEXT NOT NULL,
42
+ data TEXT NOT NULL,
43
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
44
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
45
+ )
46
+ """)
47
+
48
+ # Index for faster lookups
49
+ cursor.execute("""
50
+ CREATE INDEX IF NOT EXISTS idx_entities_type ON entities(type)
51
+ """)
52
+
53
+ # Campaign events for memory
54
+ cursor.execute("""
55
+ CREATE TABLE IF NOT EXISTS campaign_events (
56
+ id TEXT PRIMARY KEY,
57
+ campaign_id TEXT NOT NULL,
58
+ session_number INTEGER NOT NULL,
59
+ event_type TEXT NOT NULL,
60
+ data TEXT NOT NULL,
61
+ importance INTEGER DEFAULT 3,
62
+ timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
63
+ FOREIGN KEY (campaign_id) REFERENCES entities(id)
64
+ )
65
+ """)
66
+
67
+ cursor.execute("""
68
+ CREATE INDEX IF NOT EXISTS idx_events_campaign ON campaign_events(campaign_id)
69
+ """)
70
+
71
+ self.conn.commit()
72
+
73
+ def save(self, entity_id: str, entity_type: str, data: Dict[str, Any]):
74
+ """Save entity to database"""
75
+ cursor = self.conn.cursor()
76
+
77
+ # Serialize data to JSON
78
+ json_data = json.dumps(data, default=str)
79
+
80
+ cursor.execute("""
81
+ INSERT OR REPLACE INTO entities (id, type, data, updated_at)
82
+ VALUES (?, ?, ?, CURRENT_TIMESTAMP)
83
+ """, (entity_id, entity_type, json_data))
84
+
85
+ self.conn.commit()
86
+
87
+ def load(self, entity_id: str, entity_type: Optional[str] = None) -> Optional[Dict[str, Any]]:
88
+ """Load entity from database"""
89
+ cursor = self.conn.cursor()
90
+
91
+ if entity_type:
92
+ cursor.execute("""
93
+ SELECT data FROM entities WHERE id = ? AND type = ?
94
+ """, (entity_id, entity_type))
95
+ else:
96
+ cursor.execute("""
97
+ SELECT data FROM entities WHERE id = ?
98
+ """, (entity_id,))
99
+
100
+ row = cursor.fetchone()
101
+ if row:
102
+ return json.loads(row['data'])
103
+ return None
104
+
105
+ def load_all(self, entity_type: str) -> List[Dict[str, Any]]:
106
+ """Load all entities of a specific type"""
107
+ cursor = self.conn.cursor()
108
+
109
+ cursor.execute("""
110
+ SELECT data FROM entities WHERE type = ?
111
+ ORDER BY updated_at DESC
112
+ """, (entity_type,))
113
+
114
+ return [json.loads(row['data']) for row in cursor.fetchall()]
115
+
116
+ def delete(self, entity_id: str):
117
+ """Delete entity from database"""
118
+ cursor = self.conn.cursor()
119
+
120
+ cursor.execute("""
121
+ DELETE FROM entities WHERE id = ?
122
+ """, (entity_id,))
123
+
124
+ self.conn.commit()
125
+
126
+ def search(self, entity_type: str, query: str, limit: int = 10) -> List[Dict[str, Any]]:
127
+ """Simple text search in entity data"""
128
+ cursor = self.conn.cursor()
129
+
130
+ cursor.execute("""
131
+ SELECT data FROM entities
132
+ WHERE type = ? AND data LIKE ?
133
+ ORDER BY updated_at DESC
134
+ LIMIT ?
135
+ """, (entity_type, f"%{query}%", limit))
136
+
137
+ return [json.loads(row['data']) for row in cursor.fetchall()]
138
+
139
+ # Campaign Events specific methods
140
+ def save_campaign_event(self, event_data: Dict[str, Any]):
141
+ """Save campaign event for memory"""
142
+ cursor = self.conn.cursor()
143
+
144
+ cursor.execute("""
145
+ INSERT INTO campaign_events
146
+ (id, campaign_id, session_number, event_type, data, importance)
147
+ VALUES (?, ?, ?, ?, ?, ?)
148
+ """, (
149
+ event_data['id'],
150
+ event_data['campaign_id'],
151
+ event_data['session_number'],
152
+ event_data['event_type'],
153
+ json.dumps(event_data, default=str),
154
+ event_data.get('importance', 3)
155
+ ))
156
+
157
+ self.conn.commit()
158
+
159
+ def load_campaign_events(
160
+ self,
161
+ campaign_id: str,
162
+ session_number: Optional[int] = None,
163
+ limit: Optional[int] = None
164
+ ) -> List[Dict[str, Any]]:
165
+ """Load campaign events for memory context"""
166
+ cursor = self.conn.cursor()
167
+
168
+ query = """
169
+ SELECT data FROM campaign_events
170
+ WHERE campaign_id = ?
171
+ """
172
+ params = [campaign_id]
173
+
174
+ if session_number:
175
+ query += " AND session_number = ?"
176
+ params.append(session_number)
177
+
178
+ query += " ORDER BY timestamp DESC"
179
+
180
+ if limit:
181
+ query += " LIMIT ?"
182
+ params.append(limit)
183
+
184
+ cursor.execute(query, params)
185
+
186
+ return [json.loads(row['data']) for row in cursor.fetchall()]
187
+
188
+ def get_campaign_context(self, campaign_id: str, max_events: int = 50) -> str:
189
+ """Get formatted campaign context for AI"""
190
+ events = self.load_campaign_events(campaign_id, limit=max_events)
191
+
192
+ if not events:
193
+ return "No campaign history yet."
194
+
195
+ context_parts = ["# Campaign History\n"]
196
+
197
+ for event in reversed(events): # Chronological order
198
+ context_parts.append(f"## Session {event['session_number']}: {event['title']}")
199
+ context_parts.append(f"**Type:** {event['event_type']}")
200
+ context_parts.append(f"{event['description']}\n")
201
+
202
+ return "\n".join(context_parts)
203
+
204
+ def close(self):
205
+ """Close database connection"""
206
+ if self.conn:
207
+ self.conn.close()
208
+
209
+ def __enter__(self):
210
+ """Context manager entry"""
211
+ return self
212
+
213
+ def __exit__(self, exc_type, exc_val, exc_tb):
214
+ """Context manager exit"""
215
+ self.close()
216
+
217
+
218
+ # Global database instance
219
+ _database: Optional[Database] = None
220
+
221
+
222
+ def get_database() -> Database:
223
+ """Get or create global database instance"""
224
+ global _database
225
+ if _database is None:
226
+ _database = Database()
227
+ return _database
src/utils/dice.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Dice rolling utilities for D&D
3
+ """
4
+
5
+ import random
6
+ import re
7
+ from typing import List, Tuple, Optional
8
+
9
+
10
+ class DiceRoller:
11
+ """D&D dice roller with standard notation support"""
12
+
13
+ @staticmethod
14
+ def roll(notation: str) -> Tuple[int, List[int], str]:
15
+ """
16
+ Roll dice using standard notation (e.g., "2d6+3", "1d20", "4d6kh3")
17
+
18
+ Returns:
19
+ Tuple of (total, individual_rolls, explanation)
20
+ """
21
+ notation = notation.lower().strip()
22
+
23
+ # Parse notation: XdY+Z or XdY-Z or XdYkhN (keep highest N)
24
+ match = re.match(r'(\d+)?d(\d+)(?:kh(\d+))?([+-]\d+)?', notation)
25
+
26
+ if not match:
27
+ raise ValueError(f"Invalid dice notation: {notation}")
28
+
29
+ num_dice = int(match.group(1)) if match.group(1) else 1
30
+ die_size = int(match.group(2))
31
+ keep_highest = int(match.group(3)) if match.group(3) else None
32
+ modifier = int(match.group(4)) if match.group(4) else 0
33
+
34
+ # Validate
35
+ if num_dice < 1 or num_dice > 100:
36
+ raise ValueError("Number of dice must be between 1 and 100")
37
+ if die_size < 1 or die_size > 1000:
38
+ raise ValueError("Die size must be between 1 and 1000")
39
+
40
+ # Roll dice
41
+ rolls = [random.randint(1, die_size) for _ in range(num_dice)]
42
+
43
+ # Apply keep highest
44
+ if keep_highest:
45
+ if keep_highest >= num_dice:
46
+ kept_rolls = rolls
47
+ else:
48
+ sorted_rolls = sorted(rolls, reverse=True)
49
+ kept_rolls = sorted_rolls[:keep_highest]
50
+ dropped_rolls = sorted_rolls[keep_highest:]
51
+ explanation = f"Rolled {rolls}, kept {kept_rolls}, dropped {dropped_rolls}"
52
+ else:
53
+ kept_rolls = rolls
54
+ explanation = f"Rolled {rolls}"
55
+
56
+ total = sum(kept_rolls) + modifier
57
+
58
+ if modifier != 0:
59
+ explanation += f" {'+' if modifier > 0 else ''}{modifier} = {total}"
60
+ else:
61
+ explanation += f" = {total}"
62
+
63
+ return total, rolls, explanation
64
+
65
+ @staticmethod
66
+ def roll_stat() -> int:
67
+ """Roll a D&D ability score (4d6 keep highest 3)"""
68
+ total, _, _ = DiceRoller.roll("4d6kh3")
69
+ return total
70
+
71
+ @staticmethod
72
+ def roll_stats() -> dict:
73
+ """Roll a complete set of D&D ability scores"""
74
+ return {
75
+ "strength": DiceRoller.roll_stat(),
76
+ "dexterity": DiceRoller.roll_stat(),
77
+ "constitution": DiceRoller.roll_stat(),
78
+ "intelligence": DiceRoller.roll_stat(),
79
+ "wisdom": DiceRoller.roll_stat(),
80
+ "charisma": DiceRoller.roll_stat(),
81
+ }
82
+
83
+ @staticmethod
84
+ def advantage(notation: str = "1d20") -> Tuple[int, List[int], str]:
85
+ """Roll with advantage (roll twice, take higher)"""
86
+ result1, rolls1, _ = DiceRoller.roll(notation)
87
+ result2, rolls2, _ = DiceRoller.roll(notation)
88
+
89
+ if result1 >= result2:
90
+ return result1, rolls1, f"Advantage: rolled {rolls1} and {rolls2}, kept {result1}"
91
+ else:
92
+ return result2, rolls2, f"Advantage: rolled {rolls1} and {rolls2}, kept {result2}"
93
+
94
+ @staticmethod
95
+ def disadvantage(notation: str = "1d20") -> Tuple[int, List[int], str]:
96
+ """Roll with disadvantage (roll twice, take lower)"""
97
+ result1, rolls1, _ = DiceRoller.roll(notation)
98
+ result2, rolls2, _ = DiceRoller.roll(notation)
99
+
100
+ if result1 <= result2:
101
+ return result1, rolls1, f"Disadvantage: rolled {rolls1} and {rolls2}, kept {result1}"
102
+ else:
103
+ return result2, rolls2, f"Disadvantage: rolled {rolls1} and {rolls2}, kept {result2}"
104
+
105
+ @staticmethod
106
+ def roll_initiative(dex_modifier: int = 0) -> Tuple[int, str]:
107
+ """Roll initiative with dexterity modifier"""
108
+ result, rolls, _ = DiceRoller.roll("1d20")
109
+ total = result + dex_modifier
110
+ return total, f"Initiative: {rolls[0]} + {dex_modifier} = {total}"
111
+
112
+ @staticmethod
113
+ def roll_hit_points(hit_die: int, constitution_modifier: int, level: int) -> int:
114
+ """
115
+ Roll hit points for a character
116
+ First level: max hit die + con mod
117
+ Subsequent levels: roll hit die + con mod
118
+ """
119
+ if level < 1:
120
+ raise ValueError("Level must be at least 1")
121
+
122
+ # First level gets max
123
+ hp = hit_die + constitution_modifier
124
+
125
+ # Roll for subsequent levels
126
+ for _ in range(level - 1):
127
+ roll, _, _ = DiceRoller.roll(f"1d{hit_die}")
128
+ hp += roll + constitution_modifier
129
+
130
+ return max(1, hp) # Minimum 1 HP
131
+
132
+
133
+ # Convenience functions
134
+ def roll(notation: str) -> Tuple[int, List[int], str]:
135
+ """Roll dice using standard notation"""
136
+ return DiceRoller.roll(notation)
137
+
138
+
139
+ def roll_stats() -> dict:
140
+ """Roll complete set of ability scores"""
141
+ return DiceRoller.roll_stats()
142
+
143
+
144
+ def roll_with_advantage(notation: str = "1d20") -> Tuple[int, List[int], str]:
145
+ """Roll with advantage"""
146
+ return DiceRoller.advantage(notation)
147
+
148
+
149
+ def roll_with_disadvantage(notation: str = "1d20") -> Tuple[int, List[int], str]:
150
+ """Roll with disadvantage"""
151
+ return DiceRoller.disadvantage(notation)
src/utils/file_parsers.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ File Parsing Utilities for Session Notes Upload
3
+ Supports: .txt, .md, .docx, .pdf
4
+ """
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+
9
+ def parse_uploaded_file(file_path: str) -> str:
10
+ """
11
+ Parse uploaded session notes file and extract text content.
12
+
13
+ Supported formats:
14
+ - .txt - Plain text
15
+ - .md - Markdown
16
+ - .docx - Microsoft Word (requires python-docx)
17
+ - .pdf - PDF documents (requires PyPDF2)
18
+
19
+ Args:
20
+ file_path: Path to uploaded file
21
+
22
+ Returns:
23
+ Extracted text content as string
24
+
25
+ Raises:
26
+ ValueError: If file type is not supported
27
+ Exception: If file parsing fails
28
+ """
29
+ path = Path(file_path)
30
+ extension = path.suffix.lower()
31
+
32
+ # Plain text and markdown (simple read)
33
+ if extension in ['.txt', '.md']:
34
+ try:
35
+ with open(file_path, 'r', encoding='utf-8') as f:
36
+ return f.read()
37
+ except UnicodeDecodeError:
38
+ # Try with different encoding if UTF-8 fails
39
+ with open(file_path, 'r', encoding='latin-1') as f:
40
+ return f.read()
41
+
42
+ # Microsoft Word documents
43
+ elif extension == '.docx':
44
+ try:
45
+ import docx
46
+ doc = docx.Document(file_path)
47
+ paragraphs = [para.text for para in doc.paragraphs]
48
+ return '\n'.join(paragraphs)
49
+ except ImportError:
50
+ raise ImportError(
51
+ "python-docx is required to parse .docx files. "
52
+ "Install with: pip install python-docx"
53
+ )
54
+ except Exception as e:
55
+ raise Exception(f"Error parsing .docx file: {str(e)}")
56
+
57
+ # PDF documents
58
+ elif extension == '.pdf':
59
+ try:
60
+ import PyPDF2
61
+ text_content = []
62
+
63
+ with open(file_path, 'rb') as f:
64
+ pdf_reader = PyPDF2.PdfReader(f)
65
+
66
+ for page in pdf_reader.pages:
67
+ text = page.extract_text()
68
+ if text:
69
+ text_content.append(text)
70
+
71
+ return '\n'.join(text_content)
72
+
73
+ except ImportError:
74
+ raise ImportError(
75
+ "PyPDF2 is required to parse .pdf files. "
76
+ "Install with: pip install PyPDF2"
77
+ )
78
+ except Exception as e:
79
+ raise Exception(f"Error parsing .pdf file: {str(e)}")
80
+
81
+ else:
82
+ raise ValueError(
83
+ f"Unsupported file type: {extension}. "
84
+ f"Supported formats: .txt, .md, .docx, .pdf"
85
+ )
86
+
87
+
88
+ def validate_file_size(file_path: str, max_size_mb: int = 10) -> bool:
89
+ """
90
+ Validate that file size is within acceptable limits.
91
+
92
+ Args:
93
+ file_path: Path to file
94
+ max_size_mb: Maximum file size in megabytes (default: 10 MB)
95
+
96
+ Returns:
97
+ True if file size is acceptable, False otherwise
98
+ """
99
+ path = Path(file_path)
100
+
101
+ if not path.exists():
102
+ return False
103
+
104
+ file_size_mb = path.stat().st_size / (1024 * 1024)
105
+ return file_size_mb <= max_size_mb
106
+
107
+
108
+ def get_file_info(file_path: str) -> dict:
109
+ """
110
+ Get information about uploaded file.
111
+
112
+ Args:
113
+ file_path: Path to file
114
+
115
+ Returns:
116
+ Dictionary with file information
117
+ """
118
+ path = Path(file_path)
119
+
120
+ return {
121
+ 'name': path.name,
122
+ 'extension': path.suffix.lower(),
123
+ 'size_bytes': path.stat().st_size,
124
+ 'size_mb': round(path.stat().st_size / (1024 * 1024), 2)
125
+ }
src/utils/image_generator.py ADDED
@@ -0,0 +1,590 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Image Generation Utility - DALL-E 3 Integration for Character Portraits
3
+ """
4
+
5
+ import os
6
+ import base64
7
+ from typing import Optional, Tuple, Literal
8
+ from pathlib import Path
9
+ import requests
10
+ from io import BytesIO
11
+ import openai
12
+ from openai import OpenAI
13
+
14
+ from src.config import config
15
+ from src.models.character import Character, DnDRace, DnDClass
16
+
17
+ ImageProvider = Literal["openai", "huggingface", "auto"]
18
+
19
+ # Skin tone options by race (3-5 options each)
20
+ RACE_SKIN_TONES = {
21
+ DnDRace.HUMAN: [
22
+ "Fair/Pale",
23
+ "Light",
24
+ "Medium/Olive",
25
+ "Tan/Brown",
26
+ "Deep/Dark"
27
+ ],
28
+ DnDRace.ELF: [
29
+ "Pale/Moonlight",
30
+ "Porcelain",
31
+ "Honey/Golden",
32
+ "Bronze",
33
+ "Dark Copper"
34
+ ],
35
+ DnDRace.DWARF: [
36
+ "Ruddy/Tan",
37
+ "Bronze",
38
+ "Deep Brown",
39
+ "Stone Gray"
40
+ ],
41
+ DnDRace.HALFLING: [
42
+ "Fair/Rosy",
43
+ "Light Tan",
44
+ "Medium Brown",
45
+ "Dark Brown"
46
+ ],
47
+ DnDRace.DRAGONBORN: [
48
+ "Brass (Golden)",
49
+ "Copper (Reddish)",
50
+ "Bronze (Brown)",
51
+ "Silver (Silvery)",
52
+ "Red (Crimson)",
53
+ "Blue (Sapphire)",
54
+ "Green (Emerald)",
55
+ "Black (Obsidian)",
56
+ "White (Pearl)"
57
+ ],
58
+ DnDRace.GNOME: [
59
+ "Fair/Pink",
60
+ "Tan/Sandy",
61
+ "Light Brown",
62
+ "Deep Brown"
63
+ ],
64
+ DnDRace.HALF_ELF: [
65
+ "Fair",
66
+ "Light Olive",
67
+ "Medium Tan",
68
+ "Brown",
69
+ "Dark"
70
+ ],
71
+ DnDRace.HALF_ORC: [
72
+ "Gray-Green",
73
+ "Olive-Green",
74
+ "Deep Green",
75
+ "Gray-Brown"
76
+ ],
77
+ DnDRace.TIEFLING: [
78
+ "Red",
79
+ "Purple",
80
+ "Blue-Gray",
81
+ "Dark Gray",
82
+ "Pink-Red"
83
+ ],
84
+ DnDRace.DROW: [
85
+ "Dark Gray",
86
+ "Purple-Black",
87
+ "Blue-Black",
88
+ "Obsidian Black"
89
+ ]
90
+ }
91
+
92
+
93
+ class ImageGenerator:
94
+ """Generate character portraits using DALL-E 3 or HuggingFace Inference"""
95
+
96
+ def __init__(self):
97
+ """Initialize image generator with available providers"""
98
+ self.openai_api_key = config.openai_api_key
99
+ self.hf_api_key = config.huggingface_api_key
100
+
101
+ self.output_dir = Path("data/portraits")
102
+ self.output_dir.mkdir(parents=True, exist_ok=True)
103
+
104
+ # HuggingFace model for fallback (Stable Diffusion XL)
105
+ self.hf_model = "stabilityai/stable-diffusion-xl-base-1.0"
106
+
107
+ # Check available providers
108
+ self.has_openai = bool(self.openai_api_key)
109
+ self.has_hf = bool(self.hf_api_key)
110
+
111
+ if not self.has_openai and not self.has_hf:
112
+ raise ValueError(
113
+ "No image generation API keys found. "
114
+ "Please add OPENAI_API_KEY or HUGGINGFACE_API_KEY to your .env file"
115
+ )
116
+
117
+ def generate_character_portrait(
118
+ self,
119
+ character: Character,
120
+ style: str = "fantasy art",
121
+ quality: str = "standard",
122
+ size: str = "1024x1024",
123
+ provider: ImageProvider = "auto"
124
+ ) -> Tuple[Optional[str], Optional[bytes]]:
125
+ """
126
+ Generate a character portrait using DALL-E 3 or HuggingFace
127
+
128
+ Args:
129
+ character: Character object with full details
130
+ style: Art style (e.g., "fantasy art", "digital painting", "anime")
131
+ quality: Image quality ("standard" or "hd") - OpenAI only
132
+ size: Image size ("1024x1024", "1024x1792", "1792x1024")
133
+ provider: "openai", "huggingface", or "auto" (tries OpenAI first, falls back to HF)
134
+
135
+ Returns:
136
+ Tuple of (file_path, image_bytes)
137
+ """
138
+ # Build detailed prompt from character
139
+ prompt = self._build_character_prompt(character, style)
140
+
141
+ # Determine which provider to use
142
+ if provider == "auto":
143
+ # Try OpenAI first, fallback to HuggingFace
144
+ if self.has_openai:
145
+ try:
146
+ image_bytes = self._generate_image_openai(prompt, quality, size)
147
+ except Exception as openai_error:
148
+ print(f"OpenAI failed: {openai_error}")
149
+ if self.has_hf:
150
+ print("Falling back to HuggingFace Inference...")
151
+ image_bytes = self._generate_image_huggingface(prompt)
152
+ else:
153
+ raise # Re-raise OpenAI error if no fallback
154
+ elif self.has_hf:
155
+ image_bytes = self._generate_image_huggingface(prompt)
156
+ else:
157
+ raise ValueError("No image generation provider available")
158
+
159
+ elif provider == "openai":
160
+ if not self.has_openai:
161
+ raise ValueError("OpenAI API key not configured")
162
+ image_bytes = self._generate_image_openai(prompt, quality, size)
163
+
164
+ elif provider == "huggingface":
165
+ if not self.has_hf:
166
+ raise ValueError("HuggingFace API key not configured")
167
+ image_bytes = self._generate_image_huggingface(prompt)
168
+
169
+ else:
170
+ raise ValueError(f"Unknown provider: {provider}")
171
+
172
+ if image_bytes:
173
+ # Save to file
174
+ filename = f"{character.id}_portrait.png"
175
+ file_path = self.output_dir / filename
176
+
177
+ with open(file_path, 'wb') as f:
178
+ f.write(image_bytes)
179
+
180
+ return str(file_path), image_bytes
181
+
182
+ return None, None
183
+
184
+ def generate_custom_portrait(
185
+ self,
186
+ prompt: str,
187
+ character_id: Optional[str] = None,
188
+ quality: str = "standard",
189
+ size: str = "1024x1024"
190
+ ) -> Tuple[Optional[str], Optional[bytes]]:
191
+ """
192
+ Generate a portrait from custom prompt
193
+
194
+ Args:
195
+ prompt: Custom DALL-E prompt
196
+ character_id: Optional character ID for filename
197
+ quality: Image quality
198
+ size: Image size
199
+
200
+ Returns:
201
+ Tuple of (file_path, image_bytes)
202
+ """
203
+ try:
204
+ image_bytes = self._generate_image(prompt, quality, size)
205
+
206
+ if image_bytes:
207
+ # Save to file
208
+ if character_id:
209
+ filename = f"{character_id}_custom_portrait.png"
210
+ else:
211
+ import uuid
212
+ filename = f"portrait_{uuid.uuid4().hex[:8]}.png"
213
+
214
+ file_path = self.output_dir / filename
215
+
216
+ with open(file_path, 'wb') as f:
217
+ f.write(image_bytes)
218
+
219
+ return str(file_path), image_bytes
220
+
221
+ return None, None
222
+
223
+ except Exception as e:
224
+ print(f"Error generating custom image: {e}")
225
+ return None, None
226
+
227
+ def _build_character_prompt(self, character: Character, style: str) -> str:
228
+ """
229
+ Build detailed DALL-E prompt from character data
230
+
231
+ Includes race, class, gender, alignment, skin tone, physical description
232
+ """
233
+ # Base description
234
+ parts = []
235
+
236
+ # Rich style prefix
237
+ parts.append(f"Highly detailed {style} character portrait,")
238
+
239
+ # Determine gender for trait customization
240
+ character_gender = None
241
+ if hasattr(character, 'gender') and character.gender and character.gender != "Not specified":
242
+ character_gender = character.gender.lower()
243
+
244
+ # Build VERY strong gender emphasis
245
+ gender_prefix = ""
246
+ gender_features = ""
247
+
248
+ if character_gender == "female":
249
+ gender_prefix = "BEAUTIFUL FEMININE FEMALE WOMAN"
250
+ gender_features = "distinctly feminine facial features, soft feminine jawline, delicate bone structure, graceful feminine appearance"
251
+ elif character_gender == "male":
252
+ gender_prefix = "HANDSOME MASCULINE MALE MAN"
253
+ gender_features = "distinctly masculine facial features, strong masculine jawline, defined bone structure, rugged masculine appearance"
254
+ elif character_gender:
255
+ gender_prefix = character_gender.upper()
256
+ gender_features = "unique facial features"
257
+ else:
258
+ gender_prefix = "PERSON"
259
+ gender_features = "expressive facial features"
260
+
261
+ # Get skin tone with STRONG emphasis
262
+ skin_emphasis = ""
263
+ if hasattr(character, 'skin_tone') and character.skin_tone:
264
+ # Make skin tone VERY prominent
265
+ skin_emphasis = f"WITH {character.skin_tone.upper()} COLORED SKIN,"
266
+
267
+ # Build the core character description
268
+ parts.append(f"{gender_prefix} {character.race.value.upper()} {character.character_class.value.upper()} {skin_emphasis}")
269
+
270
+ # Add gender-specific features and race traits (passing gender for customization)
271
+ race_traits = self._get_race_traits(character.race, character_gender)
272
+ parts.append(f"{gender_features}, {race_traits},")
273
+
274
+ # Rich class-specific elements
275
+ class_elements = self._get_class_elements(character.character_class)
276
+ parts.append(f"{class_elements},")
277
+
278
+ # Alignment influence
279
+ alignment_mood = self._get_alignment_mood(character.alignment.value)
280
+ if alignment_mood:
281
+ parts.append(f"{alignment_mood},")
282
+
283
+ # Rich artistic details with gender reinforcement
284
+ parts.append("epic fantasy character portrait, professional illustration,")
285
+ if character_gender == "female":
286
+ parts.append("CLEARLY FEMALE CHARACTER, feminine beauty,")
287
+ elif character_gender == "male":
288
+ parts.append("CLEARLY MALE CHARACTER, masculine presence,")
289
+
290
+ parts.append("highly detailed face with intricate features, expressive eyes,")
291
+ parts.append("dramatic cinematic lighting with rim light, volumetric atmosphere,")
292
+ parts.append("sharp focus on facial features and expression,")
293
+ parts.append(f"masterpiece quality {style} artwork, trending on artstation, 8k resolution")
294
+
295
+ prompt = " ".join(parts)
296
+
297
+ # Ensure prompt isn't too long (DALL-E 3 has 4000 char limit)
298
+ if len(prompt) > 3000:
299
+ print(f"⚠️ Warning: Prompt truncated from {len(prompt)} to 3000 characters")
300
+ prompt = prompt[:3000]
301
+
302
+ return prompt
303
+
304
+ def _get_race_traits(self, race: DnDRace, gender: Optional[str] = None) -> str:
305
+ """Get ultra-detailed physical traits for race with gender-appropriate descriptions"""
306
+
307
+ if gender == "female":
308
+ traits = {
309
+ DnDRace.HUMAN: "FEMININE WOMAN with expressive almond or round eyes showing depth and intelligence, natural human facial proportions with soft cheekbones, graceful jawline, delicate yet strong features, smooth skin with subtle weathering from adventure, diverse beauty with character lines from experience, warm approachable mouth with natural lips, well-defined brow expressing emotion, elegant neck, hair framing face naturally (flowing, braided, or practical), subtle beauty marks or freckles adding character, human versatility and adaptability shown in features, CLEARLY FEMALE facial structure",
310
+
311
+ DnDRace.ELF: "FEMININE ELVEN WOMAN with long gracefully pointed ears swept back, sharp elegant angular features showing timeless beauty, almond-shaped luminous eyes with otherworldly gaze (colors like silver, gold, violet, or emerald), impossibly high sculpted cheekbones, delicate pointed chin, slender refined nose, soft bow-shaped lips, smooth porcelain-like skin with ageless quality, flowing silken hair (often silver, gold, auburn, or raven) cascading past shoulders or in elaborate braids, ethereal feminine grace, elegant arched eyebrows, slender graceful neck, mystical fey beauty radiating ancient wisdom, subtle glow to skin, CLEARLY FEMALE ELF with otherworldly elegance",
312
+
313
+ DnDRace.DWARF: "FEMININE DWARVEN WOMAN with strong beautiful features, thick lustrous hair in elaborate braids adorned with metal rings and gemstones, fierce intelligent eyes showing determination and warmth, pronounced cheekbones with weathered sun-kissed complexion, broad but feminine jawline, strong nose with character, full lips often set in confident expression, weathered skin from forge work and mountain living showing texture and history, ornate beard jewelry or traditional face tattoos (cultural choice), powerful neck and shoulders showing strength, earthen beauty combining power and femininity, hair in rich colors (copper, bronze, black, auburn), skin with ruddy or bronze undertones, CLEARLY FEMALE DWARF with warrior-artisan beauty",
314
+
315
+ DnDRace.HALFLING: "FEMININE HALFLING WOMAN with round friendly face showing youthful charm, large bright cheerful eyes (often hazel, brown, or green) sparkling with warmth and curiosity, naturally rosy cheeks with healthy glow, button nose, sweet gentle smile with dimples, soft rounded chin, curly or wavy tousled hair in warm colors (brown, auburn, golden, chestnut), smooth skin with youthful texture, delicate pointed ears (smaller than elves), laugh lines from joyful life, slightly plump cherubic features, cozy homely beauty radiating kindness, hair often adorned with flowers or ribbons, CLEARLY FEMALE HALFLING with welcoming maternal energy",
316
+
317
+ DnDRace.DRAGONBORN: "FEMININE DRAGONBORN WOMAN with elegant draconic head featuring graceful reptilian features, sleek scaled skin covering face and neck with metallic or chromatic sheen matching dragon ancestry, fierce yet intelligent eyes with slit pupils glowing with inner fire, refined snout with noble profile, sharp teeth visible when speaking, pronounced facial ridges and horns sweeping back elegantly, scales with iridescent quality catching light beautifully, no hair but decorative horn jewelry or scale paint, expressive eye ridges conveying emotion, strong graceful neck with visible scale texture, regal bearing combining dragon majesty with feminine grace, face scales in beautiful patterns, CLEARLY FEMALE DRAGONBORN with powerful elegant beauty",
318
+
319
+ DnDRace.GNOME: "FEMININE GNOME WOMAN with large expressive eyes full of endless curiosity and mischief (often in unusual colors like amber, turquoise, or violet), delicate upturned pointed nose, wild energetic hair in vibrant colors (pink, green, blue, purple, or natural tones) styled chaotically or with practical clips, mischievous playful grin showing cleverness, soft cheeks with youthful appearance, pointed chin, arched expressive eyebrows always moving with thought, smooth skin often smudged with grease or colored powder, tiny adorable ears, laugh lines around eyes from constant amusement, face showing brilliant intellect and creative energy, often wearing magnifying goggles or spectacles pushed up, CLEARLY FEMALE GNOME with inventive spirited beauty",
320
+
321
+ DnDRace.HALF_ELF: "FEMININE HALF-ELF WOMAN with subtly pointed ears (shorter than full elves), beautiful blend of human warmth and elven ethereal grace, refined yet approachable facial features, high but not extreme cheekbones, expressive eyes with hint of otherworldly luminescence (various colors with subtle glow), elegant straight nose between human and elven, soft lips with gentle smile, smooth skin with light natural glow, flowing hair in diverse colors showing mixed heritage, graceful neck and shoulders, features combining best of both ancestries, slightly elongated facial proportions, eyebrows with elegant arch, skin texture between human and elven smoothness, versatile beauty adaptable to any culture, CLEARLY FEMALE HALF-ELF with hybrid enchanting beauty",
322
+
323
+ DnDRace.HALF_ORC: "BEAUTIFUL FEMININE HALF-ORC WOMAN, CLEARLY FEMALE WARRIOR WOMAN, delicate small lower canines (petite feminine tusks barely visible), SOFT FEMININE FACIAL FEATURES with graceful cheekbones, FEMININE JAWLINE strong but elegant, fierce intelligent eyes showing both strength and compassion (colors like amber, green, brown), slightly prominent brow ridge adding character not masculinity, FEMININE NOSE with strong bridge, full lips with natural beauty, SMOOTH SKIN with greenish or grayish undertones (matching selected skin tone), LONG FLOWING HAIR in warrior braids or wild mane (black, brown, auburn, or dark green), graceful neck showing both power and femininity, war paint or tribal markings enhancing feminine beauty, ATHLETIC FEMININE PHYSIQUE radiating strength and grace, DISTINCTLY FEMALE ORC-BLOODED BEAUTY, warrior goddess appearance combining fierceness with unmistakable femininity, scarring or battle marks adding to beauty not diminishing it, WOMAN HALF-ORC FEMALE",
324
+
325
+ DnDRace.TIEFLING: "FEMININE TIEFLING WOMAN with elegant curved horns sweeping back from forehead or temples (ram-like, gazelle-like, or demonic style) adorned with jewelry, long graceful pointed tail often visible, exotic glowing eyes without pupils (colors like molten gold, burning silver, fiery red, violet, or emerald), sharp elegant cheekbones, delicate pointed chin, soft lips with hint of sharp canine teeth visible when smiling, EXOTIC SKIN in vibrant colors (deep crimson, wine red, purple, blue-gray, pink-red, or obsidian) matching heritage, high arched eyebrows, hair in unusual colors (black, white, red, purple, blue) flowing or styled dramatically, smooth skin with slight infernal texture or patterns, elegant pointed ears or human-like ears, graceful neck, facial features combining demonic heritage with seductive beauty, CLEARLY FEMALE TIEFLING with dangerous alluring charm, infernal markings or tattoos enhancing features, OTHERWORLDLY FEMININE BEAUTY with edge of darkness",
326
+
327
+ DnDRace.DROW: "FEMININE DROW ELF WOMAN with striking white or silver luminous hair (flowing, braided in elaborate warrior styles, or styled in matriarchal fashion), MIDNIGHT OBSIDIAN-BLACK SKIN or deep purple-black skin with smooth flawless texture, long gracefully pointed elven ears, GLOWING EYES in crimson red or violet purple with otherworldly luminescence piercing through darkness, razor-sharp elegant cheekbones, delicate pointed chin, straight refined nose, soft dark lips (deep purple or black), ageless ethereal features combining elven beauty with dark elegance, high arched eyebrows, slender graceful neck, hair-skin contrast creating striking visual, skin with subtle undertone (blue-black, purple-black, or pure obsidian), CLEARLY FEMALE DROW with dangerous seductive beauty, spider web jewelry or dark metal accessories, face radiating lethal grace and underground nobility, DARK ELF WOMAN with predatory elegance",
328
+ }
329
+ elif gender == "male":
330
+ traits = {
331
+ DnDRace.HUMAN: "MASCULINE MAN with strong expressive eyes showing determination and wisdom, natural human facial proportions with defined cheekbones, STRONG MASCULINE JAWLINE with character, broader features showing strength, skin with weathering from adventure and sun exposure, diverse rugged handsomeness with battle scars or life experience, firm mouth with natural lips, PROMINENT BROW showing resolve, POWERFUL NECK and shoulders, facial hair options (beard, stubble, or clean-shaven), hair in practical warrior styles or flowing heroic length, skin texture showing outdoor life, human adaptability in features, CLEARLY MALE facial structure with masculine presence",
332
+
333
+ DnDRace.ELF: "MASCULINE ELVEN MAN with long gracefully pointed ears swept back, sharp noble angular features showing ancient bloodline, almond-shaped piercing eyes with otherworldly gaze (colors like silver, gold, violet, or emerald), high prominent cheekbones, STRONG ELEGANT JAWLINE, refined straight nose, firm lips, smooth ageless skin with ethereal quality, flowing silken hair (often silver, gold, auburn, or raven) worn long or in warrior braids, CLEARLY MALE ELF with masculine grace, high arched brows, LEAN POWERFUL NECK, ethereal masculine beauty combining strength with timeless elegance, skin with subtle glow, facial features showing both warrior prowess and ancient wisdom, no facial hair typical of elves, aristocratic bearing",
334
+
335
+ DnDRace.DWARF: "MASCULINE DWARVEN MAN with MAGNIFICENT THICK BEARD in elaborate braids adorned with metal rings and gemstones (beard is point of pride), thick lustrous hair in warrior braids or bound styles, fierce determined eyes showing strength and honor, VERY PRONOUNCED STRONG CHEEKBONES, BROAD POWERFUL JAWLINE partially hidden by beard, STRONG NOSE with character, weathered ruddy complexion from forge work and mountain living showing texture and history, BROAD POWERFUL NECK and shoulders radiating strength, skin in ruddy or bronze tones, traditional clan tattoos or ritual scars, hair in rich colors (copper, bronze, black, auburn, iron-gray), CLEARLY MALE DWARF with warrior-craftsman presence, rugged masculine beauty, features carved by mountain stone and forge fire",
336
+
337
+ DnDRace.HALFLING: "MASCULINE HALFLING MAN with round friendly face showing cheerful courage, large bright eyes (often hazel, brown, or green) sparkling with warmth and mischief, naturally rosy cheeks with healthy glow, button nose, wide genuine smile with laugh lines, STRONG ROUNDED CHIN, curly or wavy tousled hair in warm colors (brown, auburn, golden, chestnut), youthful skin texture showing outdoor life, delicate pointed ears (smaller than elves), crow's feet from constant smiling, jovial features radiating hospitality, possible sideburns or chin beard, hair slightly wild from adventure, CLEARLY MALE HALFLING with welcoming paternal energy and hidden bravery",
338
+
339
+ DnDRace.DRAGONBORN: "MASCULINE DRAGONBORN MAN with powerful draconic head featuring STRONG reptilian features, THICK scaled skin covering face and neck with metallic or chromatic sheen matching dragon ancestry, fierce commanding eyes with slit pupils burning with inner fire, PRONOUNCED STRONG SNOUT with noble warrior profile, sharp dangerous teeth visible when speaking, PROMINENT FACIAL RIDGES and LARGE HORNS sweeping back powerfully, scales with armored quality showing battle-worn texture, no hair but war trophies or horn decorations, STRONG BROW RIDGE conveying intensity, POWERFUL THICK NECK with pronounced scale texture, commanding presence radiating dragon majesty and masculine power, face scales in intimidating patterns, CLEARLY MALE DRAGONBORN with fierce noble strength",
340
+
341
+ DnDRace.GNOME: "MASCULINE GNOME MAN with large expressive eyes full of endless curiosity and clever mischief (often in unusual colors like amber, turquoise, or violet), pointed upturned nose, wild energetic hair or beard in vibrant colors (pink, green, blue, purple, or natural tones) styled chaotically, mischievous clever grin showing wit and intelligence, STRONG POINTED CHIN, bushy expressive eyebrows always moving with thought, weathered skin often smudged with grease or colored powder from experiments, tiny ears, laugh lines and wrinkles from constant invention, face showing brilliant intellect and manic creativity, often wearing magnifying goggles or spectacles, possible elaborate mustache or beard, CLEARLY MALE GNOME with inventive eccentric charm",
342
+
343
+ DnDRace.HALF_ELF: "MASCULINE HALF-ELF MAN with subtly pointed ears (shorter than full elves), handsome blend of human strength and elven grace, refined yet approachable facial features, STRONG DEFINED CHEEKBONES, FIRM MASCULINE JAWLINE showing mixed heritage, piercing eyes with hint of otherworldly luminescence (various colors with subtle glow), straight nose between human and elven proportions, firm lips with confident expression, skin with light natural glow, hair in diverse colors showing mixed heritage (worn in various styles), STRONG NECK and shoulders, features combining human vitality with elven elegance, slightly elongated facial proportions, strong brow, skin texture between human ruggedness and elven smoothness, adaptable handsome features, CLEARLY MALE HALF-ELF with hybrid noble bearing",
344
+
345
+ DnDRace.HALF_ORC: "MASCULINE HALF-ORC MAN, CLEARLY MALE WARRIOR, LARGE PROMINENT LOWER CANINES (impressive tusks) protruding from mouth, VERY STRONG PROMINENT BROW RIDGE, BROAD POWERFUL MASCULINE FEATURES, SQUARE MASSIVE JAWLINE showing orcish strength, fierce intense eyes showing battle fury and determination (colors like amber, red, green, brown), WIDE FLAT NOSE with strong bridge, scarred weathered skin showing countless battles, THICK SKIN with greenish or grayish undertones (matching selected skin tone), hair in warrior styles (topknot, war braids, or shaved patterns) in dark colors (black, brown, dark green), MASSIVE POWERFUL NECK and shoulders radiating raw strength, war paint or tribal scars marking face, battle-scarred features adding to intimidating presence, CLEARLY MALE HALF-ORC with fearsome warrior appearance, rugged brutal handsomeness, MAN HALF-ORC MALE",
346
+
347
+ DnDRace.TIEFLING: "MASCULINE TIEFLING MAN with prominent curved horns sweeping from forehead or temples (ram-like, demonic, or dragon-like style) possibly adorned with rings, long pointed tail often visible, exotic glowing eyes without pupils (colors like molten gold, burning silver, hellfire red, violet, or emerald), STRONG SHARP CHEEKBONES, DEFINED MASCULINE JAWLINE, firm lips with visible sharp canine teeth when grinning, EXOTIC SKIN in vibrant infernal colors (deep crimson, wine red, purple, blue-gray, pink-red, or obsidian) matching heritage, STRONG BROW with commanding presence, hair in unusual colors (black, white, red, purple, blue) worn in dramatic styles, skin with slight infernal texture or glowing patterns, pointed ears or human-like ears, POWERFUL NECK, facial features combining demonic heritage with dark handsomeness, CLEARLY MALE TIEFLING with dangerous charismatic presence, infernal markings or ritual scars, OTHERWORLDLY MASCULINE BEAUTY with menacing edge",
348
+
349
+ DnDRace.DROW: "MASCULINE DROW ELF MAN with striking white or silver luminous hair (long warrior style, braided in military fashion, or swept back dramatically), MIDNIGHT OBSIDIAN-BLACK SKIN or deep purple-black skin with smooth flawless texture, long gracefully pointed elven ears, GLOWING EYES in crimson red or violet purple with otherworldly intensity piercing darkness, RAZOR-SHARP CHEEKBONES, STRONG MASCULINE JAWLINE, straight refined nose, firm dark lips (deep purple or black), ageless features combining elven beauty with dark masculine power, STRONG ARCHED BROWS, LEAN POWERFUL NECK, hair-skin contrast creating striking warrior appearance, skin with subtle undertone (blue-black, purple-black, or pure obsidian), CLEARLY MALE DROW with dangerous lethal handsomeness, spider web scars or dark metal piercings, face radiating predatory grace and underground warrior nobility, DARK ELF MAN with deadly elegant presence, no facial hair typical of elves",
350
+ }
351
+ else:
352
+ # Androgynous/non-binary descriptions
353
+ traits = {
354
+ DnDRace.HUMAN: "ANDROGYNOUS HUMAN with expressive eyes showing depth and soulful intelligence, balanced facial proportions combining strength and grace, elegantly defined cheekbones neither overly sharp nor soft, refined jawline with harmonious structure, captivating features transcending gender norms, smooth skin with subtle weathering from adventure showing life experience, balanced mouth with expressive lips, thoughtfully arched brow, graceful neck, hair styled in ways celebrating fluidity (undercut, flowing, asymmetric, or artistic), natural beauty radiating confidence and self-expression, features showing human diversity and non-conformity, face with androgynous allure and mysterious charm",
355
+ DnDRace.ELF: "ANDROGYNOUS ELF with long gracefully pointed ears swept back elegantly, sharp perfectly balanced angular features transcending gender, almond-shaped luminous eyes with captivating otherworldly gaze (colors like silver, gold, violet, or emerald), impossibly high sculpted cheekbones creating divine symmetry, refined jawline neither distinctly masculine nor feminine, delicate straight nose, soft balanced lips, smooth porcelain-like ageless skin with ethereal quality, flowing silken hair in mystical colors (silver, gold, auburn, raven, or pastel tones) styled in fluid artistic ways, graceful slender neck, features radiating fey beauty beyond mortal gender concepts, face showing ancient wisdom and timeless allure, subtle luminescence to skin, androgynous elven perfection with magnetic presence",
356
+ DnDRace.DWARF: "ANDROGYNOUS DWARF with thick lustrous hair in artistic braids adorned with unique gemstones and personal metalwork, fierce intelligent eyes showing determination and creative spirit, pronounced balanced cheekbones, weathered complexion from forge work and mountain living with beautiful texture, strong yet refined jawline, noble nose with character, expressive lips, neck and shoulders showing both strength and grace, skin in warm tones (ruddy, bronze, or deep brown), traditional face tattoos or unique piercings celebrating identity, hair in unconventional colors or natural tones, features combining dwarven strength with fluid beauty, face showing artisan soul and warrior heart, androgynous dwarven presence breaking traditional molds",
357
+ DnDRace.HALFLING: "ANDROGYNOUS HALFLING with round friendly face radiating warmth and acceptance, large bright expressive eyes (often hazel, brown, or green) sparkling with joy and wisdom, naturally rosy cheeks with healthy glow, button nose, gentle welcoming smile with dimples, soft balanced chin, curly or wavy hair in warm or creative colors styled in personal expression, smooth youthful skin, delicate pointed ears (smaller than elves), features combining childlike wonder with ageless understanding, face showing both nurturing spirit and adventurous soul, hair adorned with meaningful tokens or natural elements, androgynous halfling beauty radiating community and individuality",
358
+ DnDRace.DRAGONBORN: "ANDROGYNOUS DRAGONBORN with elegant draconic head featuring balanced reptilian features, sleek scaled skin covering face and neck with beautiful metallic or chromatic sheen, fierce intelligent eyes with slit pupils showing wisdom and power, refined snout with noble profile, sharp teeth visible when speaking, facial ridges and horns swept back in artistic arrangement, scales with mesmerizing iridescent patterns, no hair but decorative horn jewelry or ritual scale painting, expressive eye ridges conveying deep emotion, graceful powerful neck with scale texture, presence combining dragon majesty with fluid grace, face scales in unique beautiful patterns, androgynous dragonborn radiating ancient power beyond gender",
359
+ DnDRace.GNOME: "ANDROGYNOUS GNOME with large expressive eyes full of boundless curiosity and creative wonder (often in unique colors like amber, turquoise, or violet), delicate pointed nose, wild artistic hair in vibrant unconventional colors (rainbow, pastel gradients, or natural tones) styled in expressive ways, clever mischievous smile showing brilliant wit, balanced chin, constantly moving expressive eyebrows, skin with inventor's marks (oil smudges, colorful stains, creative tattoos), small ears with multiple piercings or decorations, face radiating innovation and free spirit, often wearing unique goggles or handcrafted accessories, features showing genius that defies categorization, androgynous gnome charm combining intellect with whimsy",
360
+ DnDRace.HALF_ELF: "ANDROGYNOUS HALF-ELF with subtly pointed ears (shorter than full elves), captivating blend of human warmth and elven ethereal beauty, refined yet approachable features transcending binary presentation, balanced elegant cheekbones, graceful jawline neither distinctly masculine nor feminine, mesmerizing eyes with hint of otherworldly luminescence (various colors with gentle glow), straight nose combining human and elven characteristics, expressive lips with enigmatic smile, smooth skin with subtle natural glow, flowing hair in diverse colors showing mixed heritage (styled in personal artistic expression), elegant neck, features perfectly balancing both ancestries, slightly elongated proportions with harmonious beauty, skin texture between human character and elven smoothness, versatile beauty that belongs to all worlds and none, androgynous half-elf with magnetic hybrid allure",
361
+ DnDRace.HALF_ORC: "ANDROGYNOUS HALF-ORC with moderate lower canines (tusks of medium prominence), powerful yet graceful build transcending traditional gender, balanced prominent brow ridge adding character without overpowering, strong features combining orcish heritage with fluid beauty, fierce intelligent eyes showing both warrior spirit and deep wisdom (colors like amber, green, hazel), balanced nose with strong bridge, expressive lips, SKIN with greenish or grayish undertones (matching selected skin tone) with beautiful texture, hair in warrior styles (braids, shaved patterns, or flowing) in unconventional colors, graceful powerful neck, war paint or artistic tattoos celebrating identity and clan, features showing both battle prowess and spiritual depth, scars telling stories of survival and growth, androgynous half-orc beauty breaking warrior stereotypes, presence radiating strength without conforming to binary expectations, face combining orcish power with transcendent grace",
362
+ DnDRace.TIEFLING: "ANDROGYNOUS TIEFLING with elegant artistic horns sweeping from forehead (ram-like, gazelle-like, or unique style) adorned with meaningful jewelry, long expressive pointed tail often visible, exotic glowing eyes without pupils (colors like molten gold, burning silver, mystic violet, or ethereal teal), balanced sharp cheekbones creating striking symmetry, refined jawline neither distinctly masculine nor feminine, expressive lips with hint of sharp canine teeth, EXOTIC SKIN in vibrant mystical colors (deep crimson, royal purple, midnight blue, pink-red, or silver-gray) matching infernal heritage, elegantly arched brows, hair in supernatural colors (starlight white, shadow black, flame red, void purple, or gradient tones) styled in personal artistic expression, smooth skin with subtle infernal patterns or glowing sigils, pointed ears or human-like ears, graceful neck, features combining demonic heritage with transcendent beauty beyond mortal categories, androgynous tiefling radiating otherworldly allure and dangerous mystique, infernal markings celebrating unique identity, face showing both darkness and light in perfect balance",
363
+ DnDRace.DROW: "ANDROGYNOUS DROW ELF with striking luminous white or silver hair (styled in unique artistic ways, warrior braids, or flowing freely), MIDNIGHT OBSIDIAN-BLACK SKIN or deep purple-black skin with flawless smooth texture, long gracefully pointed elven ears, MESMERIZING GLOWING EYES in crimson red or violet purple with captivating otherworldly luminescence, razor-sharp perfectly balanced cheekbones, refined jawline transcending gender, straight elegant nose, expressive dark lips (deep purple or black), ageless features combining elven beauty with dark androgynous allure, gracefully arched brows, elegant neck, hair-skin contrast creating stunning visual impact, skin with subtle undertone (blue-black, purple-black, or pure obsidian), androgynous drow radiating lethal grace and underground nobility beyond binary concepts, spider web jewelry or unique dark metal adornments celebrating identity, face showing predatory elegance and wisdom of the Underdark, dark elf presence with transcendent dangerous beauty, features commanding respect through power not gender",
364
+ }
365
+
366
+ return traits.get(race, "distinctive fantasy features")
367
+
368
+ def _get_class_elements(self, character_class: DnDClass) -> str:
369
+ """Get rich equipment/clothing descriptions for class"""
370
+ elements = {
371
+ DnDClass.FIGHTER: "wearing ornate heavy plate armor with battle scars, wielding a masterwork longsword, shield with heraldic emblem, warrior's stance, battle-ready posture",
372
+
373
+ DnDClass.WIZARD: "wearing flowing arcane robes covered in mystical runes and symbols, holding an ancient gnarled staff with glowing crystal, spellbook at belt, magical energy crackling around hands, scholarly yet powerful presence",
374
+
375
+ DnDClass.ROGUE: "wearing form-fitting leather armor in dark colors, multiple daggers and lockpicks visible, hooded cloak, agile stance, tools of the trade on belt, mysterious air, shadows clinging to figure",
376
+
377
+ DnDClass.CLERIC: "wearing holy vestments and blessed armor adorned with sacred symbols, divine light emanating softly, holy symbol prominently displayed, mace or shield with deity's iconography, righteous bearing",
378
+
379
+ DnDClass.RANGER: "wearing weathered leather armor decorated with natural elements, longbow and quiver of arrows, hunting knives, camouflage cloak, survival gear, at one with nature, tracker's keen gaze",
380
+
381
+ DnDClass.PALADIN: "wearing radiant plate armor gleaming with holy light, sacred sword with blessed runes, shield bearing oath symbol, divine aura surrounding them, righteous and noble bearing, armor reflecting inner conviction",
382
+
383
+ DnDClass.BARD: "wearing flamboyant colorful clothes with artistic flair, ornate musical instrument (lute or flute), decorative rapier, charismatic pose, theatrical presence, jewelry and fine accessories",
384
+
385
+ DnDClass.BARBARIAN: "wearing minimal tribal armor showing powerful muscles, massive greataxe or battle weapon, war paint or tribal tattoos, furs and bone decorations, primal fierce energy, untamed strength visible",
386
+
387
+ DnDClass.DRUID: "wearing natural robes made from leaves and vines, wooden staff carved with nature symbols, animal companion nearby or nature motifs, flowers or moss in hair, earth-toned garments, wild natural aesthetic",
388
+
389
+ DnDClass.MONK: "wearing simple martial arts robes or wraps, disciplined stance showing combat training, meditation beads or prayer rope, barefoot or simple sandals, focused ki energy visible, balanced pose, inner peace and outer strength",
390
+
391
+ DnDClass.SORCERER: "wearing elegant robes with innate magical patterns, raw arcane energy visibly crackling and swirling around body, no spellbook (power from within), draconic or wild magic aesthetic, chaotic magical aura",
392
+
393
+ DnDClass.WARLOCK: "wearing dark mysterious robes with eldritch patterns, otherworldly patron's influence visible in design, arcane focus or pact weapon, eerie magical glow, forbidden knowledge in eyes, shadows and mysterious energies swirling",
394
+ }
395
+ return elements.get(character_class, "adventurer's gear and equipment, ready for questing")
396
+
397
+ def _get_alignment_mood(self, alignment: str) -> str:
398
+ """Get rich mood/expression and visual atmosphere based on alignment"""
399
+ moods = {
400
+ "Lawful Good": "noble righteous expression radiating honor and justice, heroic stance, warm golden lighting highlighting features, aura of divine protection, inspiring presence, defender of the weak demeanor, lawful symbols glowing softly",
401
+
402
+ "Neutral Good": "kind compassionate expression with gentle warm smile, caring eyes full of empathy, soft natural lighting, helping hand gesture, approachable and trustworthy aura, peaceful yet determined energy",
403
+
404
+ "Chaotic Good": "rebellious freedom-fighter expression with fierce determination, defiant heroic gaze, dynamic wild hair, passionate energy, breaking chains imagery, fighting for justice with unconventional methods, vibrant chaotic-good energy swirling",
405
+
406
+ "Lawful Neutral": "disciplined stoic expression showing unwavering resolve, stern neutral features, cold calculated gaze, orderly balanced lighting, perfect symmetry, following code without emotion, judge-like presence, scales of balance nearby",
407
+
408
+ "True Neutral": "perfectly balanced contemplative expression, serene eyes seeing all sides, neutral gray and earth-tone lighting, harmonious natural pose, druid-like connection to balance, neither light nor dark dominating, peaceful equilibrium",
409
+
410
+ "Chaotic Neutral": "unpredictable wild expression with mischievous glint, chaotic energy in eyes, asymmetrical dynamic pose, swirling random elements, free spirit aesthetic, untamed unpredictable aura, living for freedom and self-interest",
411
+
412
+ "Lawful Evil": "coldly calculating tyrant expression, cruel methodical eyes, harsh angular shadows, oppressive dark lighting from above, commanding authoritative pose, iron-fisted control visible, merciless lawful darkness, chains and order motifs",
413
+
414
+ "Neutral Evil": "cruel cunning expression with sinister smirk, selfish malevolent eyes, shadowy ominous lighting, betrayer's calculating gaze, pure self-interest and cruelty, dark subtle shadows, dangerous predatory presence",
415
+
416
+ "Chaotic Evil": "menacing destructive expression with maniacal energy, eyes burning with malevolent chaos, violent chaotic shadows swirling, destructive aura crackling, reveling in cruelty and disorder, demonic wild energy, nightmare-inducing presence, burning destruction in background",
417
+ }
418
+ return moods.get(alignment, "determined focused expression")
419
+
420
+ def _generate_image_openai(
421
+ self,
422
+ prompt: str,
423
+ quality: str = "standard",
424
+ size: str = "1024x1024"
425
+ ) -> Optional[bytes]:
426
+ """
427
+ Call DALL-E 3 API to generate image
428
+
429
+ Args:
430
+ prompt: Image generation prompt
431
+ quality: "standard" or "hd"
432
+ size: Image dimensions
433
+
434
+ Returns:
435
+ Image bytes or None
436
+ """
437
+ client = OpenAI(api_key=self.openai_api_key)
438
+
439
+ try:
440
+ response = client.images.generate(
441
+ model="dall-e-3",
442
+ prompt=prompt,
443
+ size=size,
444
+ quality=quality,
445
+ n=1,
446
+ )
447
+
448
+ # Get image URL
449
+ image_url = response.data[0].url
450
+
451
+ # Download image
452
+ image_response = requests.get(image_url)
453
+ if image_response.status_code == 200:
454
+ return image_response.content
455
+
456
+ return None
457
+
458
+ except openai.BadRequestError as e:
459
+ error_str = str(e)
460
+ print(f"DALL-E API BadRequest error: {error_str}")
461
+
462
+ # Check for billing issues
463
+ if "billing_hard_limit" in error_str or "billing hard limit" in error_str.lower():
464
+ raise ValueError(
465
+ "πŸ’³ Billing limit reached: Your OpenAI account has reached its billing limit.\n\n"
466
+ "To fix this:\n"
467
+ "1. Go to https://platform.openai.com/account/billing\n"
468
+ "2. Add credits or update your payment method\n"
469
+ "3. Check/increase your usage limits\n\n"
470
+ "Note: DALL-E 3 costs $0.040 per standard image or $0.080 per HD image."
471
+ )
472
+ elif "content_policy" in error_str.lower() or "safety" in error_str.lower():
473
+ raise ValueError(
474
+ "Content policy violation: The generated prompt was flagged by OpenAI's safety system. "
475
+ "Try using a different art style or character description."
476
+ )
477
+ else:
478
+ raise ValueError(f"DALL-E API Error: {error_str}")
479
+
480
+ except openai.AuthenticationError as e:
481
+ print(f"DALL-E Authentication error: {e}")
482
+ raise ValueError(
483
+ "πŸ”‘ Authentication error: Your OpenAI API key is invalid or expired.\n"
484
+ "Please check that OPENAI_API_KEY in your .env file is correct."
485
+ )
486
+
487
+ except openai.RateLimitError as e:
488
+ print(f"DALL-E Rate limit error: {e}")
489
+ raise ValueError(
490
+ "⏰ Rate limit exceeded: Too many requests to OpenAI API.\n"
491
+ "Please wait a moment and try again."
492
+ )
493
+
494
+ except Exception as e:
495
+ error_str = str(e)
496
+ print(f"DALL-E API error: {error_str}")
497
+
498
+ # Catch-all with helpful hint
499
+ if "billing" in error_str.lower() or "quota" in error_str.lower():
500
+ raise ValueError(
501
+ "πŸ’³ Billing/Quota error: " + error_str + "\n\n"
502
+ "Check your OpenAI account at https://platform.openai.com/account/billing"
503
+ )
504
+ else:
505
+ raise ValueError(f"DALL-E API Error: {error_str}")
506
+
507
+ def _generate_image_huggingface(self, prompt: str) -> Optional[bytes]:
508
+ """
509
+ Call HuggingFace Inference API to generate image using Stable Diffusion
510
+
511
+ Args:
512
+ prompt: Image generation prompt
513
+
514
+ Returns:
515
+ Image bytes or None
516
+ """
517
+ from huggingface_hub import InferenceClient
518
+
519
+ client = InferenceClient(token=self.hf_api_key)
520
+
521
+ try:
522
+ # Generate image using Stable Diffusion XL
523
+ image = client.text_to_image(
524
+ prompt=prompt,
525
+ model=self.hf_model
526
+ )
527
+
528
+ # Convert PIL Image to bytes
529
+ from PIL import Image
530
+ import io
531
+
532
+ if isinstance(image, Image.Image):
533
+ img_byte_arr = io.BytesIO()
534
+ image.save(img_byte_arr, format='PNG')
535
+ img_byte_arr.seek(0)
536
+ return img_byte_arr.read()
537
+
538
+ return None
539
+
540
+ except Exception as e:
541
+ error_str = str(e)
542
+ print(f"HuggingFace API error: {error_str}")
543
+
544
+ # Provide helpful error messages
545
+ if "401" in error_str or "authentication" in error_str.lower():
546
+ raise ValueError(
547
+ "πŸ”‘ HuggingFace Authentication error: Your HF API key is invalid.\n"
548
+ "Get a free key at https://huggingface.co/settings/tokens"
549
+ )
550
+ elif "503" in error_str or "model" in error_str.lower() and "loading" in error_str.lower():
551
+ raise ValueError(
552
+ "⏰ Model loading: The HuggingFace model is starting up.\n"
553
+ "Please wait 30-60 seconds and try again."
554
+ )
555
+ elif "rate" in error_str.lower() or "quota" in error_str.lower():
556
+ raise ValueError(
557
+ "⏰ Rate limit: HuggingFace API rate limit reached.\n"
558
+ "Free tier: ~100 requests/day. Upgrade at https://huggingface.co/pricing"
559
+ )
560
+ else:
561
+ raise ValueError(f"HuggingFace API Error: {error_str}")
562
+
563
+ def get_portrait_path(self, character_id: str) -> Optional[str]:
564
+ """Get saved portrait path for character"""
565
+ filename = f"{character_id}_portrait.png"
566
+ file_path = self.output_dir / filename
567
+
568
+ if file_path.exists():
569
+ return str(file_path)
570
+
571
+ # Check for custom portrait
572
+ filename = f"{character_id}_custom_portrait.png"
573
+ file_path = self.output_dir / filename
574
+
575
+ if file_path.exists():
576
+ return str(file_path)
577
+
578
+ return None
579
+
580
+
581
+ # Global instance
582
+ _image_generator: Optional[ImageGenerator] = None
583
+
584
+
585
+ def get_image_generator() -> ImageGenerator:
586
+ """Get or create global image generator instance"""
587
+ global _image_generator
588
+ if _image_generator is None:
589
+ _image_generator = ImageGenerator()
590
+ return _image_generator
src/utils/validators.py ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Validation utilities for game objects
3
+ """
4
+
5
+ from typing import List, Dict, Any, Tuple
6
+
7
+ from src.models.character import Character, DnDRace, DnDClass
8
+ from src.models.campaign import Campaign
9
+
10
+
11
+ def validate_character(character: Character) -> Tuple[bool, List[str]]:
12
+ """
13
+ Validate character data
14
+
15
+ Returns:
16
+ Tuple of (is_valid, error_messages)
17
+ """
18
+ errors = []
19
+
20
+ # Name validation
21
+ if not character.name or len(character.name.strip()) == 0:
22
+ errors.append("Character name cannot be empty")
23
+
24
+ # Stats validation
25
+ stats = character.stats
26
+ for stat_name in ['strength', 'dexterity', 'constitution', 'intelligence', 'wisdom', 'charisma']:
27
+ stat_value = getattr(stats, stat_name)
28
+ if stat_value < 1 or stat_value > 30:
29
+ errors.append(f"{stat_name.title()} must be between 1 and 30")
30
+
31
+ # HP validation
32
+ if character.current_hit_points < 0:
33
+ errors.append("Current HP cannot be negative")
34
+ if character.current_hit_points > character.max_hit_points:
35
+ errors.append("Current HP cannot exceed max HP")
36
+ if character.max_hit_points < 1:
37
+ errors.append("Max HP must be at least 1")
38
+
39
+ # AC validation
40
+ if character.armor_class < 1:
41
+ errors.append("Armor class must be at least 1")
42
+
43
+ # Level validation
44
+ if character.level < 1 or character.level > 20:
45
+ errors.append("Level must be between 1 and 20")
46
+
47
+ # XP validation
48
+ if character.experience_points < 0:
49
+ errors.append("Experience points cannot be negative")
50
+
51
+ return len(errors) == 0, errors
52
+
53
+
54
+ def validate_campaign(campaign: Campaign) -> Tuple[bool, List[str]]:
55
+ """
56
+ Validate campaign data
57
+
58
+ Returns:
59
+ Tuple of (is_valid, error_messages)
60
+ """
61
+ errors = []
62
+
63
+ # Name validation
64
+ if not campaign.name or len(campaign.name.strip()) == 0:
65
+ errors.append("Campaign name cannot be empty")
66
+
67
+ # Summary validation
68
+ if not campaign.summary or len(campaign.summary.strip()) == 0:
69
+ errors.append("Campaign summary cannot be empty")
70
+
71
+ # Main conflict validation
72
+ if not campaign.main_conflict or len(campaign.main_conflict.strip()) == 0:
73
+ errors.append("Main conflict cannot be empty")
74
+
75
+ # Party size validation
76
+ if campaign.party_size < 1 or campaign.party_size > 10:
77
+ errors.append("Party size must be between 1 and 10")
78
+
79
+ # Session validation
80
+ if campaign.current_session < 1:
81
+ errors.append("Current session must be at least 1")
82
+ if campaign.total_sessions < 0:
83
+ errors.append("Total sessions cannot be negative")
84
+
85
+ return len(errors) == 0, errors
86
+
87
+
88
+ def validate_stat_array(stats: Dict[str, int]) -> Tuple[bool, List[str]]:
89
+ """Validate D&D ability score array"""
90
+ errors = []
91
+
92
+ required_stats = ['strength', 'dexterity', 'constitution', 'intelligence', 'wisdom', 'charisma']
93
+
94
+ for stat in required_stats:
95
+ if stat not in stats:
96
+ errors.append(f"Missing required stat: {stat}")
97
+ else:
98
+ value = stats[stat]
99
+ if value < 1 or value > 30:
100
+ errors.append(f"{stat.title()} must be between 1 and 30, got {value}")
101
+
102
+ return len(errors) == 0, errors
103
+
104
+
105
+ def is_valid_race(race: str) -> bool:
106
+ """Check if race is valid"""
107
+ try:
108
+ DnDRace(race)
109
+ return True
110
+ except ValueError:
111
+ return False
112
+
113
+
114
+ def is_valid_class(character_class: str) -> bool:
115
+ """Check if class is valid"""
116
+ try:
117
+ DnDClass(character_class)
118
+ return True
119
+ except ValueError:
120
+ return False
121
+
122
+
123
+ def get_available_races() -> List[str]:
124
+ """Get list of available races"""
125
+ return [race.value for race in DnDRace]
126
+
127
+
128
+ def get_available_classes() -> List[str]:
129
+ """Get list of available classes"""
130
+ return [cls.value for cls in DnDClass]