Spaces:
Sleeping
Sleeping
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
- .gitignore +42 -0
- DEPLOY_HF.md +403 -0
- LICENSE +21 -0
- README.md +102 -8
- app_v2.py +32 -0
- mcp_server/README.md +335 -0
- mcp_server/__init__.py +7 -0
- mcp_server/dnd_mcp_server.py +514 -0
- mcp_server/mcp_config.json +20 -0
- requirements.txt +37 -0
- setup_secrets.md +56 -0
- src/__init__.py +8 -0
- src/agents/__init__.py +9 -0
- src/agents/campaign_agent.py +990 -0
- src/agents/character_agent.py +854 -0
- src/config.py +157 -0
- src/models/__init__.py +23 -0
- src/models/campaign.py +275 -0
- src/models/character.py +285 -0
- src/models/game_objects.py +195 -0
- src/models/npc.py +246 -0
- src/models/session_notes.py +31 -0
- src/ui/__init__.py +29 -0
- src/ui/app.py +183 -0
- src/ui/character_creator_ui.py +1842 -0
- src/ui/components/__init__.py +7 -0
- src/ui/components/dropdown_manager.py +120 -0
- src/ui/tabs/__init__.py +29 -0
- src/ui/tabs/about_tab.py +52 -0
- src/ui/tabs/campaign_add_chars_tab.py +106 -0
- src/ui/tabs/campaign_create_tab.py +156 -0
- src/ui/tabs/campaign_manage_tab.py +155 -0
- src/ui/tabs/campaign_synthesize_tab.py +197 -0
- src/ui/tabs/character_create_tab.py +348 -0
- src/ui/tabs/character_export_tab.py +187 -0
- src/ui/tabs/character_load_tab.py +109 -0
- src/ui/tabs/character_manage_tab.py +112 -0
- src/ui/tabs/character_portrait_tab.py +156 -0
- src/ui/tabs/session_tracking_tab.py +599 -0
- src/ui/utils/__init__.py +5 -0
- src/utils/__init__.py +21 -0
- src/utils/ai_client.py +294 -0
- src/utils/character_sheet_exporter.py +567 -0
- src/utils/database.py +227 -0
- src/utils/dice.py +151 -0
- src/utils/file_parsers.py +125 -0
- src/utils/image_generator.py +590 -0
- 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:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
-
license:
|
| 11 |
-
short_description: Create characters, images and custom campaigns
|
| 12 |
---
|
| 13 |
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"")
|
| 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]
|