Spaces:
Running
Running
Switch to Gradio UI; Add MCP support
Browse filesTransitioned Wrdler from Streamlit to Gradio as the primary UI framework, incorporating MCP (Model Context Protocol) server support for AI word generation. Updated README and deployment instructions to reflect these changes. Removed obsolete test scripts and added new ones for Gradio audio serving and MCP integration. Refactored audio handling to use a new shared core module. Incremented project version to 0.1.5.
- LOCALHOST_PWA_README.md +0 -269
- README.md +23 -375
- check_audio_setup.py +0 -109
- docs/MCP_INTEGRATION.md +280 -0
- gradio_app.py +89 -6
- pyproject.toml +6 -2
- test_gradio_audio_serving.py +120 -0
- test_mcp.py +92 -0
- tests/test_apptest.py +0 -7
- tests/test_compare_difficulty_functions.py +0 -237
- tests/test_download_game_settings.py +0 -63
- tests/test_generator.py +0 -29
- tests/test_generator_wrdler.py +0 -227
- tests/test_logic.py +0 -55
- tests/test_models_rect.py +0 -110
- tests/test_word_distribution.py +0 -38
- tests/test_word_file_validation.py +0 -67
- wrdler/__init__.py +1 -1
- wrdler/audio.py +42 -78
- wrdler/audio_core.py +175 -0
- wrdler/gradio_ui.py +269 -211
- wrdler/word_loader_ai.py +120 -0
LOCALHOST_PWA_README.md
DELETED
|
@@ -1,269 +0,0 @@
|
|
| 1 |
-
# PWA on Localhost - Important Information
|
| 2 |
-
|
| 3 |
-
## Summary
|
| 4 |
-
|
| 5 |
-
**The PWA files were created successfully**, but they **won't work fully on `localhost:8501`** due to Streamlit's static file serving limitations.
|
| 6 |
-
|
| 7 |
-
---
|
| 8 |
-
|
| 9 |
-
## What You're Seeing (or Not Seeing)
|
| 10 |
-
|
| 11 |
-
### ✅ What DOES Work on Localhost:
|
| 12 |
-
|
| 13 |
-
1. **Game functionality**: Everything works normally
|
| 14 |
-
2. **Challenge Mode**: Loading `?game_id=...` works (if HF credentials configured)
|
| 15 |
-
3. **PWA meta tags**: Injected into HTML (check page source)
|
| 16 |
-
4. **Service worker registration attempt**: Runs in browser console
|
| 17 |
-
|
| 18 |
-
### ❌ What DOESN'T Work on Localhost:
|
| 19 |
-
|
| 20 |
-
1. **`manifest.json` not accessible**:
|
| 21 |
-
```
|
| 22 |
-
http://localhost:8501/app/static/manifest.json
|
| 23 |
-
→ Returns HTML instead of JSON (Streamlit doesn't serve /app/static/)
|
| 24 |
-
```
|
| 25 |
-
|
| 26 |
-
2. **Icons not accessible**:
|
| 27 |
-
```
|
| 28 |
-
http://localhost:8501/app/static/icon-192.png
|
| 29 |
-
→ Returns 404 or HTML
|
| 30 |
-
```
|
| 31 |
-
|
| 32 |
-
3. **Service worker fails to register**:
|
| 33 |
-
```javascript
|
| 34 |
-
// Browser console shows:
|
| 35 |
-
Failed to register service worker: 404 Not Found
|
| 36 |
-
```
|
| 37 |
-
|
| 38 |
-
4. **No PWA install prompt**:
|
| 39 |
-
- No banner at bottom of screen
|
| 40 |
-
- No install icon in address bar
|
| 41 |
-
- PWA features disabled
|
| 42 |
-
|
| 43 |
-
---
|
| 44 |
-
|
| 45 |
-
## Why This Happens
|
| 46 |
-
|
| 47 |
-
**Streamlit's Static File Serving:**
|
| 48 |
-
|
| 49 |
-
- Streamlit only serves files from:
|
| 50 |
-
- `/.streamlit/static/` (internal Streamlit assets)
|
| 51 |
-
- Component assets via `declare_component()`
|
| 52 |
-
- NOT from arbitrary `battlewords/static/` directories
|
| 53 |
-
|
| 54 |
-
- On HuggingFace Spaces:
|
| 55 |
-
- `/app/static/` is mapped by HF infrastructure
|
| 56 |
-
- Files in `battlewords/static/` are accessible at `/app/static/`
|
| 57 |
-
- ✅ PWA works perfectly
|
| 58 |
-
|
| 59 |
-
- On localhost:
|
| 60 |
-
- No `/app/static/` mapping exists
|
| 61 |
-
- Streamlit returns HTML for all unrecognized paths
|
| 62 |
-
- ❌ PWA files return 404
|
| 63 |
-
|
| 64 |
-
---
|
| 65 |
-
|
| 66 |
-
## How to Test PWA Locally
|
| 67 |
-
|
| 68 |
-
### Option 1: Use ngrok (HTTPS Tunnel) ⭐ **RECOMMENDED**
|
| 69 |
-
|
| 70 |
-
This is the **best way** to test PWA locally with full functionality:
|
| 71 |
-
|
| 72 |
-
```bash
|
| 73 |
-
# Terminal 1: Run Streamlit
|
| 74 |
-
streamlit run app.py
|
| 75 |
-
|
| 76 |
-
# Terminal 2: Expose with HTTPS
|
| 77 |
-
ngrok http 8501
|
| 78 |
-
|
| 79 |
-
# Output shows:
|
| 80 |
-
# Forwarding https://abc123.ngrok-free.app -> http://localhost:8501
|
| 81 |
-
```
|
| 82 |
-
|
| 83 |
-
**Then visit the HTTPS URL on your phone or desktop:**
|
| 84 |
-
- ✅ Full PWA functionality
|
| 85 |
-
- ✅ Install prompt appears
|
| 86 |
-
- ✅ manifest.json loads
|
| 87 |
-
- ✅ Service worker registers
|
| 88 |
-
- ✅ Icons display correctly
|
| 89 |
-
|
| 90 |
-
**ngrok Setup:**
|
| 91 |
-
1. Download: https://ngrok.com/download
|
| 92 |
-
2. Sign up for free account
|
| 93 |
-
3. Install: `unzip /path/to/ngrok.zip` (or chocolatey on Windows: `choco install ngrok`)
|
| 94 |
-
4. Authenticate: `ngrok config add-authtoken <your-token>`
|
| 95 |
-
5. Run: `ngrok http 8501`
|
| 96 |
-
|
| 97 |
-
---
|
| 98 |
-
|
| 99 |
-
### Option 2: Deploy to HuggingFace Spaces ⭐ **PRODUCTION**
|
| 100 |
-
|
| 101 |
-
PWA works out-of-the-box on HF Spaces:
|
| 102 |
-
|
| 103 |
-
```bash
|
| 104 |
-
git add wrdler/static/ wrdler/ui.py
|
| 105 |
-
git commit -m "Add PWA support"
|
| 106 |
-
git push
|
| 107 |
-
|
| 108 |
-
# HF Spaces auto-deploys
|
| 109 |
-
# Visit: https://[YourUsername]-wrdler.hf.space
|
| 110 |
-
```
|
| 111 |
-
|
| 112 |
-
**Then test PWA:**
|
| 113 |
-
- Android Chrome: "Add to Home Screen" prompt appears
|
| 114 |
-
- iOS Safari: Share → "Add to Home Screen"
|
| 115 |
-
- Desktop Chrome: Install icon in address bar
|
| 116 |
-
|
| 117 |
-
✅ **This is where PWA is meant to work!**
|
| 118 |
-
|
| 119 |
-
---
|
| 120 |
-
|
| 121 |
-
###Option 3: Manual Static File Server (Advanced)
|
| 122 |
-
|
| 123 |
-
You can serve the static files separately:
|
| 124 |
-
|
| 125 |
-
```bash
|
| 126 |
-
# Terminal 1: Run Streamlit
|
| 127 |
-
streamlit run app.py
|
| 128 |
-
|
| 129 |
-
# Terminal 2: Serve static files
|
| 130 |
-
cd wrdler/static
|
| 131 |
-
python3 -m http.server 8502
|
| 132 |
-
|
| 133 |
-
# Then access:
|
| 134 |
-
# Streamlit: http://localhost:8501
|
| 135 |
-
# Static files: http://localhost:8502/manifest.json
|
| 136 |
-
```
|
| 137 |
-
|
| 138 |
-
**Then modify the PWA paths in `ui.py`:**
|
| 139 |
-
```python
|
| 140 |
-
pwa_meta_tags = """
|
| 141 |
-
<link rel="manifest" href="http://localhost:8502/manifest.json">
|
| 142 |
-
<link rel="apple-touch-icon" href="http://localhost:8502/icon-192.png">
|
| 143 |
-
<!-- etc -->
|
| 144 |
-
"""
|
| 145 |
-
```
|
| 146 |
-
|
| 147 |
-
❌ **Not recommended**: Too complex, defeats the purpose
|
| 148 |
-
|
| 149 |
-
---
|
| 150 |
-
|
| 151 |
-
## What About Challenge Mode?
|
| 152 |
-
|
| 153 |
-
**Question:** "I loaded `localhost:8501/?game_id=hDjsB_dl` but don't see anything"
|
| 154 |
-
|
| 155 |
-
**Answer:** Challenge Mode is **separate from PWA**. You should see a blue banner at the top if:
|
| 156 |
-
|
| 157 |
-
### ✅ Requirements for Challenge Mode to Work:
|
| 158 |
-
|
| 159 |
-
1. **Environment variables configured** (`.env` file):
|
| 160 |
-
```bash
|
| 161 |
-
HF_API_TOKEN=hf_xxxxxxxxxxxxx
|
| 162 |
-
HF_REPO_ID=Surn/Storage
|
| 163 |
-
SPACE_NAME=Surn/BattleWords
|
| 164 |
-
```
|
| 165 |
-
|
| 166 |
-
2. **Valid game_id exists** in the HF repo:
|
| 167 |
-
- `hDjsB_dl` must be a real challenge created previously
|
| 168 |
-
- Check HuggingFace dataset repo: https://huggingface.co/datasets/Surn/Storage
|
| 169 |
-
- Look for: `games/<uid>/settings.json`
|
| 170 |
-
- Verify `shortener.json` has entry for `hDjsB_dl`
|
| 171 |
-
|
| 172 |
-
3. **Internet connection** (to fetch challenge data)
|
| 173 |
-
|
| 174 |
-
### If Challenge Mode ISN'T Working:
|
| 175 |
-
|
| 176 |
-
**Check browser console (F12 → Console):**
|
| 177 |
-
```javascript
|
| 178 |
-
// Look for errors:
|
| 179 |
-
"[game_storage] Could not resolve sid: hDjsB_dl" ← Challenge not found
|
| 180 |
-
"Failed to load game from sid" ← HF API error
|
| 181 |
-
"HF_API_TOKEN not configured" ← Missing credentials
|
| 182 |
-
```
|
| 183 |
-
|
| 184 |
-
**If you see errors:**
|
| 185 |
-
1. Verify `.env` file exists with correct variables
|
| 186 |
-
2. Restart Streamlit (`Ctrl+C` and `streamlit run app.py` again)
|
| 187 |
-
3. Try a different `game_id` from a known challenge
|
| 188 |
-
4. Check HF repo has the challenge data
|
| 189 |
-
|
| 190 |
-
**Note:** Challenge Mode works the same in Wrdler as it did in BattleWords.
|
| 191 |
-
|
| 192 |
-
---
|
| 193 |
-
|
| 194 |
-
## Summary Table
|
| 195 |
-
|
| 196 |
-
| Feature | Localhost | Localhost + ngrok | HF Spaces (Production) |
|
| 197 |
-
|---------|-----------|-------------------|------------------------|
|
| 198 |
-
| **Game works** | ✅ | ✅ | ✅ |
|
| 199 |
-
| **Challenge Mode** | ✅ (if .env configured) | ✅ | ✅ |
|
| 200 |
-
| **PWA manifest loads** | ❌ | ✅ | ✅ |
|
| 201 |
-
| **Service worker registers** | ❌ | ✅ | ✅ |
|
| 202 |
-
| **Install prompt** | ❌ | ✅ | ✅ |
|
| 203 |
-
| **Icons display** | ❌ | ✅ | ✅ |
|
| 204 |
-
| **Full-screen mode** | ❌ | ✅ | ✅ |
|
| 205 |
-
|
| 206 |
-
---
|
| 207 |
-
|
| 208 |
-
## What You Should Do
|
| 209 |
-
|
| 210 |
-
### For Development:
|
| 211 |
-
✅ **Just develop normally on localhost**
|
| 212 |
-
- Game features work fine
|
| 213 |
-
- Challenge Mode works (if .env configured)
|
| 214 |
-
- PWA features won't work, but that's okay
|
| 215 |
-
- Test PWA when you deploy
|
| 216 |
-
|
| 217 |
-
### For PWA Testing:
|
| 218 |
-
✅ **Use ngrok for quick local PWA testing**
|
| 219 |
-
- 5 minutes to setup
|
| 220 |
-
- Full PWA functionality
|
| 221 |
-
- Test on real phone
|
| 222 |
-
|
| 223 |
-
### For Production:
|
| 224 |
-
✅ **Deploy to HuggingFace Spaces**
|
| 225 |
-
- PWA works automatically
|
| 226 |
-
- No configuration needed
|
| 227 |
-
- `/app/static/` path works out-of-the-box
|
| 228 |
-
|
| 229 |
-
---
|
| 230 |
-
|
| 231 |
-
## Bottom Line
|
| 232 |
-
|
| 233 |
-
**Your question:** "Should I see something at the bottom of the screen?"
|
| 234 |
-
|
| 235 |
-
**Answer:**
|
| 236 |
-
|
| 237 |
-
1. **PWA install prompt**: ❌ Not on `localhost:8501` (Streamlit limitation)
|
| 238 |
-
- **Will work** on HF Spaces production deployment ✅
|
| 239 |
-
- **Will work** with ngrok HTTPS tunnel ✅
|
| 240 |
-
|
| 241 |
-
2. **Challenge Mode banner**: ✅ Should appear at TOP (not bottom)
|
| 242 |
-
- Check if `?game_id=hDjsB_dl` exists in your HF repo
|
| 243 |
-
- Check browser console for errors
|
| 244 |
-
- Verify `.env` has `HF_API_TOKEN` configured
|
| 245 |
-
|
| 246 |
-
The PWA implementation is **correct** and **ready for production**. It just won't work on bare localhost due to Streamlit's static file serving limitations. Once you deploy to HuggingFace Spaces, everything will work perfectly!
|
| 247 |
-
|
| 248 |
-
---
|
| 249 |
-
|
| 250 |
-
## Quick Test Command
|
| 251 |
-
|
| 252 |
-
```bash
|
| 253 |
-
# Check if .env is configured:
|
| 254 |
-
cat .env | grep HF_
|
| 255 |
-
|
| 256 |
-
# Should show:
|
| 257 |
-
# HF_API_TOKEN=hf_xxxxx
|
| 258 |
-
# HF_REPO_ID=YourUsername/Storage
|
| 259 |
-
# SPACE_NAME=YourUsername/Wrdler
|
| 260 |
-
|
| 261 |
-
# If missing, Challenge Mode won't work locally
|
| 262 |
-
```
|
| 263 |
-
|
| 264 |
-
---
|
| 265 |
-
|
| 266 |
-
**Next Steps:**
|
| 267 |
-
1. Test game functionality on localhost ✅
|
| 268 |
-
2. Deploy to HF Spaces for PWA testing ✅
|
| 269 |
-
3. Or install ngrok for local PWA testing ✅
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
README.md
CHANGED
|
@@ -6,396 +6,34 @@ colorTo: indigo
|
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 5.50.0
|
| 8 |
python_version: 3.12.8
|
| 9 |
-
app_port:
|
| 10 |
-
app_file:
|
| 11 |
suggested_hardware: cpu-basic
|
| 12 |
pinned: false
|
| 13 |
tags:
|
| 14 |
- game
|
| 15 |
- vocabulary
|
| 16 |
-
- streamlit
|
| 17 |
- gradio
|
| 18 |
- education
|
| 19 |
- ai
|
| 20 |
-
|
|
|
|
|
|
|
| 21 |
thumbnail: >-
|
| 22 |
https://cdn-uploads.huggingface.co/production/uploads/6346595c9e5f0fe83fc60444/6rWS4AIaozoNMCbx9F5Rv.png
|
| 23 |
---
|
| 24 |
|
| 25 |
-
# Wrdler
|
| 26 |
|
| 27 |
> **This project is based on BattleWords, but adapted for a simpler word puzzle game with an 8x6 grid, horizontal words only, and free letter guesses at the start.**
|
| 28 |
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
**Dual UI Support:** Play with Streamlit (default) or Gradio 5.50+ (alternative) - same great gameplay, your choice of framework!
|
| 32 |
-
|
| 33 |
-
**🎮 Live Demo:** [Play on Hugging Face Spaces](https://huggingface.co/spaces/Surn/Wrdler)
|
| 34 |
-
|
| 35 |
-
## Key Differences from BattleWords
|
| 36 |
-
|
| 37 |
-
- **8x6 grid** (instead of 12x12) with **6 words total** (one per row)
|
| 38 |
-
- **Horizontal words only** (no vertical placement)
|
| 39 |
-
- **No scope/radar visualization**
|
| 40 |
-
- **2 free letter guesses** at the start - choose letters to reveal all instances in the grid
|
| 41 |
-
|
| 42 |
-
## Features
|
| 43 |
-
|
| 44 |
-
### Core Gameplay
|
| 45 |
-
- 8x6 grid with six hidden words (one per row, all horizontal)
|
| 46 |
-
- **Word composition:** Each puzzle contains exactly 2 four-letter words, 2 five-letter words, and 2 six-letter words
|
| 47 |
-
- Game starts with 2 free letter guesses; all instances of chosen letters are revealed
|
| 48 |
-
- Reveal grid cells and guess words for points
|
| 49 |
-
- Scoring tiers: Good (34–37), Great (38–41), Fantastic (42+)
|
| 50 |
-
- Game ends when all words are guessed or all word letters are revealed
|
| 51 |
-
- Incorrect guess history with tooltip and optional display (enabled by default)
|
| 52 |
-
- 10 incorrect guess limit per game
|
| 53 |
-
- Two game modes: Classic (chain guesses) and Too Easy (single guess per reveal)
|
| 54 |
-
|
| 55 |
-
### Audio & Visuals
|
| 56 |
-
- Ocean-themed gradient background with wave animations
|
| 57 |
-
- Background music system (toggleable with volume control)
|
| 58 |
-
- Sound effects for hits, misses, correct/incorrect guesses
|
| 59 |
-
- Responsive UI built with Streamlit
|
| 60 |
-
|
| 61 |
-
### AI Word Generation
|
| 62 |
-
- **Topic-based word lists**: Generate custom word lists using AI for any theme
|
| 63 |
-
- **Intelligent word expansion**: New AI-generated words automatically saved to local files
|
| 64 |
-
- Smart detection separates existing dictionary words from new AI words
|
| 65 |
-
- Only saves new words to prevent duplicates
|
| 66 |
-
- Automatic retry mechanism (up to 3 attempts) for insufficient word counts
|
| 67 |
-
- 1000-word file size limit prevents bloat
|
| 68 |
-
- Auto-sorted by length then alphabetically
|
| 69 |
-
- **Dual generation modes**:
|
| 70 |
-
- **HF Space API** (primary): Uses Hugging Face Space when `USE_HF_WORDS=true`
|
| 71 |
-
- **Local transformers** (fallback): Falls back to local models if HF unavailable
|
| 72 |
-
- **Fallback support**: Gracefully uses dictionary words if AI generation fails
|
| 73 |
-
- **Guaranteed distribution**: Ensures exactly 25 words each of lengths 4, 5, and 6
|
| 74 |
-
|
| 75 |
-
### Customization
|
| 76 |
-
- Multiple word lists (classic, fourth_grade, wordlist)
|
| 77 |
-
- Wordlist sidebar controls (picker + one-click sort)
|
| 78 |
-
- Audio volume controls (music and effects separate)
|
| 79 |
-
|
| 80 |
-
### ✅ Challenge Mode
|
| 81 |
-
- **Shareable challenge links** via short URLs (`?game_id=<sid>`)
|
| 82 |
-
- **Multi-user leaderboards** sorted by score and time
|
| 83 |
-
- **Remote storage** via Hugging Face datasets
|
| 84 |
-
- **Word list difficulty calculation** and display
|
| 85 |
-
- **Submit results** to existing challenges or create new ones
|
| 86 |
-
- **Top 5 leaderboard** display in Challenge Mode banner
|
| 87 |
-
- **"Show Challenge Share Links" toggle** (default OFF) to control URL visibility
|
| 88 |
-
- Each player gets different random words from the same wordlist
|
| 89 |
-
|
| 90 |
-
### Deployment & Technical
|
| 91 |
-
- **Dockerfile-based deployment** supported for Hugging Face Spaces and other container platforms
|
| 92 |
-
- **Dual UI frameworks**:
|
| 93 |
-
- **Streamlit 1.51.0** (default) - Feature-rich, session-state based
|
| 94 |
-
- **Gradio 5.50+** (alternative) - Modern, reactive components
|
| 95 |
-
- Reference: [Gradio 6 Migration Guide](https://www.gradio.app/guides/gradio-6-migration-guide)
|
| 96 |
-
- **Environment variables** for Challenge Mode (HF_API_TOKEN, HF_REPO_ID, SPACE_NAME)
|
| 97 |
-
- Works offline without HF credentials (Challenge Mode features disabled gracefully)
|
| 98 |
-
- Compatible with both local development and cloud deployment
|
| 99 |
-
|
| 100 |
-
### Progressive Web App (PWA)
|
| 101 |
-
- Installable on desktop and mobile from your browser
|
| 102 |
-
- Includes `service worker` and `manifest.json` with basic offline caching of static assets
|
| 103 |
-
- See `INSTALL_GUIDE.md` for platform-specific steps
|
| 104 |
-
|
| 105 |
-
### Planned
|
| 106 |
-
- Local persistent storage for personal game history
|
| 107 |
-
- Personal high scores sidebar (offline-capable)
|
| 108 |
-
- Player statistics tracking
|
| 109 |
-
- Deterministic seed UI for custom puzzles
|
| 110 |
-
|
| 111 |
-
## Challenge Mode & Leaderboard
|
| 112 |
-
|
| 113 |
-
When playing a shared challenge (via a `game_id` link), the leaderboard displays all submitted results for that challenge. The leaderboard is **sorted by highest score (descending), then by fastest time (ascending)**. This means players with the most points appear at the top, and ties are broken by the shortest completion time.
|
| 114 |
-
|
| 115 |
-
## Installation
|
| 116 |
-
1. Clone the repository:
|
| 117 |
-
```
|
| 118 |
-
git clone https://github.com/Oncorporation/Wrdler.git
|
| 119 |
-
cd wrdler
|
| 120 |
-
```
|
| 121 |
-
2. (Optional) Create and activate a virtual environment:
|
| 122 |
-
```
|
| 123 |
-
python -m venv venv
|
| 124 |
-
source venv/bin/activate # On Windows use `venv\Scripts\activate`
|
| 125 |
-
```
|
| 126 |
-
3. Install dependencies: ( add --system if not using a virutal environment)
|
| 127 |
-
```
|
| 128 |
-
uv pip install -r requirements.txt --link-mode=copy
|
| 129 |
-
```
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
## Running Wrdler
|
| 133 |
-
|
| 134 |
-
You can run the app locally using either [uv](https://github.com/astral-sh/uv) or Streamlit directly:
|
| 135 |
-
|
| 136 |
-
**Streamlit UI (default):**
|
| 137 |
-
```bash
|
| 138 |
-
uv run streamlit run app.py
|
| 139 |
-
# or
|
| 140 |
-
streamlit run app.py
|
| 141 |
-
```
|
| 142 |
-
|
| 143 |
-
**Gradio UI (alternative):**
|
| 144 |
-
```bash
|
| 145 |
-
python -m wrdler.gradio_ui
|
| 146 |
-
# or run the Gradio demo file directly
|
| 147 |
-
```
|
| 148 |
-
|
| 149 |
-
Both interfaces provide the same gameplay experience with slightly different UI frameworks.
|
| 150 |
-
|
| 151 |
-
### Dockerfile Deployment (Hugging Face Spaces and more)
|
| 152 |
-
|
| 153 |
-
Wrdler supports containerized deployment using a `Dockerfile`. This is the recommended method for deploying to [Hugging Face Spaces](https://huggingface.co/docs/hub/spaces-sdks-docker) or any Docker-compatible environment.
|
| 154 |
-
|
| 155 |
-
To deploy on Hugging Face Spaces:
|
| 156 |
-
1. Add a `Dockerfile` to your repository root (see [Spaces Dockerfile guide](https://huggingface.co/docs/hub/spaces-sdks-docker)).
|
| 157 |
-
2. Push your code to your Hugging Face Space.
|
| 158 |
-
3. The platform will build and run your app automatically.
|
| 159 |
-
|
| 160 |
-
For local Docker runs:
|
| 161 |
-
```sh
|
| 162 |
-
docker build -t wrdler .
|
| 163 |
-
docker run -p8501:8501 wrdler
|
| 164 |
-
```
|
| 165 |
-
|
| 166 |
-
### Environment Variables (for Challenge Mode)
|
| 167 |
-
|
| 168 |
-
Challenge Mode requires a `.env` file in the project root with HuggingFace Hub credentials:
|
| 169 |
-
|
| 170 |
-
```bash
|
| 171 |
-
# Required for Challenge Mode
|
| 172 |
-
HF_API_TOKEN=hf_xxxxxxxxxxxxxxxxxxxxx # or HF_TOKEN
|
| 173 |
-
HF_REPO_ID=YourUsername/YourRepo # Target HF dataset repo
|
| 174 |
-
SPACE_NAME=YourUsername/Wrdler # Your HF Space name
|
| 175 |
-
|
| 176 |
-
# Optional
|
| 177 |
-
CRYPTO_PK= # Reserved for future signing
|
| 178 |
-
```
|
| 179 |
-
|
| 180 |
-
**How to get your HF_API_TOKEN:**
|
| 181 |
-
1. Go to https://huggingface.co/settings/tokens
|
| 182 |
-
2. Create a new token with `write` access
|
| 183 |
-
3. Add to `.env` file as `HF_API_TOKEN=hf_...`
|
| 184 |
-
|
| 185 |
-
**Note:** The app works without these variables, but Challenge Mode features (sharing, leaderboards) will be disabled.
|
| 186 |
-
|
| 187 |
-
## Folder Structure
|
| 188 |
-
|
| 189 |
-
- `app.py` – Streamlit entry point
|
| 190 |
-
- `wrdler/` – Python package
|
| 191 |
-
- `models.py` – data models and types
|
| 192 |
-
- `word_loader.py` – word list loading and validation
|
| 193 |
-
- `word_loader_ai.py` – AI word generation with HF Space API and local transformers
|
| 194 |
-
- `generator.py` – word placement logic (8x6, horizontal only)
|
| 195 |
-
- `logic.py` – game mechanics (reveal, guess, scoring, free letters)
|
| 196 |
-
- `ui.py` – Streamlit UI composition
|
| 197 |
-
- `gradio_ui.py` – Gradio UI implementation (alternative interface)
|
| 198 |
-
- `game_storage.py` – Hugging Face remote storage integration and challenge sharing
|
| 199 |
-
- `local_storage.py` – local JSON storage for results and high scores
|
| 200 |
-
- `storage.py` – (legacy) local storage and high scores
|
| 201 |
-
- `modules/` – shared utility modules (storage, constants, file_utils)
|
| 202 |
-
- `words/` – word list files (classic.txt, fourth_grade.txt, wordlist.txt, AI-generated)
|
| 203 |
-
- `style_wrdler.css` – Custom CSS styling for Gradio interface
|
| 204 |
-
- `specs/` – documentation (`specs.md`, `requirements.md`)
|
| 205 |
-
- `tests/` – unit tests
|
| 206 |
-
|
| 207 |
-
## Test File Location
|
| 208 |
-
All test files must be placed in the `/tests` folder. This ensures a clean project structure and makes it easy to discover and run all tests.
|
| 209 |
-
|
| 210 |
-
## How to Play
|
| 211 |
-
|
| 212 |
-
1. **Start with 2 free letter guesses** - choose two letters to reveal all their instances in the grid.
|
| 213 |
-
2. Click grid squares to reveal letters or empty spaces.
|
| 214 |
-
3. After revealing a letter, enter a guess for a word in the text box.
|
| 215 |
-
4. Earn points for correct guesses and bonus points for unrevealed letters.
|
| 216 |
-
5. **The game ends when all six words are found or all word letters are revealed. Your score tier is displayed.**
|
| 217 |
-
6. **To play a shared challenge, use a link with `?game_id=<sid>`. Your result will be added to the challenge leaderboard.**
|
| 218 |
-
|
| 219 |
-
## Changelog
|
| 220 |
-
|
| 221 |
-
### v0.1.2 (Current)
|
| 222 |
-
- ✅ **Gradio UI Topic Display** - Prominent neon-styled badge at top of game area
|
| 223 |
-
- Glassmorphism background with glowing cyan border
|
| 224 |
-
- Pulsing neon animation effect
|
| 225 |
-
- Editable - type new topic and press Enter to generate/switch word lists
|
| 226 |
-
- ✅ **Settings Persistence** - Enable Free Letters and Show Challenge Links persist across new games
|
| 227 |
-
- ✅ **Timer Implementation** - Real-time game timer using gr.Timer component
|
| 228 |
-
- ✅ **AI Topic Generation UI** - Added to Settings tab with Generate button
|
| 229 |
-
- ✅ **Streamlit Cache Fix** - Conditional caching to avoid warnings in Gradio mode
|
| 230 |
-
- ✅ **Enhanced CSS Styling** - Improved grid container, topic display, and responsive design
|
| 231 |
-
|
| 232 |
-
### v0.1.1
|
| 233 |
-
- ✅ Enhanced AI word generation with intelligent word saving
|
| 234 |
-
- ✅ Automatic retry mechanism for insufficient word counts (up to 3 retries)
|
| 235 |
-
- ✅ 1000-word file size limit to prevent dictionary bloat
|
| 236 |
-
- ✅ Improved new word detection (separates existing vs. new words before saving)
|
| 237 |
-
- ✅ Better HF Space API integration with graceful fallback to local models
|
| 238 |
-
- ✅ Additional word generation when initial pass doesn't meet MIN_REQUIRED threshold
|
| 239 |
-
- ✅ Enhanced logging for word generation pipeline visibility
|
| 240 |
-
- ✅ **Gradio UI implementation** (gradio>=5.50) as alternative to Streamlit
|
| 241 |
-
- ✅ Custom CSS styling for Gradio interface (style_wrdler.css)
|
| 242 |
-
- ✅ Dual UI framework support - choose between Streamlit or Gradio
|
| 243 |
-
|
| 244 |
-
### v0.1.0
|
| 245 |
-
- ✅ AI word generation functionality added
|
| 246 |
-
- ✅ Topic-based custom word list creation
|
| 247 |
-
- ✅ Dual generation modes (HF Space API + local transformers)
|
| 248 |
-
- ✅ Utility modules integration (storage, file_utils, constants)
|
| 249 |
-
- ✅ Documentation synchronized across all files
|
| 250 |
-
|
| 251 |
-
### v0.0.8
|
| 252 |
-
- remove background animation
|
| 253 |
-
- add "easy" mode (single guess per reveal)
|
| 254 |
-
|
| 255 |
-
### v0.0.7
|
| 256 |
-
- fix guess bug - allowing guesses only after word guessed or letter revealed
|
| 257 |
-
|
| 258 |
-
### v0.0.2 (Current - All Sprints Complete) 🎉
|
| 259 |
-
- **Sprint 1-3:** Core data models, generator refactor, radar removal
|
| 260 |
-
- **Sprint 4:** Implemented free letter selection UI with circular green gradient buttons
|
| 261 |
-
- **Sprint 5:** Updated grid UI rendering for 8×6 display
|
| 262 |
-
- **Sprint 6:** Comprehensive integration testing (7/7 tests passing)
|
| 263 |
-
- **Sprint 7:** Complete documentation update
|
| 264 |
-
- Sound effects integration for free letter selection
|
| 265 |
-
- Mobile-responsive free letter grid
|
| 266 |
-
- Fixed duplicate rendering call bug
|
| 267 |
-
- **All core Wrdler features complete and tested**
|
| 268 |
-
|
| 269 |
-
### v0.0.1 (Initial Wrdler Release)
|
| 270 |
-
- Project renamed from BattleWords to Wrdler
|
| 271 |
-
- Grid resized from 12x12 to 8x6
|
| 272 |
-
- Changed to one word per row (6 total), horizontal only
|
| 273 |
-
- Removed vertical word placement
|
| 274 |
-
- Removed scope/radar visualization
|
| 275 |
-
- Core data models updated for rectangular grid
|
| 276 |
-
- Generator refactored for horizontal-only placement
|
| 277 |
-
|
| 278 |
-
Note
|
| 279 |
-
- `battlewords/storage.py` remains local-only storage; a separate HF integration wrapper is provided as `game_storage.py` for remote challenge mode.
|
| 280 |
-
|
| 281 |
-
## Known Issues / TODO
|
| 282 |
-
|
| 283 |
-
- Word list loading bug: the app may not select the proper word lists in some environments. Investigate `word_loader.get_wordlist_files()` / `load_word_list()` and sidebar selection to ensure the chosen file is correctly used by the generator.
|
| 284 |
-
|
| 285 |
-
## Development Phases
|
| 286 |
-
|
| 287 |
-
- **Proof of Concept (0.1.0):** No overlaps, basic UI, single session.
|
| 288 |
-
- **Beta (0.5.0):** Overlaps allowed on shared letters, responsive layout, keyboard support, deterministic seed.
|
| 289 |
-
- **Full (1.0.0):** Enhanced UX, persistence, leaderboards, daily/practice modes, advanced features.
|
| 290 |
-
|
| 291 |
-
See `specs/requirements.md` and `specs/specs.md` for full details and roadmap.
|
| 292 |
-
|
| 293 |
-
## License
|
| 294 |
-
|
| 295 |
-
Wrdler is based on BattleWords. BattlewordsTM. All Rights Reserved. All content, trademarks and logos are copyrighted by the owner.
|
| 296 |
-
|
| 297 |
-
## Hugging Face Spaces Configuration
|
| 298 |
-
|
| 299 |
-
Wrdler is deployable as a Hugging Face Space with **dual UI support**: Streamlit (default) and Gradio (>=5.50). You can use either the YAML config block or a Dockerfile for advanced/custom deployments.
|
| 300 |
-
|
| 301 |
-
To configure your Space with the YAML block, add it at the top of your `README.md`:
|
| 302 |
-
|
| 303 |
-
**Streamlit Configuration (default):**
|
| 304 |
-
```yaml
|
| 305 |
-
---
|
| 306 |
-
title: Wrdler
|
| 307 |
-
emoji: 🎲
|
| 308 |
-
colorFrom: blue
|
| 309 |
-
colorTo: indigo
|
| 310 |
-
sdk: streamlit
|
| 311 |
-
sdk_version: 1.51.0
|
| 312 |
-
python_version: 3.12.8
|
| 313 |
-
app_file: app.py
|
| 314 |
-
suggested_hardware: cpu-basic
|
| 315 |
-
tags:
|
| 316 |
-
- game
|
| 317 |
-
- vocabulary
|
| 318 |
-
- streamlit
|
| 319 |
-
- education
|
| 320 |
-
- ai
|
| 321 |
-
---
|
| 322 |
-
```
|
| 323 |
-
|
| 324 |
-
**Gradio Configuration (alternative):**
|
| 325 |
-
```yaml
|
| 326 |
-
---
|
| 327 |
-
title: Wrdler Gradio
|
| 328 |
-
emoji: 🎲
|
| 329 |
-
colorFrom: blue
|
| 330 |
-
colorTo: indigo
|
| 331 |
-
sdk: gradio
|
| 332 |
-
sdk_version: 5.50
|
| 333 |
-
python_version: 3.12.8
|
| 334 |
-
app_file: wrdler/gradio_ui.py
|
| 335 |
-
suggested_hardware: cpu-basic
|
| 336 |
-
tags:
|
| 337 |
-
- game
|
| 338 |
-
- vocabulary
|
| 339 |
-
- gradio
|
| 340 |
-
- education
|
| 341 |
-
- ai
|
| 342 |
-
---
|
| 343 |
-
```
|
| 344 |
-
|
| 345 |
-
**Key parameters:**
|
| 346 |
-
- `title`, `emoji`, `colorFrom`, `colorTo`: Visuals for your Space.
|
| 347 |
-
- `sdk`: Use `streamlit` or `gradio` depending on your preferred UI framework.
|
| 348 |
-
- `sdk_version`: Latest supported version (Streamlit 1.51.0 or Gradio 5.50+).
|
| 349 |
-
- `python_version`: Python version (3.12.8 recommended).
|
| 350 |
-
- `app_file`: Entry point for your app (`app.py` for Streamlit, `wrdler/gradio_ui.py` for Gradio).
|
| 351 |
-
- `suggested_hardware`: Recommended hardware tier (cpu-basic is sufficient).
|
| 352 |
-
- `tags`: List of descriptive tags.
|
| 353 |
-
|
| 354 |
-
**Gradio Implementation Notes:**
|
| 355 |
-
- Uses modern Gradio 5.50+ API (compatible with upcoming Gradio 6)
|
| 356 |
-
- Reference: [Gradio 6 Migration Guide](https://www.gradio.app/guides/gradio-6-migration-guide)
|
| 357 |
-
- Custom styling via `style_wrdler.css`
|
| 358 |
-
- State management with `gr.State` and deep copy updates
|
| 359 |
-
- Event handlers use `.click()`, `.change()`, `.submit()` methods
|
| 360 |
-
- Modal dialogs with `gr.Modal(visible=False)` pattern
|
| 361 |
-
|
| 362 |
-
````````
|
| 363 |
-
|
| 364 |
-
# Response
|
| 365 |
-
````````markdown
|
| 366 |
-
---
|
| 367 |
-
title: Wrdler
|
| 368 |
-
emoji: 🎲
|
| 369 |
-
colorFrom: blue
|
| 370 |
-
colorTo: indigo
|
| 371 |
-
sdk: streamlit
|
| 372 |
-
sdk_version: 1.51.0
|
| 373 |
-
python_version: 3.12.8
|
| 374 |
-
app_port: 8501
|
| 375 |
-
app_file: app.py
|
| 376 |
-
suggested_hardware: cpu-basic
|
| 377 |
-
pinned: false
|
| 378 |
-
tags:
|
| 379 |
-
- game
|
| 380 |
-
- vocabulary
|
| 381 |
-
- streamlit
|
| 382 |
-
- gradio
|
| 383 |
-
- education
|
| 384 |
-
- ai
|
| 385 |
-
short_description: Fast paced word guessing game with AI-generated word lists
|
| 386 |
-
thumbnail: >-
|
| 387 |
-
https://cdn-uploads.huggingface.co/production/uploads/6346595c9e5f0fe83fc60444/6rWS4AIaozoNMCbx9F5Rv.png
|
| 388 |
-
---
|
| 389 |
-
|
| 390 |
-
# Wrdler
|
| 391 |
-
|
| 392 |
-
> **This project is based on BattleWords, but adapted for a simpler word puzzle game with an 8x6 grid, horizontal words only, and free letter guesses at the start.**
|
| 393 |
|
| 394 |
Wrdler is a vocabulary learning game with a simplified grid and strategic letter guessing. The objective is to discover hidden words on a grid by making smart guesses before all letters are revealed.
|
| 395 |
|
| 396 |
-
|
| 397 |
|
| 398 |
-
|
| 399 |
|
| 400 |
## Key Differences from BattleWords
|
| 401 |
|
|
@@ -436,6 +74,8 @@ Wrdler is a vocabulary learning game with a simplified grid and strategic letter
|
|
| 436 |
- **Local transformers** (fallback): Falls back to local models if HF unavailable
|
| 437 |
- **Fallback support**: Gracefully uses dictionary words if AI generation fails
|
| 438 |
- **Guaranteed distribution**: Ensures exactly 25 words each of lengths 4, 5, and 6
|
|
|
|
|
|
|
| 439 |
|
| 440 |
### Customization
|
| 441 |
- Multiple word lists (classic, fourth_grade, wordlist)
|
|
@@ -505,13 +145,16 @@ uv run streamlit run app.py
|
|
| 505 |
streamlit run app.py
|
| 506 |
```
|
| 507 |
|
| 508 |
-
**Gradio UI (
|
| 509 |
```bash
|
|
|
|
|
|
|
| 510 |
python -m wrdler.gradio_ui
|
| 511 |
-
|
|
|
|
| 512 |
```
|
| 513 |
|
| 514 |
-
Both interfaces provide the same gameplay experience with slightly different UI frameworks.
|
| 515 |
|
| 516 |
### Dockerfile Deployment (Hugging Face Spaces and more)
|
| 517 |
|
|
@@ -583,7 +226,12 @@ All test files must be placed in the `/tests` folder. This ensures a clean proje
|
|
| 583 |
|
| 584 |
## Changelog
|
| 585 |
|
| 586 |
-
### v0.1.2 (Current)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 587 |
- ✅ **Gradio UI Topic Display** - Prominent neon-styled badge at top of game area
|
| 588 |
- Glassmorphism background with glowing cyan border
|
| 589 |
- Pulsing neon animation effect
|
|
|
|
| 6 |
sdk: gradio
|
| 7 |
sdk_version: 5.50.0
|
| 8 |
python_version: 3.12.8
|
| 9 |
+
app_port: 7860
|
| 10 |
+
app_file: gradio_app.py
|
| 11 |
suggested_hardware: cpu-basic
|
| 12 |
pinned: false
|
| 13 |
tags:
|
| 14 |
- game
|
| 15 |
- vocabulary
|
|
|
|
| 16 |
- gradio
|
| 17 |
- education
|
| 18 |
- ai
|
| 19 |
+
- mcp
|
| 20 |
+
- mcp-server
|
| 21 |
+
short_description: Fast paced word game with AI-generated word lists & MCP
|
| 22 |
thumbnail: >-
|
| 23 |
https://cdn-uploads.huggingface.co/production/uploads/6346595c9e5f0fe83fc60444/6rWS4AIaozoNMCbx9F5Rv.png
|
| 24 |
---
|
| 25 |
|
| 26 |
+
# Wrdler - MCP Hackathon Edition 🎉
|
| 27 |
|
| 28 |
> **This project is based on BattleWords, but adapted for a simpler word puzzle game with an 8x6 grid, horizontal words only, and free letter guesses at the start.**
|
| 29 |
|
| 30 |
+
> **🏆 MCP Hackathon Entry:** This Gradio version includes full MCP (Model Context Protocol) server support for AI word generation! See [MCP Integration Guide](docs/MCP_INTEGRATION.md) for details.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
Wrdler is a vocabulary learning game with a simplified grid and strategic letter guessing. The objective is to discover hidden words on a grid by making smart guesses before all letters are revealed.
|
| 33 |
|
| 34 |
+
**🎮 Live Demo:** [Play on Hugging Face Spaces](https://huggingface.co/spaces/MCP-1st-Birthday/wrdler)
|
| 35 |
|
| 36 |
+
**🔧 MCP Server:** This version exposes AI word generation as an MCP tool for LLM integration!
|
| 37 |
|
| 38 |
## Key Differences from BattleWords
|
| 39 |
|
|
|
|
| 74 |
- **Local transformers** (fallback): Falls back to local models if HF unavailable
|
| 75 |
- **Fallback support**: Gracefully uses dictionary words if AI generation fails
|
| 76 |
- **Guaranteed distribution**: Ensures exactly 25 words each of lengths 4, 5, and 6
|
| 77 |
+
- **MCP Integration**: Expose AI word generation as MCP tool when running locally
|
| 78 |
+
- See [MCP Integration Guide](docs/MCP_INTEGRATION.md) for setup and usage
|
| 79 |
|
| 80 |
### Customization
|
| 81 |
- Multiple word lists (classic, fourth_grade, wordlist)
|
|
|
|
| 145 |
streamlit run app.py
|
| 146 |
```
|
| 147 |
|
| 148 |
+
**Gradio UI (MCP-enabled for hackathon):**
|
| 149 |
```bash
|
| 150 |
+
python gradio_app.py
|
| 151 |
+
# or
|
| 152 |
python -m wrdler.gradio_ui
|
| 153 |
+
|
| 154 |
+
# MCP server will be enabled automatically when USE_HF_WORDS=false
|
| 155 |
```
|
| 156 |
|
| 157 |
+
Both interfaces provide the same gameplay experience with slightly different UI frameworks. **The Gradio version includes MCP server support for AI tool integration.**
|
| 158 |
|
| 159 |
### Dockerfile Deployment (Hugging Face Spaces and more)
|
| 160 |
|
|
|
|
| 226 |
|
| 227 |
## Changelog
|
| 228 |
|
| 229 |
+
### v0.1.2 (Current - MCP Hackathon Edition)
|
| 230 |
+
- ✅ **MCP Server Integration** - Expose AI word generation as MCP tool 🏆
|
| 231 |
+
- `@gr.mcp_server_function` decorator for `generate_ai_words`
|
| 232 |
+
- Conditional registration based on `USE_HF_WORDS` flag
|
| 233 |
+
- Full schema documentation for inputs/outputs
|
| 234 |
+
- See [MCP Integration Guide](docs/MCP_INTEGRATION.md) for setup
|
| 235 |
- ✅ **Gradio UI Topic Display** - Prominent neon-styled badge at top of game area
|
| 236 |
- Glassmorphism background with glowing cyan border
|
| 237 |
- Pulsing neon animation effect
|
check_audio_setup.py
DELETED
|
@@ -1,109 +0,0 @@
|
|
| 1 |
-
#!/usr/bin/env python3
|
| 2 |
-
"""
|
| 3 |
-
Diagnostic script to check audio setup for Wrdler Gradio UI.
|
| 4 |
-
Run this to verify audio files and directories are properly configured.
|
| 5 |
-
"""
|
| 6 |
-
|
| 7 |
-
import os
|
| 8 |
-
from pathlib import Path
|
| 9 |
-
|
| 10 |
-
def check_audio_setup():
|
| 11 |
-
"""Check if audio directories and files exist."""
|
| 12 |
-
print("=" * 70)
|
| 13 |
-
print("Wrdler Audio Setup Diagnostic")
|
| 14 |
-
print("=" * 70)
|
| 15 |
-
|
| 16 |
-
wrdler_dir = Path(__file__).parent / "wrdler"
|
| 17 |
-
|
| 18 |
-
# Check music directory
|
| 19 |
-
music_dir = wrdler_dir / "assets" / "audio" / "music"
|
| 20 |
-
print(f"\n?? Music Directory: {music_dir}")
|
| 21 |
-
if music_dir.exists():
|
| 22 |
-
print(" ? Directory exists")
|
| 23 |
-
music_files = list(music_dir.glob("*.mp3"))
|
| 24 |
-
if music_files:
|
| 25 |
-
print(f" ?? Found {len(music_files)} MP3 file(s):")
|
| 26 |
-
for f in music_files:
|
| 27 |
-
size_kb = f.stat().st_size / 1024
|
| 28 |
-
print(f" - {f.name} ({size_kb:.1f} KB)")
|
| 29 |
-
else:
|
| 30 |
-
print(" ?? No MP3 files found")
|
| 31 |
-
else:
|
| 32 |
-
print(" ? Directory does NOT exist")
|
| 33 |
-
print(f" ?? Create it with: mkdir -p {music_dir}")
|
| 34 |
-
|
| 35 |
-
# Check effects directory
|
| 36 |
-
effects_dir = wrdler_dir / "assets" / "audio" / "effects"
|
| 37 |
-
print(f"\n?? Sound Effects Directory: {effects_dir}")
|
| 38 |
-
if effects_dir.exists():
|
| 39 |
-
print(" ? Directory exists")
|
| 40 |
-
|
| 41 |
-
required_effects = ["correct_guess", "incorrect_guess", "hit", "miss", "congratulations"]
|
| 42 |
-
found_effects = []
|
| 43 |
-
missing_effects = []
|
| 44 |
-
|
| 45 |
-
for effect in required_effects:
|
| 46 |
-
mp3_file = effects_dir / f"{effect}.mp3"
|
| 47 |
-
wav_file = effects_dir / f"{effect}.wav"
|
| 48 |
-
|
| 49 |
-
if mp3_file.exists():
|
| 50 |
-
size_kb = mp3_file.stat().st_size / 1024
|
| 51 |
-
found_effects.append(f"{effect}.mp3 ({size_kb:.1f} KB)")
|
| 52 |
-
elif wav_file.exists():
|
| 53 |
-
size_kb = wav_file.stat().st_size / 1024
|
| 54 |
-
found_effects.append(f"{effect}.wav ({size_kb:.1f} KB)")
|
| 55 |
-
else:
|
| 56 |
-
missing_effects.append(effect)
|
| 57 |
-
|
| 58 |
-
if found_effects:
|
| 59 |
-
print(f" ?? Found {len(found_effects)} effect file(s):")
|
| 60 |
-
for f in found_effects:
|
| 61 |
-
print(f" - {f}")
|
| 62 |
-
|
| 63 |
-
if missing_effects:
|
| 64 |
-
print(f" ?? Missing {len(missing_effects)} effect file(s):")
|
| 65 |
-
for f in missing_effects:
|
| 66 |
-
print(f" - {f}.mp3 or {f}.wav")
|
| 67 |
-
else:
|
| 68 |
-
print(" ? Directory does NOT exist")
|
| 69 |
-
print(f" ?? Create it with: mkdir -p {effects_dir}")
|
| 70 |
-
|
| 71 |
-
# Summary
|
| 72 |
-
print("\n" + "=" * 70)
|
| 73 |
-
print("Summary:")
|
| 74 |
-
print("=" * 70)
|
| 75 |
-
|
| 76 |
-
all_ok = True
|
| 77 |
-
|
| 78 |
-
if not music_dir.exists() or not list(music_dir.glob("*.mp3")):
|
| 79 |
-
print("?? Background music will not work (no music files found)")
|
| 80 |
-
all_ok = False
|
| 81 |
-
else:
|
| 82 |
-
print("? Background music should work")
|
| 83 |
-
|
| 84 |
-
if not effects_dir.exists():
|
| 85 |
-
print("? Sound effects will not work (effects directory missing)")
|
| 86 |
-
all_ok = False
|
| 87 |
-
else:
|
| 88 |
-
required_effects = ["correct_guess", "incorrect_guess", "hit", "miss", "congratulations"]
|
| 89 |
-
missing_count = sum(1 for e in required_effects
|
| 90 |
-
if not (effects_dir / f"{e}.mp3").exists()
|
| 91 |
-
and not (effects_dir / f"{e}.wav").exists())
|
| 92 |
-
if missing_count > 0:
|
| 93 |
-
print(f"?? Sound effects partially working ({missing_count} missing)")
|
| 94 |
-
all_ok = False
|
| 95 |
-
else:
|
| 96 |
-
print("? All sound effects should work")
|
| 97 |
-
|
| 98 |
-
if all_ok:
|
| 99 |
-
print("\n?? Audio setup is complete!")
|
| 100 |
-
else:
|
| 101 |
-
print("\n?? To generate missing sound effects, run:")
|
| 102 |
-
print(" python wrdler/sounds.py")
|
| 103 |
-
print(" or")
|
| 104 |
-
print(" python wrdler/generate_sounds.py")
|
| 105 |
-
|
| 106 |
-
print("=" * 70)
|
| 107 |
-
|
| 108 |
-
if __name__ == "__main__":
|
| 109 |
-
check_audio_setup()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
docs/MCP_INTEGRATION.md
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# MCP Integration for Wrdler
|
| 2 |
+
|
| 3 |
+
## Overview
|
| 4 |
+
|
| 5 |
+
Wrdler exposes AI word generation functionality as an **MCP (Model Context Protocol) tool** when running locally. This allows AI assistants and other MCP clients to generate vocabulary words for custom topics.
|
| 6 |
+
|
| 7 |
+
## What is MCP?
|
| 8 |
+
|
| 9 |
+
The Model Context Protocol (MCP) is a standard for integrating AI assistants with external tools and data sources. Gradio 5.0+ has built-in MCP server support, making it easy to expose functions as MCP tools.
|
| 10 |
+
|
| 11 |
+
**Reference:** [Building MCP Server with Gradio](https://www.gradio.app/guides/building-mcp-server-with-gradio)
|
| 12 |
+
|
| 13 |
+
## Available MCP Tools
|
| 14 |
+
|
| 15 |
+
### `generate_ai_words`
|
| 16 |
+
|
| 17 |
+
Generate 75 AI-selected words (25 each of lengths 4, 5, 6) related to a specific topic.
|
| 18 |
+
|
| 19 |
+
**Availability:** Only when running locally with `USE_HF_WORDS=false`
|
| 20 |
+
|
| 21 |
+
#### Input Parameters
|
| 22 |
+
|
| 23 |
+
```json
|
| 24 |
+
{
|
| 25 |
+
"topic": "Ocean Life", // Required: Theme for word generation
|
| 26 |
+
"model_name": null, // Optional: Override default AI model
|
| 27 |
+
"seed": null, // Optional: Random seed for reproducibility
|
| 28 |
+
"use_dictionary_filter": true, // Optional: Filter against dictionary (legacy parameter)
|
| 29 |
+
"selected_file": null // Optional: Word list file for dictionary context
|
| 30 |
+
}
|
| 31 |
+
```
|
| 32 |
+
|
| 33 |
+
#### Output Format
|
| 34 |
+
|
| 35 |
+
```json
|
| 36 |
+
{
|
| 37 |
+
"words": [
|
| 38 |
+
"WAVE", "TIDE", "FISH", ... // 75 words total (25 � 4-letter, 25 � 5-letter, 25 � 6-letter)
|
| 39 |
+
],
|
| 40 |
+
"difficulties": {
|
| 41 |
+
"WAVE": 0.45, // Difficulty score for each word
|
| 42 |
+
"TIDE": 0.32,
|
| 43 |
+
...
|
| 44 |
+
},
|
| 45 |
+
"metadata": {
|
| 46 |
+
"model_used": "microsoft/Phi-3-mini-4k-instruct",
|
| 47 |
+
"transformers_available": "True",
|
| 48 |
+
"gradio_client_available": "True",
|
| 49 |
+
"use_hf_words": "False",
|
| 50 |
+
"raw_output_length": "2048",
|
| 51 |
+
"raw_output_snippet": "...",
|
| 52 |
+
"ai_initial_count": "75",
|
| 53 |
+
"topic": "Ocean Life",
|
| 54 |
+
"dictionary_filter": "True",
|
| 55 |
+
"new_words_saved": "15"
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
```
|
| 59 |
+
|
| 60 |
+
## Setup
|
| 61 |
+
|
| 62 |
+
### 1. Environment Configuration
|
| 63 |
+
|
| 64 |
+
Set the `USE_HF_WORDS` environment variable to enable local mode:
|
| 65 |
+
|
| 66 |
+
```bash
|
| 67 |
+
# Linux/Mac
|
| 68 |
+
export USE_HF_WORDS=false
|
| 69 |
+
|
| 70 |
+
# Windows (PowerShell)
|
| 71 |
+
$env:USE_HF_WORDS="false"
|
| 72 |
+
|
| 73 |
+
# .env file
|
| 74 |
+
USE_HF_WORDS=false
|
| 75 |
+
```
|
| 76 |
+
|
| 77 |
+
### 2. Run Gradio App
|
| 78 |
+
|
| 79 |
+
```bash
|
| 80 |
+
python gradio_app.py
|
| 81 |
+
```
|
| 82 |
+
|
| 83 |
+
You should see:
|
| 84 |
+
|
| 85 |
+
```
|
| 86 |
+
===========================================================================
|
| 87 |
+
?? MCP SERVER ENABLED (Local Mode)
|
| 88 |
+
===========================================================================
|
| 89 |
+
MCP tools available:
|
| 90 |
+
- generate_ai_words: Generate AI vocabulary words for topics
|
| 91 |
+
|
| 92 |
+
To use MCP tools, connect your MCP client to this Gradio app.
|
| 93 |
+
See: https://www.gradio.app/guides/building-mcp-server-with-gradio
|
| 94 |
+
===========================================================================
|
| 95 |
+
```
|
| 96 |
+
|
| 97 |
+
### 3. Connect MCP Client
|
| 98 |
+
|
| 99 |
+
Configure your MCP client to connect to the Gradio server:
|
| 100 |
+
|
| 101 |
+
```json
|
| 102 |
+
{
|
| 103 |
+
"mcpServers": {
|
| 104 |
+
"wrdler": {
|
| 105 |
+
"url": "http://localhost:7860",
|
| 106 |
+
"transport": "gradio"
|
| 107 |
+
}
|
| 108 |
+
}
|
| 109 |
+
}
|
| 110 |
+
```
|
| 111 |
+
|
| 112 |
+
## Usage Examples
|
| 113 |
+
|
| 114 |
+
### Example 1: Basic Topic Generation
|
| 115 |
+
|
| 116 |
+
**Input:**
|
| 117 |
+
```json
|
| 118 |
+
{
|
| 119 |
+
"topic": "Space Exploration"
|
| 120 |
+
}
|
| 121 |
+
```
|
| 122 |
+
|
| 123 |
+
**Output:**
|
| 124 |
+
```json
|
| 125 |
+
{
|
| 126 |
+
"words": [
|
| 127 |
+
"STAR", "MARS", "MOON", "SHIP", "ORBIT", "COMET", ...
|
| 128 |
+
],
|
| 129 |
+
"difficulties": {
|
| 130 |
+
"STAR": 0.25,
|
| 131 |
+
"MARS": 0.30,
|
| 132 |
+
...
|
| 133 |
+
},
|
| 134 |
+
"metadata": {
|
| 135 |
+
"model_used": "microsoft/Phi-3-mini-4k-instruct",
|
| 136 |
+
"topic": "Space Exploration",
|
| 137 |
+
"ai_initial_count": "75",
|
| 138 |
+
...
|
| 139 |
+
}
|
| 140 |
+
}
|
| 141 |
+
```
|
| 142 |
+
|
| 143 |
+
### Example 2: With Custom Model and Seed
|
| 144 |
+
|
| 145 |
+
**Input:**
|
| 146 |
+
```json
|
| 147 |
+
{
|
| 148 |
+
"topic": "Medieval History",
|
| 149 |
+
"model_name": "meta-llama/Llama-3.1-8B-Instruct",
|
| 150 |
+
"seed": 42
|
| 151 |
+
}
|
| 152 |
+
```
|
| 153 |
+
|
| 154 |
+
### Example 3: Using MCP via Claude Desktop
|
| 155 |
+
|
| 156 |
+
If you have Claude Desktop configured with MCP:
|
| 157 |
+
|
| 158 |
+
1. Add Wrdler to your MCP configuration
|
| 159 |
+
2. In Claude, use natural language:
|
| 160 |
+
|
| 161 |
+
```
|
| 162 |
+
Can you generate vocabulary words about Ancient Rome using the generate_ai_words tool?
|
| 163 |
+
```
|
| 164 |
+
|
| 165 |
+
Claude will automatically call the MCP tool and return the results.
|
| 166 |
+
|
| 167 |
+
## Technical Details
|
| 168 |
+
|
| 169 |
+
### Implementation
|
| 170 |
+
|
| 171 |
+
The MCP integration is implemented in `wrdler/word_loader_ai.py` using Gradio's `@gr.mcp_server_function` decorator:
|
| 172 |
+
|
| 173 |
+
```python
|
| 174 |
+
@gr.mcp_server_function(
|
| 175 |
+
name="generate_ai_words",
|
| 176 |
+
description="Generate 75 AI-selected words...",
|
| 177 |
+
input_schema={...},
|
| 178 |
+
output_schema={...}
|
| 179 |
+
)
|
| 180 |
+
def mcp_generate_ai_words(...) -> dict:
|
| 181 |
+
# Wrapper for generate_ai_words()
|
| 182 |
+
...
|
| 183 |
+
```
|
| 184 |
+
|
| 185 |
+
The Gradio app (`gradio_app.py`) enables the MCP server by setting `mcp_server=True` in the launch configuration:
|
| 186 |
+
|
| 187 |
+
```python
|
| 188 |
+
demo.launch(
|
| 189 |
+
server_name="0.0.0.0",
|
| 190 |
+
server_port=7860,
|
| 191 |
+
mcp_server=True, # Enable MCP server
|
| 192 |
+
...
|
| 193 |
+
)
|
| 194 |
+
```
|
| 195 |
+
|
| 196 |
+
### Conditional Registration
|
| 197 |
+
|
| 198 |
+
The MCP function is **only registered when**:
|
| 199 |
+
- ? Gradio is available
|
| 200 |
+
- ? `USE_HF_WORDS=false` (local mode)
|
| 201 |
+
|
| 202 |
+
When deployed to Hugging Face Spaces (`USE_HF_WORDS=true`), the MCP function is **not registered** to avoid conflicts with the remote API.
|
| 203 |
+
|
| 204 |
+
### Word Generation Pipeline
|
| 205 |
+
|
| 206 |
+
1. **AI Generation**: Use local transformers models or HF Space API
|
| 207 |
+
2. **Validation**: Filter words to lengths 4, 5, 6 (uppercase A-Z only)
|
| 208 |
+
3. **Distribution**: Ensure 25 words per length
|
| 209 |
+
4. **Difficulty Scoring**: Calculate word difficulty metrics
|
| 210 |
+
5. **File Saving**: Save new words to topic-based files
|
| 211 |
+
6. **Return**: Provide words, difficulties, and metadata
|
| 212 |
+
|
| 213 |
+
## Troubleshooting
|
| 214 |
+
|
| 215 |
+
### MCP Function Not Appearing
|
| 216 |
+
|
| 217 |
+
**Check 1: Environment Variable**
|
| 218 |
+
```bash
|
| 219 |
+
echo $USE_HF_WORDS # Should be "false"
|
| 220 |
+
```
|
| 221 |
+
|
| 222 |
+
**Check 2: Gradio Logs**
|
| 223 |
+
```
|
| 224 |
+
? word_loader_ai module loaded (MCP functions may be registered)
|
| 225 |
+
? MCP server function 'generate_ai_words' registered (local mode)
|
| 226 |
+
```
|
| 227 |
+
|
| 228 |
+
**Check 3: Gradio Version**
|
| 229 |
+
```bash
|
| 230 |
+
pip show gradio # Should be >= 5.0.0
|
| 231 |
+
```
|
| 232 |
+
|
| 233 |
+
### Model Loading Issues
|
| 234 |
+
|
| 235 |
+
If you see warnings about model loading:
|
| 236 |
+
```
|
| 237 |
+
?? Transformers not available; falling back to dictionary words.
|
| 238 |
+
```
|
| 239 |
+
|
| 240 |
+
Install transformers:
|
| 241 |
+
```bash
|
| 242 |
+
pip install transformers torch
|
| 243 |
+
```
|
| 244 |
+
|
| 245 |
+
### Port Conflicts
|
| 246 |
+
|
| 247 |
+
If port 7860 is in use, modify `gradio_app.py`:
|
| 248 |
+
```python
|
| 249 |
+
launch_kwargs = {
|
| 250 |
+
"server_port": 7861, # Change port
|
| 251 |
+
...
|
| 252 |
+
}
|
| 253 |
+
```
|
| 254 |
+
|
| 255 |
+
## Remote vs Local Mode
|
| 256 |
+
|
| 257 |
+
| Feature | Local Mode (`USE_HF_WORDS=false`) | Remote Mode (`USE_HF_WORDS=true`) |
|
| 258 |
+
|---------|-----------------------------------|-----------------------------------|
|
| 259 |
+
| MCP Server | ? Enabled | ? Disabled |
|
| 260 |
+
| AI Models | Local transformers | HF Space API |
|
| 261 |
+
| Word Saving | ? Saves to files | ? Saves to files |
|
| 262 |
+
| Best For | Development, MCP clients | Production deployment |
|
| 263 |
+
|
| 264 |
+
## Security Notes
|
| 265 |
+
|
| 266 |
+
- MCP tools run with **full local file system access**
|
| 267 |
+
- Only enable MCP server in **trusted environments**
|
| 268 |
+
- Generated words are saved to `wrdler/words/` directory
|
| 269 |
+
- Model weights are cached in `TMPDIR/hf-cache/`
|
| 270 |
+
|
| 271 |
+
## Further Reading
|
| 272 |
+
|
| 273 |
+
- [Gradio MCP Guide](https://www.gradio.app/guides/building-mcp-server-with-gradio)
|
| 274 |
+
- [MCP Specification](https://modelcontextprotocol.io/)
|
| 275 |
+
- [Wrdler Requirements](../specs/requirements.md)
|
| 276 |
+
|
| 277 |
+
---
|
| 278 |
+
|
| 279 |
+
**Last Updated:** 2025-11-30
|
| 280 |
+
**Version:** 0.1.5
|
gradio_app.py
CHANGED
|
@@ -10,16 +10,99 @@ Usage:
|
|
| 10 |
gradio gradio_app.py
|
| 11 |
"""
|
| 12 |
|
|
|
|
|
|
|
|
|
|
| 13 |
import gradio as gr
|
| 14 |
from wrdler.gradio_ui import create_app
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
# Create the Gradio app
|
| 17 |
demo = create_app()
|
| 18 |
|
| 19 |
if __name__ == "__main__":
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
gradio gradio_app.py
|
| 11 |
"""
|
| 12 |
|
| 13 |
+
import os
|
| 14 |
+
import tempfile
|
| 15 |
+
from pathlib import Path
|
| 16 |
import gradio as gr
|
| 17 |
from wrdler.gradio_ui import create_app
|
| 18 |
|
| 19 |
+
# Import word_loader_ai to register MCP functions
|
| 20 |
+
# This must happen before creating the app
|
| 21 |
+
try:
|
| 22 |
+
from wrdler import word_loader_ai
|
| 23 |
+
print("? word_loader_ai module loaded (MCP functions may be registered)")
|
| 24 |
+
except Exception as e:
|
| 25 |
+
print(f"?? Could not load word_loader_ai: {e}")
|
| 26 |
+
|
| 27 |
+
# Get the project root directory (where this file is located)
|
| 28 |
+
PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
|
| 29 |
+
|
| 30 |
+
# Set the current working directory to project root
|
| 31 |
+
# This ensures file paths are resolved correctly
|
| 32 |
+
os.chdir(PROJECT_ROOT)
|
| 33 |
+
|
| 34 |
+
print(f"Working Directory: {os.getcwd()}")
|
| 35 |
+
print(f"Project Root: {PROJECT_ROOT}")
|
| 36 |
+
|
| 37 |
# Create the Gradio app
|
| 38 |
demo = create_app()
|
| 39 |
|
| 40 |
if __name__ == "__main__":
|
| 41 |
+
# Configure paths for static assets
|
| 42 |
+
favicon_path = os.path.join(PROJECT_ROOT, "static", "favicon.ico")
|
| 43 |
+
|
| 44 |
+
# Get Gradio's temp directory
|
| 45 |
+
gradio_temp_dir = Path(tempfile.gettempdir()) / "gradio"
|
| 46 |
+
|
| 47 |
+
# Verify audio directory exists
|
| 48 |
+
audio_dir = os.path.join(PROJECT_ROOT, "wrdler", "assets", "audio")
|
| 49 |
+
print(f"Audio directory exists: {os.path.exists(audio_dir)}")
|
| 50 |
+
|
| 51 |
+
if os.path.exists(audio_dir):
|
| 52 |
+
# List audio files for debugging
|
| 53 |
+
music_dir = os.path.join(audio_dir, "music")
|
| 54 |
+
effects_dir = os.path.join(audio_dir, "effects")
|
| 55 |
+
|
| 56 |
+
if os.path.exists(music_dir):
|
| 57 |
+
music_files = [f for f in os.listdir(music_dir) if f.endswith('.mp3')]
|
| 58 |
+
print(f"Music files found: {music_files}")
|
| 59 |
+
|
| 60 |
+
if os.path.exists(effects_dir):
|
| 61 |
+
effect_files = [f for f in os.listdir(effects_dir) if f.endswith('.mp3')]
|
| 62 |
+
print(f"Effect files found: {effect_files}")
|
| 63 |
+
|
| 64 |
+
# Check MCP server status
|
| 65 |
+
use_hf_words = os.getenv("USE_HF_WORDS", "false").lower() == "true"
|
| 66 |
+
if not use_hf_words:
|
| 67 |
+
print("\n" + "="*70)
|
| 68 |
+
print("?? MCP SERVER ENABLED (Local Mode)")
|
| 69 |
+
print("="*70)
|
| 70 |
+
print("MCP tools available:")
|
| 71 |
+
print(" - generate_ai_words: Generate AI vocabulary words for topics")
|
| 72 |
+
print("\nTo use MCP tools, connect your MCP client to this Gradio app.")
|
| 73 |
+
print("See: https://www.gradio.app/guides/building-mcp-server-with-gradio")
|
| 74 |
+
print("="*70 + "\n")
|
| 75 |
+
else:
|
| 76 |
+
print("\n?? MCP server disabled (USE_HF_WORDS=true, running in remote mode)")
|
| 77 |
+
|
| 78 |
+
# Launch configuration
|
| 79 |
+
launch_kwargs = {
|
| 80 |
+
"server_name": "0.0.0.0",
|
| 81 |
+
"server_port": 7860,
|
| 82 |
+
"share": False,
|
| 83 |
+
"show_error": True,
|
| 84 |
+
# Enable MCP server in local mode
|
| 85 |
+
"mcp_server": not use_hf_words,
|
| 86 |
+
# Gradio's allowed_paths should include:
|
| 87 |
+
# 1. Project root (for any relative paths)
|
| 88 |
+
# 2. System temp directory (where Gradio caches files)
|
| 89 |
+
"allowed_paths": [
|
| 90 |
+
PROJECT_ROOT,
|
| 91 |
+
str(gradio_temp_dir),
|
| 92 |
+
tempfile.gettempdir(),
|
| 93 |
+
],
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
# Add favicon if it exists
|
| 97 |
+
if os.path.exists(favicon_path):
|
| 98 |
+
launch_kwargs["favicon_path"] = favicon_path
|
| 99 |
+
|
| 100 |
+
print(f"\nLaunching Gradio app...")
|
| 101 |
+
print(f"Allowed paths:")
|
| 102 |
+
for path in launch_kwargs['allowed_paths']:
|
| 103 |
+
print(f" - {path}")
|
| 104 |
+
print(f"Server URL: http://localhost:7860")
|
| 105 |
+
if launch_kwargs["mcp_server"]:
|
| 106 |
+
print(f"MCP Server: ENABLED")
|
| 107 |
+
|
| 108 |
+
demo.launch(**launch_kwargs)
|
pyproject.toml
CHANGED
|
@@ -1,11 +1,13 @@
|
|
| 1 |
[project]
|
| 2 |
name = "wrdler"
|
| 3 |
-
version = "0.1.
|
| 4 |
-
description = "Wrdler vocabulary puzzle game - simplified version based on BattleWords with 8x6 grid, horizontal words only, no scope, and 2 free letter guesses"
|
| 5 |
readme = "README.md"
|
| 6 |
requires-python = ">=3.12,<3.13"
|
| 7 |
dependencies = [
|
| 8 |
"streamlit>=1.51.0",
|
|
|
|
|
|
|
| 9 |
"matplotlib>=3.8",
|
| 10 |
"requests>=2.31.0",
|
| 11 |
"huggingface_hub>=0.20.0",
|
|
@@ -27,3 +29,5 @@ include = ["wrdler*"]
|
|
| 27 |
|
| 28 |
[tool.setuptools.package-data]
|
| 29 |
"wrdler.words" = ["*.txt"]
|
|
|
|
|
|
|
|
|
| 1 |
[project]
|
| 2 |
name = "wrdler"
|
| 3 |
+
version = "0.1.5"
|
| 4 |
+
description = "Wrdler vocabulary puzzle game - simplified version based on BattleWords with 8x6 grid, horizontal words only, no scope, and 2 free letter guesses. Now with Gradio UI!"
|
| 5 |
readme = "README.md"
|
| 6 |
requires-python = ">=3.12,<3.13"
|
| 7 |
dependencies = [
|
| 8 |
"streamlit>=1.51.0",
|
| 9 |
+
"gradio>=5.0.0",
|
| 10 |
+
"gradio-modal>=0.0.3",
|
| 11 |
"matplotlib>=3.8",
|
| 12 |
"requests>=2.31.0",
|
| 13 |
"huggingface_hub>=0.20.0",
|
|
|
|
| 29 |
|
| 30 |
[tool.setuptools.package-data]
|
| 31 |
"wrdler.words" = ["*.txt"]
|
| 32 |
+
"wrdler.assets.audio.music" = ["*.mp3"]
|
| 33 |
+
"wrdler.assets.audio.effects" = ["*.mp3", "*.wav"]
|
test_gradio_audio_serving.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Test script to verify Gradio audio file serving.
|
| 3 |
+
|
| 4 |
+
This creates a minimal Gradio app with HTML5 audio controls to test
|
| 5 |
+
if audio files can be served via Gradio's /file= endpoint.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
import gradio as gr
|
| 10 |
+
|
| 11 |
+
# Get project root
|
| 12 |
+
PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
|
| 13 |
+
|
| 14 |
+
# Audio file paths (absolute)
|
| 15 |
+
MUSIC_FILE = os.path.join(PROJECT_ROOT, "wrdler", "assets", "audio", "music", "background.mp3")
|
| 16 |
+
HIT_EFFECT = os.path.join(PROJECT_ROOT, "wrdler", "assets", "audio", "effects", "hit.mp3")
|
| 17 |
+
MISS_EFFECT = os.path.join(PROJECT_ROOT, "wrdler", "assets", "audio", "effects", "miss.mp3")
|
| 18 |
+
|
| 19 |
+
# Audio directory paths for allowed_paths
|
| 20 |
+
MUSIC_DIR = os.path.join(PROJECT_ROOT, "wrdler", "assets", "audio", "music")
|
| 21 |
+
EFFECTS_DIR = os.path.join(PROJECT_ROOT, "wrdler", "assets", "audio", "effects")
|
| 22 |
+
|
| 23 |
+
print(f"Project Root: {PROJECT_ROOT}")
|
| 24 |
+
print(f"Music file exists: {os.path.exists(MUSIC_FILE)}")
|
| 25 |
+
print(f"Hit effect exists: {os.path.exists(HIT_EFFECT)}")
|
| 26 |
+
print(f"Miss effect exists: {os.path.exists(MISS_EFFECT)}")
|
| 27 |
+
print(f"Music dir exists: {os.path.exists(MUSIC_DIR)}")
|
| 28 |
+
print(f"Effects dir exists: {os.path.exists(EFFECTS_DIR)}")
|
| 29 |
+
|
| 30 |
+
# Convert to website-relative URLs (full path from website root)
|
| 31 |
+
def get_file_url(absolute_path):
|
| 32 |
+
"""Convert absolute path to /file= URL with full relative path from project root."""
|
| 33 |
+
# Get path relative to project root (website root)
|
| 34 |
+
rel_path = os.path.relpath(absolute_path, PROJECT_ROOT)
|
| 35 |
+
# Normalize to forward slashes for URLs
|
| 36 |
+
url_path = rel_path.replace(os.sep, '/')
|
| 37 |
+
return f"/gradio_api/file={url_path}"
|
| 38 |
+
|
| 39 |
+
# Generate URLs with full relative paths from website root
|
| 40 |
+
music_url = get_file_url(MUSIC_FILE)
|
| 41 |
+
hit_url = get_file_url(HIT_EFFECT)
|
| 42 |
+
miss_url = get_file_url(MISS_EFFECT)
|
| 43 |
+
|
| 44 |
+
print(f"\nFile URLs (relative to website root):")
|
| 45 |
+
print(f"Music: {music_url}")
|
| 46 |
+
print(f"Hit: {hit_url}")
|
| 47 |
+
print(f"Miss: {miss_url}")
|
| 48 |
+
|
| 49 |
+
# Create HTML with audio controls using website-relative paths
|
| 50 |
+
html_content = f"""
|
| 51 |
+
<div style="padding: 20px; font-family: Arial, sans-serif;">
|
| 52 |
+
<h2>Gradio Audio File Serving Test (Full Relative Paths)</h2>
|
| 53 |
+
|
| 54 |
+
<div style="margin: 20px 0; padding: 15px; background: #f0f0f0; border-radius: 5px;">
|
| 55 |
+
<h3>Background Music</h3>
|
| 56 |
+
<p><strong>Absolute path:</strong> {MUSIC_FILE}</p>
|
| 57 |
+
<p><strong>URL (from website root):</strong> {music_url}</p>
|
| 58 |
+
<audio controls style="width: 100%;">
|
| 59 |
+
<source src="{music_url}" type="audio/mpeg">
|
| 60 |
+
Your browser does not support audio.
|
| 61 |
+
</audio>
|
| 62 |
+
</div>
|
| 63 |
+
|
| 64 |
+
<div style="margin: 20px 0; padding: 15px; background: #f0f0f0; border-radius: 5px;">
|
| 65 |
+
<h3>Hit Sound Effect</h3>
|
| 66 |
+
<p><strong>Absolute path:</strong> {HIT_EFFECT}</p>
|
| 67 |
+
<p><strong>URL (from website root):</strong> {hit_url}</p>
|
| 68 |
+
<audio controls style="width: 100%;">
|
| 69 |
+
<source src="{hit_url}" type="audio/mpeg">
|
| 70 |
+
Your browser does not support audio.
|
| 71 |
+
</audio>
|
| 72 |
+
</div>
|
| 73 |
+
|
| 74 |
+
<div style="margin: 20px 0; padding: 15px; background: #f0f0f0; border-radius: 5px;">
|
| 75 |
+
<h3>Miss Sound Effect</h3>
|
| 76 |
+
<p><strong>Absolute path:</strong> {MISS_EFFECT}</p>
|
| 77 |
+
<p><strong>URL (from website root):</strong> {miss_url}</p>
|
| 78 |
+
<audio controls style="width: 100%;">
|
| 79 |
+
<source src="{miss_url}" type="audio/mpeg">
|
| 80 |
+
Your browser does not support audio.
|
| 81 |
+
</audio>
|
| 82 |
+
</div>
|
| 83 |
+
|
| 84 |
+
<div style="margin: 20px 0; padding: 15px; background: #fff3cd; border-radius: 5px;">
|
| 85 |
+
<h3>Testing Instructions</h3>
|
| 86 |
+
<ol>
|
| 87 |
+
<li>Check the browser console for any 404 errors</li>
|
| 88 |
+
<li>Try playing each audio file using the controls</li>
|
| 89 |
+
<li>Check the Network tab to see if files load successfully</li>
|
| 90 |
+
</ol>
|
| 91 |
+
<p><strong>Note:</strong> Using full relative paths from website root (e.g., /file=wrdler/assets/audio/music/background.mp3)</p>
|
| 92 |
+
</div>
|
| 93 |
+
</div>
|
| 94 |
+
"""
|
| 95 |
+
|
| 96 |
+
# Create Gradio interface
|
| 97 |
+
with gr.Blocks(title="Audio Serving Test") as demo:
|
| 98 |
+
gr.HTML(html_content)
|
| 99 |
+
|
| 100 |
+
if __name__ == "__main__":
|
| 101 |
+
# Configure allowed paths - include specific audio directories
|
| 102 |
+
launch_kwargs = {
|
| 103 |
+
"server_name": "0.0.0.0",
|
| 104 |
+
"server_port": 7861, # Different port to avoid conflict
|
| 105 |
+
"share": False,
|
| 106 |
+
"show_error": True,
|
| 107 |
+
# Allow Gradio to serve files from these directories
|
| 108 |
+
"allowed_paths": [
|
| 109 |
+
PROJECT_ROOT, # Project root (fallback)
|
| 110 |
+
MUSIC_DIR, # wrdler/assets/audio/music
|
| 111 |
+
EFFECTS_DIR, # wrdler/assets/audio/effects
|
| 112 |
+
],
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
print(f"\nLaunching test server on http://localhost:7861")
|
| 116 |
+
print(f"Allowed paths:")
|
| 117 |
+
for path in launch_kwargs['allowed_paths']:
|
| 118 |
+
print(f" - {path}")
|
| 119 |
+
|
| 120 |
+
demo.launch(**launch_kwargs)
|
test_mcp.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python
|
| 2 |
+
"""
|
| 3 |
+
Quick test script to verify MCP integration is working.
|
| 4 |
+
|
| 5 |
+
This script:
|
| 6 |
+
1. Checks if Gradio MCP dependencies are installed
|
| 7 |
+
2. Verifies the MCP function registration
|
| 8 |
+
3. Tests the generate_ai_words wrapper
|
| 9 |
+
|
| 10 |
+
Usage:
|
| 11 |
+
python test_mcp.py
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import os
|
| 15 |
+
import sys
|
| 16 |
+
|
| 17 |
+
def test_gradio_mcp():
|
| 18 |
+
"""Test Gradio MCP support."""
|
| 19 |
+
print("="*70)
|
| 20 |
+
print("Testing Gradio MCP Integration")
|
| 21 |
+
print("="*70)
|
| 22 |
+
|
| 23 |
+
# Test 1: Check Gradio installation
|
| 24 |
+
print("\n1. Checking Gradio installation...")
|
| 25 |
+
try:
|
| 26 |
+
import gradio as gr
|
| 27 |
+
print(f" ? Gradio version: {gr.__version__}")
|
| 28 |
+
except ImportError as e:
|
| 29 |
+
print(f" ? Gradio not installed: {e}")
|
| 30 |
+
return False
|
| 31 |
+
|
| 32 |
+
# Test 2: Check MCP dependencies
|
| 33 |
+
print("\n2. Checking MCP dependencies...")
|
| 34 |
+
try:
|
| 35 |
+
import mcp
|
| 36 |
+
print(f" ? MCP package installed")
|
| 37 |
+
except ImportError:
|
| 38 |
+
print(f" ?? MCP package not installed")
|
| 39 |
+
print(f" Install with: pip install 'gradio[mcp]'")
|
| 40 |
+
# Not critical - continue anyway
|
| 41 |
+
|
| 42 |
+
# Test 3: Check word_loader_ai module
|
| 43 |
+
print("\n3. Checking word_loader_ai module...")
|
| 44 |
+
try:
|
| 45 |
+
from wrdler import word_loader_ai
|
| 46 |
+
print(f" ? word_loader_ai module loaded")
|
| 47 |
+
|
| 48 |
+
# Check if MCP function exists
|
| 49 |
+
if hasattr(word_loader_ai, 'mcp_generate_ai_words'):
|
| 50 |
+
print(f" ? MCP function 'mcp_generate_ai_words' registered")
|
| 51 |
+
else:
|
| 52 |
+
print(f" ?? MCP function not registered (may be disabled by USE_HF_WORDS flag)")
|
| 53 |
+
|
| 54 |
+
except ImportError as e:
|
| 55 |
+
print(f" ? Failed to load word_loader_ai: {e}")
|
| 56 |
+
return False
|
| 57 |
+
|
| 58 |
+
# Test 4: Check environment variables
|
| 59 |
+
print("\n4. Checking environment configuration...")
|
| 60 |
+
use_hf_words = os.getenv("USE_HF_WORDS", "false").lower() == "true"
|
| 61 |
+
print(f" USE_HF_WORDS: {use_hf_words}")
|
| 62 |
+
|
| 63 |
+
if use_hf_words:
|
| 64 |
+
print(f" ?? MCP server will be DISABLED (remote mode)")
|
| 65 |
+
else:
|
| 66 |
+
print(f" ? MCP server will be ENABLED (local mode)")
|
| 67 |
+
|
| 68 |
+
# Test 5: Verify function can be called
|
| 69 |
+
print("\n5. Testing MCP function wrapper...")
|
| 70 |
+
try:
|
| 71 |
+
if hasattr(word_loader_ai, 'mcp_generate_ai_words'):
|
| 72 |
+
print(f" ?? Function callable: mcp_generate_ai_words")
|
| 73 |
+
print(f" ?? Note: Full test requires AI model (skipping actual generation)")
|
| 74 |
+
else:
|
| 75 |
+
print(f" ?? Function not available (USE_HF_WORDS=true or Gradio not available)")
|
| 76 |
+
except Exception as e:
|
| 77 |
+
print(f" ? Error testing function: {e}")
|
| 78 |
+
|
| 79 |
+
print("\n" + "="*70)
|
| 80 |
+
print("MCP Integration Test Complete")
|
| 81 |
+
print("="*70)
|
| 82 |
+
print("\nTo enable MCP server:")
|
| 83 |
+
print(" 1. Set USE_HF_WORDS=false in your environment")
|
| 84 |
+
print(" 2. Run: python gradio_app.py")
|
| 85 |
+
print(" 3. Connect your MCP client to http://localhost:7860")
|
| 86 |
+
print("\nFor detailed setup, see: docs/MCP_INTEGRATION.md")
|
| 87 |
+
|
| 88 |
+
return True
|
| 89 |
+
|
| 90 |
+
if __name__ == "__main__":
|
| 91 |
+
success = test_gradio_mcp()
|
| 92 |
+
sys.exit(0 if success else 1)
|
tests/test_apptest.py
DELETED
|
@@ -1,7 +0,0 @@
|
|
| 1 |
-
# file: D:/Projects/Battlewords/tests/test_apptest.py
|
| 2 |
-
from streamlit.testing.v1 import AppTest
|
| 3 |
-
|
| 4 |
-
def test_app_runs():
|
| 5 |
-
at = AppTest.from_file("app.py")
|
| 6 |
-
at.run()
|
| 7 |
-
assert not at.exception
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_compare_difficulty_functions.py
DELETED
|
@@ -1,237 +0,0 @@
|
|
| 1 |
-
# file: tests/test_compare_difficulty_functions.py
|
| 2 |
-
import os
|
| 3 |
-
import sys
|
| 4 |
-
import pytest
|
| 5 |
-
|
| 6 |
-
# Ensure the modules path is available
|
| 7 |
-
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
| 8 |
-
|
| 9 |
-
from wrdler.modules.constants import HF_API_TOKEN
|
| 10 |
-
from wrdler.modules.storage import gen_full_url, _get_json_from_repo, HF_REPO_ID, SHORTENER_JSON_FILE
|
| 11 |
-
from wrdler.word_loader import compute_word_difficulties, compute_word_difficulties2, compute_word_difficulties3
|
| 12 |
-
|
| 13 |
-
# Ensure the token is set for Hugging Face Hub
|
| 14 |
-
if HF_API_TOKEN:
|
| 15 |
-
os.environ["HF_API_TOKEN"] = HF_API_TOKEN
|
| 16 |
-
|
| 17 |
-
# Define sample_words as a global variable
|
| 18 |
-
sample_words = []
|
| 19 |
-
|
| 20 |
-
def test_compare_difficulty_functions_for_challenge(capsys):
|
| 21 |
-
"""
|
| 22 |
-
Compare compute_word_difficulties, compute_word_difficulties2, and compute_word_difficulties3
|
| 23 |
-
for all users in a challenge identified by short_id.
|
| 24 |
-
"""
|
| 25 |
-
global sample_words # Ensure we modify the global variable
|
| 26 |
-
|
| 27 |
-
# Use a fixed short id for testing
|
| 28 |
-
short_id = "hDjsB_dl"
|
| 29 |
-
|
| 30 |
-
# Step 1: Resolve short ID to full URL
|
| 31 |
-
status, full_url = gen_full_url(
|
| 32 |
-
short_url=short_id,
|
| 33 |
-
repo_id=HF_REPO_ID,
|
| 34 |
-
json_file=SHORTENER_JSON_FILE
|
| 35 |
-
)
|
| 36 |
-
|
| 37 |
-
if status != "success_retrieved_full" or not full_url:
|
| 38 |
-
print(
|
| 39 |
-
f"Could not resolve short id '{short_id}'. "
|
| 40 |
-
f"Status: {status}. "
|
| 41 |
-
f"Check repo '{HF_REPO_ID}' and mapping file '{SHORTENER_JSON_FILE}'."
|
| 42 |
-
)
|
| 43 |
-
captured = capsys.readouterr()
|
| 44 |
-
assert "Could not resolve short id" in captured.out
|
| 45 |
-
assert not full_url, "full_url should be empty/None on failure"
|
| 46 |
-
print("settings.json was not found or could not be resolved.")
|
| 47 |
-
return
|
| 48 |
-
|
| 49 |
-
print(f"✓ Resolved short id '{short_id}' to full URL: {full_url}")
|
| 50 |
-
|
| 51 |
-
# Step 2: Extract file path from full URL
|
| 52 |
-
url_parts = full_url.split("/resolve/main/")
|
| 53 |
-
assert len(url_parts) == 2, f"Invalid full URL format: {full_url}"
|
| 54 |
-
file_path = url_parts[1]
|
| 55 |
-
|
| 56 |
-
# Step 3: Download and parse settings.json
|
| 57 |
-
settings = _get_json_from_repo(HF_REPO_ID, file_path, repo_type="dataset")
|
| 58 |
-
assert settings, "Failed to download or parse settings.json"
|
| 59 |
-
print(f"✓ Downloaded settings.json")
|
| 60 |
-
|
| 61 |
-
# Validate settings structure
|
| 62 |
-
assert "challenge_id" in settings
|
| 63 |
-
assert "wordlist_source" in settings
|
| 64 |
-
assert "users" in settings
|
| 65 |
-
|
| 66 |
-
wordlist_source = settings.get("wordlist_source", "wordlist.txt")
|
| 67 |
-
users = settings.get("users", [])
|
| 68 |
-
|
| 69 |
-
print(f"\nChallenge ID: {settings['challenge_id']}")
|
| 70 |
-
print(f"Wordlist Source: {wordlist_source}")
|
| 71 |
-
print(f"Number of Users: {len(users)}")
|
| 72 |
-
|
| 73 |
-
# Step 4: Determine wordlist file path
|
| 74 |
-
# Assuming the wordlist is in battlewords/words/ directory
|
| 75 |
-
words_dir = os.path.join(os.path.dirname(__file__), "..", "battlewords", "words")
|
| 76 |
-
wordlist_path = os.path.join(words_dir, wordlist_source)
|
| 77 |
-
|
| 78 |
-
# If wordlist doesn't exist, try classic.txt as fallback
|
| 79 |
-
if not os.path.exists(wordlist_path):
|
| 80 |
-
print(f"⚠ Wordlist '{wordlist_source}' not found, using 'classic.txt' as fallback")
|
| 81 |
-
wordlist_path = os.path.join(words_dir, "classic.txt")
|
| 82 |
-
|
| 83 |
-
assert os.path.exists(wordlist_path), f"Wordlist file not found: {wordlist_path}"
|
| 84 |
-
print(f"✓ Using wordlist: {wordlist_path}")
|
| 85 |
-
|
| 86 |
-
# Step 5: Compare difficulty functions for each user
|
| 87 |
-
print("\n" + "="*80)
|
| 88 |
-
print("DIFFICULTY COMPARISON BY USER")
|
| 89 |
-
print("="*80)
|
| 90 |
-
|
| 91 |
-
all_results = []
|
| 92 |
-
|
| 93 |
-
for user_idx, user in enumerate(users, 1):
|
| 94 |
-
user_name = user.get("name", f"User {user_idx}")
|
| 95 |
-
word_list = user.get("word_list", [])
|
| 96 |
-
sample_words += word_list # Update the global variable with the latest word list
|
| 97 |
-
|
| 98 |
-
if not word_list:
|
| 99 |
-
print(f"\n[{user_idx}] {user_name}: No words assigned, skipping")
|
| 100 |
-
continue
|
| 101 |
-
|
| 102 |
-
print(f"\n[{user_idx}] {user_name}")
|
| 103 |
-
print(f" Words: {len(word_list)} words")
|
| 104 |
-
print(f" Sample: {', '.join(word_list[:5])}{'...' if len(word_list) > 5 else ''}")
|
| 105 |
-
|
| 106 |
-
# Compute difficulties using all three functions
|
| 107 |
-
total_diff1, difficulties1 = compute_word_difficulties(wordlist_path, word_list)
|
| 108 |
-
total_diff2, difficulties2 = compute_word_difficulties2(wordlist_path, word_list)
|
| 109 |
-
total_diff3, difficulties3 = compute_word_difficulties3(wordlist_path, word_list)
|
| 110 |
-
|
| 111 |
-
print(f"\n Function 1 (compute_word_difficulties):")
|
| 112 |
-
print(f" Total Difficulty: {total_diff1:.4f}")
|
| 113 |
-
print(f" Words Processed: {len(difficulties1)}")
|
| 114 |
-
|
| 115 |
-
print(f"\n Function 2 (compute_word_difficulties2):")
|
| 116 |
-
print(f" Total Difficulty: {total_diff2:.4f}")
|
| 117 |
-
print(f" Words Processed: {len(difficulties2)}")
|
| 118 |
-
|
| 119 |
-
print(f"\n Function 3 (compute_word_difficulties3):")
|
| 120 |
-
print(f" Total Difficulty: {total_diff3:.4f}")
|
| 121 |
-
print(f" Words Processed: {len(difficulties3)}")
|
| 122 |
-
|
| 123 |
-
# Calculate statistics
|
| 124 |
-
if difficulties1 and difficulties2 and difficulties3:
|
| 125 |
-
avg_diff1 = total_diff1 / len(difficulties1)
|
| 126 |
-
avg_diff2 = total_diff2 / len(difficulties2)
|
| 127 |
-
avg_diff3 = total_diff3 / len(difficulties3)
|
| 128 |
-
|
| 129 |
-
print(f"\n Comparison:")
|
| 130 |
-
print(f" Average Difficulty (Func1): {avg_diff1:.4f}")
|
| 131 |
-
print(f" Average Difficulty (Func2): {avg_diff2:.4f}")
|
| 132 |
-
print(f" Average Difficulty (Func3): {avg_diff3:.4f}")
|
| 133 |
-
print(f" Difference (Func1 vs Func2): {abs(avg_diff1 - avg_diff2):.4f}")
|
| 134 |
-
print(f" Difference (Func1 vs Func3): {abs(avg_diff1 - avg_diff3):.4f}")
|
| 135 |
-
print(f" Difference (Func2 vs Func3): {abs(avg_diff2 - avg_diff3):.4f}")
|
| 136 |
-
|
| 137 |
-
# Store results for final summary
|
| 138 |
-
all_results.append({
|
| 139 |
-
"user_name": user_name,
|
| 140 |
-
"word_count": len(word_list),
|
| 141 |
-
"total_diff1": total_diff1,
|
| 142 |
-
"total_diff2": total_diff2,
|
| 143 |
-
"total_diff3": total_diff3,
|
| 144 |
-
"difficulties1": difficulties1,
|
| 145 |
-
"difficulties2": difficulties2,
|
| 146 |
-
"difficulties3": difficulties3,
|
| 147 |
-
})
|
| 148 |
-
|
| 149 |
-
# Step 6: Print summary comparison
|
| 150 |
-
print("\n" + "="*80)
|
| 151 |
-
print("OVERALL SUMMARY")
|
| 152 |
-
print("="*80)
|
| 153 |
-
|
| 154 |
-
if all_results:
|
| 155 |
-
total1_sum = sum(r["total_diff1"] for r in all_results)
|
| 156 |
-
total2_sum = sum(r["total_diff2"] for r in all_results)
|
| 157 |
-
total3_sum = sum(r["total_diff3"] for r in all_results)
|
| 158 |
-
total_words = sum(r["word_count"] for r in all_results)
|
| 159 |
-
|
| 160 |
-
print(f"\nTotal Users Analyzed: {len(all_results)}")
|
| 161 |
-
print(f"Total Words Across All Users: {total_words}")
|
| 162 |
-
print(f"\nAggregate Total Difficulty:")
|
| 163 |
-
print(f" Function 1: {total1_sum:.4f}")
|
| 164 |
-
print(f" Function 2: {total2_sum:.4f}")
|
| 165 |
-
print(f" Function 3: {total3_sum:.4f}")
|
| 166 |
-
print(f" Difference (Func1 vs Func2): {abs(total1_sum - total2_sum):.4f}")
|
| 167 |
-
print(f" Difference (Func1 vs Func3): {abs(total1_sum - total3_sum):.4f}")
|
| 168 |
-
print(f" Difference (Func2 vs Func3): {abs(total2_sum - total3_sum):.4f}")
|
| 169 |
-
|
| 170 |
-
# Validate that all functions returned results for all users
|
| 171 |
-
assert all(r["difficulties1"] for r in all_results), "Function 1 failed for some users"
|
| 172 |
-
assert all(r["difficulties2"] for r in all_results), "Function 2 failed for some users"
|
| 173 |
-
assert all(r["difficulties3"] for r in all_results), "Function 3 failed for some users"
|
| 174 |
-
|
| 175 |
-
print("\n✓ All tests passed!")
|
| 176 |
-
else:
|
| 177 |
-
print("\n⚠ No users with words found in this challenge")
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
def test_compare_difficulty_functions_with_classic_wordlist():
|
| 181 |
-
"""
|
| 182 |
-
Test all three difficulty functions using the classic.txt wordlist
|
| 183 |
-
with a sample set of words.
|
| 184 |
-
"""
|
| 185 |
-
global sample_words # Use the global variable
|
| 186 |
-
|
| 187 |
-
words_dir = os.path.join(os.path.dirname(__file__), "..", "battlewords", "words")
|
| 188 |
-
wordlist_path = os.path.join(words_dir, "classic.txt")
|
| 189 |
-
|
| 190 |
-
if not os.path.exists(wordlist_path):
|
| 191 |
-
pytest.skip(f"classic.txt not found at {wordlist_path}")
|
| 192 |
-
|
| 193 |
-
# Use the global sample_words if already populated, otherwise set a default
|
| 194 |
-
if not sample_words:
|
| 195 |
-
sample_words = ["ABLE", "ACID", "AREA", "ARMY", "BEAR", "BOWL", "CAVE", "COIN", "ECHO", "GOLD"]
|
| 196 |
-
|
| 197 |
-
print("\n" + "="*80)
|
| 198 |
-
print("TESTING WITH CLASSIC.TXT WORDLIST")
|
| 199 |
-
print("="*80)
|
| 200 |
-
print(f"Sample Words: {', '.join(sample_words)}")
|
| 201 |
-
|
| 202 |
-
# Compute difficulties
|
| 203 |
-
total_diff1, difficulties1 = compute_word_difficulties(wordlist_path, sample_words)
|
| 204 |
-
total_diff2, difficulties2 = compute_word_difficulties2(wordlist_path, sample_words)
|
| 205 |
-
total_diff3, difficulties3 = compute_word_difficulties3(wordlist_path, sample_words)
|
| 206 |
-
|
| 207 |
-
print(f"\nFunction compute_word_difficulties Results:")
|
| 208 |
-
print(f" Total Difficulty: {total_diff1:.4f}")
|
| 209 |
-
for word in sample_words:
|
| 210 |
-
if word in difficulties1:
|
| 211 |
-
print(f" {word}: {difficulties1[word]:.4f}")
|
| 212 |
-
|
| 213 |
-
print(f"\nFunction compute_word_difficulties2 Results:")
|
| 214 |
-
print(f" Total Difficulty: {total_diff2:.4f}")
|
| 215 |
-
for word in sample_words:
|
| 216 |
-
if word in difficulties2:
|
| 217 |
-
print(f" {word}: {difficulties2[word]:.4f}")
|
| 218 |
-
|
| 219 |
-
print(f"\nFunction compute_word_difficulties3 Results:")
|
| 220 |
-
print(f" Total Difficulty: {total_diff3:.4f}")
|
| 221 |
-
for word in sample_words:
|
| 222 |
-
if word in difficulties3:
|
| 223 |
-
print(f" {word}: {difficulties3[word]:.4f}")
|
| 224 |
-
|
| 225 |
-
# Assertions
|
| 226 |
-
assert len(difficulties1) == len(set(sample_words)), "Function 1 didn't process all words"
|
| 227 |
-
assert len(difficulties2) == len(set(sample_words)), "Function 2 didn't process all words"
|
| 228 |
-
assert len(difficulties3) == len(set(sample_words)), "Function 3 didn't process all words"
|
| 229 |
-
assert total_diff1 > 0, "Function 1 total difficulty should be positive"
|
| 230 |
-
assert total_diff2 > 0, "Function 2 total difficulty should be positive"
|
| 231 |
-
assert total_diff3 > 0, "Function 3 total difficulty should be positive"
|
| 232 |
-
|
| 233 |
-
print("\n✓ Classic wordlist test passed!")
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
if __name__ == "__main__":
|
| 237 |
-
pytest.main(["-s", "-v", __file__])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_download_game_settings.py
DELETED
|
@@ -1,63 +0,0 @@
|
|
| 1 |
-
# file: tests/test_download_game_settings.py
|
| 2 |
-
import os
|
| 3 |
-
import sys
|
| 4 |
-
import pytest
|
| 5 |
-
|
| 6 |
-
# Ensure the modules path is available
|
| 7 |
-
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
| 8 |
-
|
| 9 |
-
from wrdler.modules.constants import HF_API_TOKEN # <-- Import the token
|
| 10 |
-
from wrdler.modules.storage import gen_full_url, _get_json_from_repo, HF_REPO_ID, SHORTENER_JSON_FILE
|
| 11 |
-
|
| 12 |
-
# Ensure the token is set for Hugging Face Hub
|
| 13 |
-
if HF_API_TOKEN:
|
| 14 |
-
os.environ["HF_API_TOKEN"] = HF_API_TOKEN
|
| 15 |
-
|
| 16 |
-
def test_download_settings_by_short_id_handles_both(capsys):
|
| 17 |
-
# Use a fixed short id for testing
|
| 18 |
-
short_id = "hDjsB_dl"
|
| 19 |
-
|
| 20 |
-
# Step 1: Resolve short ID to full URL
|
| 21 |
-
status, full_url = gen_full_url(
|
| 22 |
-
short_url=short_id,
|
| 23 |
-
repo_id=HF_REPO_ID,
|
| 24 |
-
json_file=SHORTENER_JSON_FILE
|
| 25 |
-
)
|
| 26 |
-
|
| 27 |
-
# Failure branch: provide a helpful message and assert expected failure shape
|
| 28 |
-
if status != "success_retrieved_full" or not full_url:
|
| 29 |
-
print(
|
| 30 |
-
f"Could not resolve short id '{short_id}'. "
|
| 31 |
-
f"Status: {status}. "
|
| 32 |
-
f"Check repo '{HF_REPO_ID}' and mapping file '{SHORTENER_JSON_FILE}'."
|
| 33 |
-
)
|
| 34 |
-
captured = capsys.readouterr()
|
| 35 |
-
assert "Could not resolve short id" in captured.out
|
| 36 |
-
# Ensure failure shape is consistent
|
| 37 |
-
assert not full_url, "full_url should be empty/None on failure"
|
| 38 |
-
print("settings.json was not found or could not be resolved.")
|
| 39 |
-
return
|
| 40 |
-
else:
|
| 41 |
-
print(f"Resolved short id '{short_id}' to full URL: {full_url}")
|
| 42 |
-
|
| 43 |
-
# Success branch
|
| 44 |
-
assert status == "success_retrieved_full", f"Failed to resolve short ID: {status}"
|
| 45 |
-
assert full_url, "No full URL returned"
|
| 46 |
-
|
| 47 |
-
# Step 2: Extract file path from full URL
|
| 48 |
-
url_parts = full_url.split("/resolve/main/")
|
| 49 |
-
assert len(url_parts) == 2, f"Invalid full URL format: {full_url}"
|
| 50 |
-
file_path = url_parts[1]
|
| 51 |
-
|
| 52 |
-
# Step 3: Download and parse settings.json
|
| 53 |
-
settings = _get_json_from_repo(HF_REPO_ID, file_path, repo_type="dataset")
|
| 54 |
-
assert settings, "Failed to download or parse settings.json"
|
| 55 |
-
|
| 56 |
-
print("\nDownloaded settings.json contents:", settings)
|
| 57 |
-
# Optionally, add more assertions about the settings structure
|
| 58 |
-
assert "challenge_id" in settings
|
| 59 |
-
assert "wordlist_source" in settings
|
| 60 |
-
assert "users" in settings
|
| 61 |
-
|
| 62 |
-
if __name__ == "__main__":
|
| 63 |
-
pytest.main(["-s", __file__])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_generator.py
DELETED
|
@@ -1,29 +0,0 @@
|
|
| 1 |
-
import unittest
|
| 2 |
-
|
| 3 |
-
from wrdler.generator import generate_puzzle, validate_puzzle
|
| 4 |
-
from wrdler.models import Coord
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
class TestGenerator(unittest.TestCase):
|
| 8 |
-
def test_generate_valid_puzzle(self):
|
| 9 |
-
# Provide a minimal word pool for deterministic testing
|
| 10 |
-
words_by_len = {
|
| 11 |
-
4: ["TREE", "BOAT"],
|
| 12 |
-
5: ["APPLE", "RIVER"],
|
| 13 |
-
6: ["ORANGE", "PYTHON"],
|
| 14 |
-
}
|
| 15 |
-
p = generate_puzzle(grid_size=12, words_by_len=words_by_len, seed=1234)
|
| 16 |
-
validate_puzzle(p, grid_size=12)
|
| 17 |
-
# Ensure 6 words and 6 radar pulses
|
| 18 |
-
self.assertEqual(len(p.words), 6)
|
| 19 |
-
self.assertEqual(len(p.radar), 6)
|
| 20 |
-
# Ensure no overlaps
|
| 21 |
-
seen = set()
|
| 22 |
-
for w in p.words:
|
| 23 |
-
for c in w.cells:
|
| 24 |
-
self.assertNotIn(c, seen)
|
| 25 |
-
seen.add(c)
|
| 26 |
-
self.assertTrue(0 <= c.x < 12 and 0 <= c.y < 12)
|
| 27 |
-
|
| 28 |
-
if __name__ == "__main__":
|
| 29 |
-
unittest.main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_generator_wrdler.py
DELETED
|
@@ -1,227 +0,0 @@
|
|
| 1 |
-
#!/usr/bin/env python3
|
| 2 |
-
# -*- coding: utf-8 -*-
|
| 3 |
-
"""
|
| 4 |
-
Quick test script for Wrdler puzzle generator.
|
| 5 |
-
Tests the new horizontal-only, 6x8 grid generator.
|
| 6 |
-
"""
|
| 7 |
-
|
| 8 |
-
import sys
|
| 9 |
-
import os
|
| 10 |
-
|
| 11 |
-
# Force UTF-8 encoding on Windows
|
| 12 |
-
if sys.platform == "win32":
|
| 13 |
-
import codecs
|
| 14 |
-
sys.stdout = codecs.getwriter("utf-8")(sys.stdout.detach())
|
| 15 |
-
sys.stderr = codecs.getwriter("utf-8")(sys.stderr.detach())
|
| 16 |
-
|
| 17 |
-
from wrdler.generator import generate_puzzle, validate_puzzle
|
| 18 |
-
from wrdler.word_loader import load_word_list
|
| 19 |
-
|
| 20 |
-
def test_basic_generation():
|
| 21 |
-
"""Test basic puzzle generation with default parameters."""
|
| 22 |
-
print("Test 1: Basic puzzle generation (6 rows × 8 columns)")
|
| 23 |
-
print("=" * 60)
|
| 24 |
-
|
| 25 |
-
try:
|
| 26 |
-
puzzle = generate_puzzle()
|
| 27 |
-
print(f"✓ Puzzle generated successfully!")
|
| 28 |
-
print(f" Grid: {puzzle.grid_rows} rows × {puzzle.grid_cols} columns")
|
| 29 |
-
print(f" Words: {len(puzzle.words)}")
|
| 30 |
-
print()
|
| 31 |
-
|
| 32 |
-
# Print words
|
| 33 |
-
print("Words in puzzle:")
|
| 34 |
-
for i, word in enumerate(sorted(puzzle.words, key=lambda w: w.start.x), 1):
|
| 35 |
-
print(f" {i}. '{word.text}' ({len(word.text)} letters) - Row {word.start.x}, Cols {word.start.y}-{word.start.y + len(word.text) - 1}, Direction: {word.direction}")
|
| 36 |
-
print()
|
| 37 |
-
|
| 38 |
-
# Validate
|
| 39 |
-
validate_puzzle(puzzle, grid_rows=puzzle.grid_rows, grid_cols=puzzle.grid_cols)
|
| 40 |
-
print("✓ Puzzle validation passed!")
|
| 41 |
-
print()
|
| 42 |
-
|
| 43 |
-
return True
|
| 44 |
-
except Exception as e:
|
| 45 |
-
print(f"✗ Test failed: {e}")
|
| 46 |
-
import traceback
|
| 47 |
-
traceback.print_exc()
|
| 48 |
-
return False
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
def test_with_seed():
|
| 52 |
-
"""Test deterministic generation with seed."""
|
| 53 |
-
print("Test 2: Deterministic generation with seed")
|
| 54 |
-
print("=" * 60)
|
| 55 |
-
|
| 56 |
-
try:
|
| 57 |
-
puzzle1 = generate_puzzle(seed=12345)
|
| 58 |
-
puzzle2 = generate_puzzle(seed=12345)
|
| 59 |
-
|
| 60 |
-
# Should generate same words
|
| 61 |
-
words1 = [w.text for w in puzzle1.words]
|
| 62 |
-
words2 = [w.text for w in puzzle2.words]
|
| 63 |
-
|
| 64 |
-
if words1 == words2:
|
| 65 |
-
print(f"✓ Deterministic generation works!")
|
| 66 |
-
print(f" Both puzzles have words: {words1}")
|
| 67 |
-
print()
|
| 68 |
-
return True
|
| 69 |
-
else:
|
| 70 |
-
print(f"✗ Deterministic generation failed!")
|
| 71 |
-
print(f" Puzzle 1: {words1}")
|
| 72 |
-
print(f" Puzzle 2: {words2}")
|
| 73 |
-
print()
|
| 74 |
-
return False
|
| 75 |
-
except Exception as e:
|
| 76 |
-
print(f"✗ Test failed: {e}")
|
| 77 |
-
import traceback
|
| 78 |
-
traceback.print_exc()
|
| 79 |
-
return False
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
def test_target_words():
|
| 83 |
-
"""Test generation with specific target words."""
|
| 84 |
-
print("Test 3: Generation with target words")
|
| 85 |
-
print("=" * 60)
|
| 86 |
-
|
| 87 |
-
target_words = ["CAT", "DOGS", "BIRDS", "FISH", "MONKEY", "ELEPHANT"]
|
| 88 |
-
|
| 89 |
-
try:
|
| 90 |
-
puzzle = generate_puzzle(target_words=target_words)
|
| 91 |
-
generated_words = sorted([w.text for w in puzzle.words])
|
| 92 |
-
expected_words = sorted([w.upper() for w in target_words])
|
| 93 |
-
|
| 94 |
-
if generated_words == expected_words:
|
| 95 |
-
print(f"✓ Target words generation works!")
|
| 96 |
-
print(f" Generated: {generated_words}")
|
| 97 |
-
print()
|
| 98 |
-
return True
|
| 99 |
-
else:
|
| 100 |
-
print(f"✗ Target words generation failed!")
|
| 101 |
-
print(f" Expected: {expected_words}")
|
| 102 |
-
print(f" Got: {generated_words}")
|
| 103 |
-
print()
|
| 104 |
-
return False
|
| 105 |
-
except Exception as e:
|
| 106 |
-
print(f"✗ Test failed: {e}")
|
| 107 |
-
import traceback
|
| 108 |
-
traceback.print_exc()
|
| 109 |
-
return False
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
def test_grid_visualization():
|
| 113 |
-
"""Visualize a generated puzzle on the grid."""
|
| 114 |
-
print("Test 4: Grid visualization")
|
| 115 |
-
print("=" * 60)
|
| 116 |
-
|
| 117 |
-
try:
|
| 118 |
-
puzzle = generate_puzzle(seed=42)
|
| 119 |
-
|
| 120 |
-
# Create 6x8 grid
|
| 121 |
-
grid = [[' ' for _ in range(puzzle.grid_cols)] for _ in range(puzzle.grid_rows)]
|
| 122 |
-
|
| 123 |
-
# Fill in words
|
| 124 |
-
for word in puzzle.words:
|
| 125 |
-
for i, cell in enumerate(word.cells):
|
| 126 |
-
grid[cell.x][cell.y] = word.text[i]
|
| 127 |
-
|
| 128 |
-
# Print grid
|
| 129 |
-
print("Grid visualization:")
|
| 130 |
-
print(" " + "+-" * puzzle.grid_cols + "+")
|
| 131 |
-
for row_idx, row in enumerate(grid):
|
| 132 |
-
print(f"{row_idx} |" + "|".join(row) + "|")
|
| 133 |
-
print(" " + "+-" * puzzle.grid_cols + "+")
|
| 134 |
-
print(" " + "".join(str(i) for i in range(puzzle.grid_cols)))
|
| 135 |
-
print()
|
| 136 |
-
|
| 137 |
-
print("✓ Grid visualization complete!")
|
| 138 |
-
print()
|
| 139 |
-
return True
|
| 140 |
-
except Exception as e:
|
| 141 |
-
print(f"✗ Test failed: {e}")
|
| 142 |
-
import traceback
|
| 143 |
-
traceback.print_exc()
|
| 144 |
-
return False
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
def test_validation_checks():
|
| 148 |
-
"""Test that validation catches errors."""
|
| 149 |
-
print("Test 5: Validation error detection")
|
| 150 |
-
print("=" * 60)
|
| 151 |
-
|
| 152 |
-
# This should work
|
| 153 |
-
try:
|
| 154 |
-
puzzle = generate_puzzle()
|
| 155 |
-
validate_puzzle(puzzle, grid_rows=6, grid_cols=8)
|
| 156 |
-
print("✓ Valid puzzle passes validation")
|
| 157 |
-
except AssertionError as e:
|
| 158 |
-
print(f"✗ Valid puzzle failed validation: {e}")
|
| 159 |
-
return False
|
| 160 |
-
|
| 161 |
-
# Test row count enforcement
|
| 162 |
-
try:
|
| 163 |
-
from wrdler.models import Word, Coord, Puzzle
|
| 164 |
-
|
| 165 |
-
# Try to create puzzle with wrong number of rows
|
| 166 |
-
words = [
|
| 167 |
-
Word("CAT", Coord(0, 0), "H"),
|
| 168 |
-
Word("DOG", Coord(1, 0), "H"),
|
| 169 |
-
Word("RAT", Coord(2, 0), "H"),
|
| 170 |
-
]
|
| 171 |
-
bad_puzzle = Puzzle(words=words, grid_rows=6, grid_cols=8)
|
| 172 |
-
|
| 173 |
-
try:
|
| 174 |
-
validate_puzzle(bad_puzzle, grid_rows=6, grid_cols=8)
|
| 175 |
-
print("✗ Validation should have failed for 3 words (expected 6)")
|
| 176 |
-
return False
|
| 177 |
-
except AssertionError:
|
| 178 |
-
print("✓ Validation correctly rejects wrong word count")
|
| 179 |
-
except Exception as e:
|
| 180 |
-
print(f"✗ Test error: {e}")
|
| 181 |
-
return False
|
| 182 |
-
|
| 183 |
-
print()
|
| 184 |
-
return True
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
def main():
|
| 188 |
-
"""Run all tests."""
|
| 189 |
-
print("\n" + "=" * 60)
|
| 190 |
-
print("WRDLER PUZZLE GENERATOR TESTS")
|
| 191 |
-
print("=" * 60)
|
| 192 |
-
print()
|
| 193 |
-
|
| 194 |
-
tests = [
|
| 195 |
-
test_basic_generation,
|
| 196 |
-
test_with_seed,
|
| 197 |
-
test_target_words,
|
| 198 |
-
test_grid_visualization,
|
| 199 |
-
test_validation_checks,
|
| 200 |
-
]
|
| 201 |
-
|
| 202 |
-
results = []
|
| 203 |
-
for test_func in tests:
|
| 204 |
-
result = test_func()
|
| 205 |
-
results.append((test_func.__name__, result))
|
| 206 |
-
|
| 207 |
-
# Summary
|
| 208 |
-
print("=" * 60)
|
| 209 |
-
print("TEST SUMMARY")
|
| 210 |
-
print("=" * 60)
|
| 211 |
-
for name, passed in results:
|
| 212 |
-
status = "✓ PASS" if passed else "✗ FAIL"
|
| 213 |
-
print(f"{status}: {name}")
|
| 214 |
-
|
| 215 |
-
total = len(results)
|
| 216 |
-
passed = sum(1 for _, p in results if p)
|
| 217 |
-
print()
|
| 218 |
-
print(f"Results: {passed}/{total} tests passed")
|
| 219 |
-
print("=" * 60)
|
| 220 |
-
|
| 221 |
-
return all(p for _, p in results)
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
if __name__ == "__main__":
|
| 225 |
-
import sys
|
| 226 |
-
success = main()
|
| 227 |
-
sys.exit(0 if success else 1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_logic.py
DELETED
|
@@ -1,55 +0,0 @@
|
|
| 1 |
-
import unittest
|
| 2 |
-
|
| 3 |
-
from wrdler.logic import build_letter_map, reveal_cell, guess_word, is_game_over
|
| 4 |
-
from wrdler.models import Coord, Word, Puzzle, GameState
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
class TestLogic(unittest.TestCase):
|
| 8 |
-
def make_state(self):
|
| 9 |
-
w1 = Word("TREE", Coord(0, 0), "H")
|
| 10 |
-
w2 = Word("APPLE", Coord(2, 0), "H")
|
| 11 |
-
w3 = Word("ORANGE", Coord(4, 0), "H")
|
| 12 |
-
w4 = Word("WIND", Coord(0, 6), "V")
|
| 13 |
-
w5 = Word("MOUSE", Coord(0, 8), "V")
|
| 14 |
-
w6 = Word("PYTHON", Coord(0, 10), "V")
|
| 15 |
-
p = Puzzle([w1, w2, w3, w4, w5, w6])
|
| 16 |
-
state = GameState(
|
| 17 |
-
grid_size=12,
|
| 18 |
-
puzzle=p,
|
| 19 |
-
revealed=set(),
|
| 20 |
-
guessed=set(),
|
| 21 |
-
score=0,
|
| 22 |
-
last_action="",
|
| 23 |
-
can_guess=False,
|
| 24 |
-
)
|
| 25 |
-
return state, p
|
| 26 |
-
|
| 27 |
-
def test_reveal_and_guess_gating(self):
|
| 28 |
-
state, puzzle = self.make_state()
|
| 29 |
-
letter_map = build_letter_map(puzzle)
|
| 30 |
-
# Can't guess before reveal
|
| 31 |
-
ok, pts = guess_word(state, "TREE")
|
| 32 |
-
self.assertFalse(ok)
|
| 33 |
-
self.assertEqual(pts, 0)
|
| 34 |
-
# Reveal one cell then guess
|
| 35 |
-
reveal_cell(state, letter_map, Coord(0, 0))
|
| 36 |
-
self.assertTrue(state.can_guess)
|
| 37 |
-
ok, pts = guess_word(state, "TREE")
|
| 38 |
-
self.assertTrue(ok)
|
| 39 |
-
self.assertGreater(pts, 0)
|
| 40 |
-
self.assertIn("TREE", state.guessed)
|
| 41 |
-
self.assertFalse(state.can_guess)
|
| 42 |
-
|
| 43 |
-
def test_game_over(self):
|
| 44 |
-
state, puzzle = self.make_state()
|
| 45 |
-
letter_map = build_letter_map(puzzle)
|
| 46 |
-
# Guess all words after a reveal each time
|
| 47 |
-
for w in puzzle.words:
|
| 48 |
-
reveal_cell(state, letter_map, w.start)
|
| 49 |
-
ok, _ = guess_word(state, w.text)
|
| 50 |
-
self.assertTrue(ok)
|
| 51 |
-
self.assertTrue(is_game_over(state))
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
if __name__ == "__main__":
|
| 55 |
-
unittest.main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_models_rect.py
DELETED
|
@@ -1,110 +0,0 @@
|
|
| 1 |
-
# file: tests/test_models_rect.py
|
| 2 |
-
"""Unit tests for Wrdler rectangular grid support in models.py"""
|
| 3 |
-
import pytest
|
| 4 |
-
from wrdler.models import Coord, Word, Puzzle, GameState
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
class TestCoordRectangular:
|
| 8 |
-
"""Tests for Coord.in_bounds_rect() method"""
|
| 9 |
-
|
| 10 |
-
def test_in_bounds_rect_valid_coords(self):
|
| 11 |
-
"""Test that valid coordinates pass boundary check"""
|
| 12 |
-
# 6 rows x 8 cols (Wrdler grid)
|
| 13 |
-
assert Coord(0, 0).in_bounds_rect(6, 8) is True
|
| 14 |
-
assert Coord(5, 7).in_bounds_rect(6, 8) is True # Bottom-right corner
|
| 15 |
-
assert Coord(3, 4).in_bounds_rect(6, 8) is True # Middle
|
| 16 |
-
|
| 17 |
-
def test_in_bounds_rect_invalid_coords(self):
|
| 18 |
-
"""Test that out-of-bounds coordinates fail boundary check"""
|
| 19 |
-
# 6 rows x 8 cols
|
| 20 |
-
assert Coord(6, 0).in_bounds_rect(6, 8) is False # Row too large
|
| 21 |
-
assert Coord(0, 8).in_bounds_rect(6, 8) is False # Col too large
|
| 22 |
-
assert Coord(-1, 0).in_bounds_rect(6, 8) is False # Negative row
|
| 23 |
-
assert Coord(0, -1).in_bounds_rect(6, 8) is False # Negative col
|
| 24 |
-
|
| 25 |
-
def test_in_bounds_backward_compatibility(self):
|
| 26 |
-
"""Test that legacy in_bounds() still works for square grids"""
|
| 27 |
-
assert Coord(0, 0).in_bounds(12) is True
|
| 28 |
-
assert Coord(11, 11).in_bounds(12) is True
|
| 29 |
-
assert Coord(12, 0).in_bounds(12) is False
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
class TestPuzzleRectangular:
|
| 33 |
-
"""Tests for Puzzle with rectangular grid dimensions"""
|
| 34 |
-
|
| 35 |
-
def test_puzzle_default_dimensions(self):
|
| 36 |
-
"""Test that Puzzle defaults to Wrdler dimensions (6x8)"""
|
| 37 |
-
puzzle = Puzzle(words=[])
|
| 38 |
-
assert puzzle.grid_rows == 6
|
| 39 |
-
assert puzzle.grid_cols == 8
|
| 40 |
-
|
| 41 |
-
def test_puzzle_custom_dimensions(self):
|
| 42 |
-
"""Test that Puzzle can use custom dimensions"""
|
| 43 |
-
puzzle = Puzzle(words=[], grid_rows=10, grid_cols=12)
|
| 44 |
-
assert puzzle.grid_rows == 10
|
| 45 |
-
assert puzzle.grid_cols == 12
|
| 46 |
-
|
| 47 |
-
def test_puzzle_no_radar_field(self):
|
| 48 |
-
"""Test that radar field is removed from Puzzle"""
|
| 49 |
-
puzzle = Puzzle(words=[])
|
| 50 |
-
assert not hasattr(puzzle, 'radar')
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
class TestGameStateRectangular:
|
| 54 |
-
"""Tests for GameState with rectangular grid support"""
|
| 55 |
-
|
| 56 |
-
def test_gamestate_default_dimensions(self):
|
| 57 |
-
"""Test that GameState defaults to Wrdler dimensions (6x8)"""
|
| 58 |
-
state = GameState()
|
| 59 |
-
assert state.grid_rows == 6
|
| 60 |
-
assert state.grid_cols == 8
|
| 61 |
-
|
| 62 |
-
def test_gamestate_free_letters_field(self):
|
| 63 |
-
"""Test that free_letters field exists and is initialized"""
|
| 64 |
-
state = GameState()
|
| 65 |
-
assert hasattr(state, 'free_letters')
|
| 66 |
-
assert isinstance(state.free_letters, set)
|
| 67 |
-
assert len(state.free_letters) == 0
|
| 68 |
-
|
| 69 |
-
def test_gamestate_free_letters_used_field(self):
|
| 70 |
-
"""Test that free_letters_used field exists and is initialized to 0"""
|
| 71 |
-
state = GameState()
|
| 72 |
-
assert hasattr(state, 'free_letters_used')
|
| 73 |
-
assert state.free_letters_used == 0
|
| 74 |
-
|
| 75 |
-
def test_gamestate_grid_size_property_square(self):
|
| 76 |
-
"""Test grid_size property works for square grids"""
|
| 77 |
-
state = GameState(grid_rows=12, grid_cols=12)
|
| 78 |
-
assert state.grid_size == 12
|
| 79 |
-
|
| 80 |
-
def test_gamestate_grid_size_property_rectangular_raises(self):
|
| 81 |
-
"""Test grid_size property raises ValueError for rectangular grids"""
|
| 82 |
-
state = GameState(grid_rows=6, grid_cols=8)
|
| 83 |
-
with pytest.raises(ValueError, match="grid_size not applicable"):
|
| 84 |
-
_ = state.grid_size
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
class TestWordHorizontalOnly:
|
| 88 |
-
"""Tests for Word with horizontal-only placement (Wrdler requirement)"""
|
| 89 |
-
|
| 90 |
-
def test_word_horizontal_valid(self):
|
| 91 |
-
"""Test that horizontal words are created correctly"""
|
| 92 |
-
word = Word(text="WORD", start=Coord(0, 0), direction="H")
|
| 93 |
-
assert word.direction == "H"
|
| 94 |
-
assert len(word.cells) == 4
|
| 95 |
-
assert word.cells[0] == Coord(0, 0)
|
| 96 |
-
assert word.cells[3] == Coord(0, 3)
|
| 97 |
-
|
| 98 |
-
def test_word_horizontal_fits_in_8_cols(self):
|
| 99 |
-
"""Test that words fit within 8-column grid"""
|
| 100 |
-
# Max length word (8 letters) starting at column 0
|
| 101 |
-
word = Word(text="TESTWORD", start=Coord(0, 0), direction="H")
|
| 102 |
-
assert all(c.in_bounds_rect(6, 8) for c in word.cells)
|
| 103 |
-
|
| 104 |
-
# 4-letter word starting at column 4 (last cell at column 7)
|
| 105 |
-
word = Word(text="TEST", start=Coord(0, 4), direction="H")
|
| 106 |
-
assert all(c.in_bounds_rect(6, 8) for c in word.cells)
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
if __name__ == "__main__":
|
| 110 |
-
pytest.main([__file__, "-v"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_word_distribution.py
DELETED
|
@@ -1,38 +0,0 @@
|
|
| 1 |
-
#!/usr/bin/env python3
|
| 2 |
-
"""Quick test to verify word length distribution in generated puzzles."""
|
| 3 |
-
|
| 4 |
-
from wrdler.generator import generate_puzzle
|
| 5 |
-
from wrdler.word_loader import load_word_list
|
| 6 |
-
|
| 7 |
-
def test_word_distribution():
|
| 8 |
-
"""Test that puzzles have 2 four-letter, 2 five-letter, and 2 six-letter words."""
|
| 9 |
-
print("Testing word distribution in generated puzzles...")
|
| 10 |
-
|
| 11 |
-
# Load word list
|
| 12 |
-
words_by_len = load_word_list()
|
| 13 |
-
print(f"Loaded words: {len(words_by_len[4])} 4-letter, {len(words_by_len[5])} 5-letter, {len(words_by_len[6])} 6-letter")
|
| 14 |
-
|
| 15 |
-
# Generate 5 test puzzles
|
| 16 |
-
for i in range(5):
|
| 17 |
-
puzzle = generate_puzzle(grid_rows=6, grid_cols=8, words_by_len=words_by_len)
|
| 18 |
-
|
| 19 |
-
# Count word lengths
|
| 20 |
-
length_counts = {4: 0, 5: 0, 6: 0}
|
| 21 |
-
for word in puzzle.words:
|
| 22 |
-
length = len(word.text)
|
| 23 |
-
if length in length_counts:
|
| 24 |
-
length_counts[length] += 1
|
| 25 |
-
|
| 26 |
-
# Verify distribution
|
| 27 |
-
assert length_counts[4] == 2, f"Puzzle {i+1}: Expected 2 four-letter words, got {length_counts[4]}"
|
| 28 |
-
assert length_counts[5] == 2, f"Puzzle {i+1}: Expected 2 five-letter words, got {length_counts[5]}"
|
| 29 |
-
assert length_counts[6] == 2, f"Puzzle {i+1}: Expected 2 six-letter words, got {length_counts[6]}"
|
| 30 |
-
|
| 31 |
-
# Print puzzle info
|
| 32 |
-
words_str = ", ".join([f"{w.text}({len(w.text)})" for w in puzzle.words])
|
| 33 |
-
print(f"✓ Puzzle {i+1}: {words_str}")
|
| 34 |
-
|
| 35 |
-
print("\n✅ All tests passed! Word distribution is correct.")
|
| 36 |
-
|
| 37 |
-
if __name__ == "__main__":
|
| 38 |
-
test_word_distribution()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
tests/test_word_file_validation.py
DELETED
|
@@ -1,67 +0,0 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Test validation of word files for MIN_REQUIRED threshold compliance.
|
| 3 |
-
"""
|
| 4 |
-
|
| 5 |
-
import os
|
| 6 |
-
import tempfile
|
| 7 |
-
import shutil
|
| 8 |
-
from wrdler.word_loader_ai import _save_ai_words_to_file
|
| 9 |
-
from wrdler.word_loader import MIN_REQUIRED
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
def test_save_ai_words_validates_min_required():
|
| 13 |
-
"""Test that _save_ai_words_to_file returns insufficiency info."""
|
| 14 |
-
# Create a temporary directory for test files
|
| 15 |
-
test_dir = tempfile.mkdtemp()
|
| 16 |
-
|
| 17 |
-
try:
|
| 18 |
-
# Mock the words directory to point to our temp dir
|
| 19 |
-
import wrdler.word_loader_ai as wl_ai
|
| 20 |
-
original_dirname = wl_ai.os.path.dirname
|
| 21 |
-
|
| 22 |
-
def mock_dirname(path):
|
| 23 |
-
if "word_loader_ai.py" in path:
|
| 24 |
-
return test_dir
|
| 25 |
-
return original_dirname(path)
|
| 26 |
-
|
| 27 |
-
wl_ai.os.path.dirname = mock_dirname
|
| 28 |
-
|
| 29 |
-
# Test case 1: Insufficient words (should return non-empty dict)
|
| 30 |
-
insufficient_words = [
|
| 31 |
-
"COOK", "BAKE", "HEAT", # 3 x 4-letter (need 25)
|
| 32 |
-
"ROAST", "GRILL", "STEAM", # 3 x 5-letter (need 25)
|
| 33 |
-
"SIMMER", "BRAISE", # 2 x 6-letter (need 25)
|
| 34 |
-
]
|
| 35 |
-
|
| 36 |
-
filename, insufficient = _save_ai_words_to_file("test_topic", insufficient_words)
|
| 37 |
-
|
| 38 |
-
assert filename == "test_topic.txt", f"Expected 'test_topic.txt', got '{filename}'"
|
| 39 |
-
assert len(insufficient) > 0, "Expected insufficient_lengths to be non-empty"
|
| 40 |
-
assert 4 in insufficient, "Expected 4-letter words to be insufficient"
|
| 41 |
-
assert 5 in insufficient, "Expected 5-letter words to be insufficient"
|
| 42 |
-
assert 6 in insufficient, "Expected 6-letter words to be insufficient"
|
| 43 |
-
|
| 44 |
-
# Test case 2: Sufficient words (should return empty dict)
|
| 45 |
-
sufficient_words = []
|
| 46 |
-
for length in [4, 5, 6]:
|
| 47 |
-
for i in range(MIN_REQUIRED):
|
| 48 |
-
# Generate unique words of the required length
|
| 49 |
-
word = chr(65 + (i % 26)) * length + str(i).zfill(length - 1)
|
| 50 |
-
sufficient_words.append(word[:length].upper())
|
| 51 |
-
|
| 52 |
-
filename2, insufficient2 = _save_ai_words_to_file("test_sufficient", sufficient_words)
|
| 53 |
-
|
| 54 |
-
assert filename2 == "test_sufficient.txt", f"Expected 'test_sufficient.txt', got '{filename2}'"
|
| 55 |
-
assert len(insufficient2) == 0, f"Expected empty insufficient_lengths, got {insufficient2}"
|
| 56 |
-
|
| 57 |
-
print("? All validation tests passed!")
|
| 58 |
-
|
| 59 |
-
finally:
|
| 60 |
-
# Restore original dirname
|
| 61 |
-
wl_ai.os.path.dirname = original_dirname
|
| 62 |
-
# Clean up temp directory
|
| 63 |
-
shutil.rmtree(test_dir, ignore_errors=True)
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
if __name__ == "__main__":
|
| 67 |
-
test_save_ai_words_validates_min_required()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
wrdler/__init__.py
CHANGED
|
@@ -8,5 +8,5 @@ Key differences from BattleWords:
|
|
| 8 |
- 2 free letter guesses at game start
|
| 9 |
"""
|
| 10 |
|
| 11 |
-
__version__ = "0.1.
|
| 12 |
__all__ = ["models", "generator", "logic", "ui", "word_loader"]
|
|
|
|
| 8 |
- 2 free letter guesses at game start
|
| 9 |
"""
|
| 10 |
|
| 11 |
+
__version__ = "0.1.5"
|
| 12 |
__all__ = ["models", "generator", "logic", "ui", "word_loader"]
|
wrdler/audio.py
CHANGED
|
@@ -1,47 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import os
|
| 2 |
from typing import Optional
|
| 3 |
import streamlit as st
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
return []
|
| 16 |
-
tracks = []
|
| 17 |
-
for fname in os.listdir(audio_dir):
|
| 18 |
-
if fname.lower().endswith('.mp3'):
|
| 19 |
-
path = os.path.join(audio_dir, fname)
|
| 20 |
-
# Use the filename without extension as the display name
|
| 21 |
-
name = os.path.splitext(fname)[0]
|
| 22 |
-
tracks.append((name, path))
|
| 23 |
-
return tracks
|
| 24 |
|
| 25 |
@st.cache_data(show_spinner=False)
|
| 26 |
def _load_audio_data_url(path: str) -> str:
|
| 27 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
import base64, mimetypes
|
| 29 |
mime, _ = mimetypes.guess_type(path)
|
| 30 |
if not mime:
|
| 31 |
-
# Default to mp3 to avoid blocked playback if unknown
|
| 32 |
mime = "audio/mpeg"
|
| 33 |
with open(path, "rb") as fp:
|
| 34 |
encoded = base64.b64encode(fp.read()).decode("ascii")
|
| 35 |
return f"data:{mime};base64,{encoded}"
|
| 36 |
|
|
|
|
| 37 |
def _mount_background_audio(enabled: bool, src_data_url: Optional[str], volume: float, loop: bool = True) -> None:
|
| 38 |
-
"""
|
|
|
|
| 39 |
|
| 40 |
Args:
|
| 41 |
-
enabled: Whether the background audio should be active
|
| 42 |
-
src_data_url: data
|
| 43 |
-
volume: 0.0
|
| 44 |
-
loop: Whether the audio should loop (default True)
|
| 45 |
"""
|
| 46 |
from streamlit.components.v1 import html as _html
|
| 47 |
|
|
@@ -60,11 +69,9 @@ def _mount_background_audio(enabled: bool, src_data_url: Optional[str], volume:
|
|
| 60 |
)
|
| 61 |
return
|
| 62 |
|
| 63 |
-
|
| 64 |
-
vol = max(0.0, min(1.0, float(volume)))
|
| 65 |
should_loop = "true" if loop else "false"
|
| 66 |
|
| 67 |
-
# Inject or update a single persistent audio element and make sure it starts after interaction if autoplay is blocked
|
| 68 |
_html(
|
| 69 |
f"""
|
| 70 |
<script>
|
|
@@ -78,7 +85,6 @@ def _mount_background_audio(enabled: bool, src_data_url: Optional[str], volume:
|
|
| 78 |
doc.body.appendChild(audio);
|
| 79 |
}}
|
| 80 |
|
| 81 |
-
// Ensure loop is explicitly set every time, even if element already exists
|
| 82 |
const shouldLoop = {should_loop};
|
| 83 |
audio.loop = shouldLoop;
|
| 84 |
if (shouldLoop) {{
|
|
@@ -98,14 +104,11 @@ def _mount_background_audio(enabled: bool, src_data_url: Optional[str], volume:
|
|
| 98 |
|
| 99 |
const tryPlay = () => {{
|
| 100 |
const p = audio.play();
|
| 101 |
-
if (p && p.catch) {{ p.catch(() => {{
|
| 102 |
}};
|
| 103 |
tryPlay();
|
| 104 |
|
| 105 |
-
const unlock = () => {{
|
| 106 |
-
tryPlay();
|
| 107 |
-
}};
|
| 108 |
-
// Add once-only listeners to resume playback after first user interaction
|
| 109 |
doc.addEventListener('pointerdown', unlock, {{ once: true }});
|
| 110 |
doc.addEventListener('keydown', unlock, {{ once: true }});
|
| 111 |
doc.addEventListener('touchstart', unlock, {{ once: true }});
|
|
@@ -115,6 +118,7 @@ def _mount_background_audio(enabled: bool, src_data_url: Optional[str], volume:
|
|
| 115 |
height=0,
|
| 116 |
)
|
| 117 |
|
|
|
|
| 118 |
def _inject_audio_control_sync():
|
| 119 |
"""Inject JS to sync volume and enable/disable state immediately."""
|
| 120 |
from streamlit.components.v1 import html as _html
|
|
@@ -125,14 +129,12 @@ def _inject_audio_control_sync():
|
|
| 125 |
const doc = window.parent?.document || document;
|
| 126 |
const audio = doc.getElementById('bw-bg-audio');
|
| 127 |
if (!audio) return;
|
| 128 |
-
// Get values from Streamlit DOM
|
| 129 |
const volInput = doc.querySelector('input[type="range"][aria-label="Volume"]');
|
| 130 |
const enableInput = doc.querySelector('input[type="checkbox"][aria-label="Enable music"]');
|
| 131 |
if (volInput) {
|
| 132 |
volInput.addEventListener('input', function(){
|
| 133 |
audio.volume = parseFloat(this.value)/100;
|
| 134 |
});
|
| 135 |
-
// Set initial volume
|
| 136 |
audio.volume = parseFloat(volInput.value)/100;
|
| 137 |
}
|
| 138 |
if (enableInput) {
|
|
@@ -145,7 +147,6 @@ def _inject_audio_control_sync():
|
|
| 145 |
audio.pause();
|
| 146 |
}
|
| 147 |
});
|
| 148 |
-
// Set initial mute state
|
| 149 |
if (enableInput.checked) {
|
| 150 |
audio.muted = false;
|
| 151 |
audio.play().catch(()=>{});
|
|
@@ -160,43 +161,10 @@ def _inject_audio_control_sync():
|
|
| 160 |
height=0,
|
| 161 |
)
|
| 162 |
|
| 163 |
-
# Sound effects functionality
|
| 164 |
-
def get_sound_effect_files() -> dict[str, str]:
|
| 165 |
-
"""
|
| 166 |
-
Return dictionary of sound effect name -> absolute path.
|
| 167 |
-
Prefers .mp3 files; falls back to .wav if no .mp3 is found.
|
| 168 |
-
"""
|
| 169 |
-
audio_dir = _get_effects_dir()
|
| 170 |
-
if not os.path.isdir(audio_dir):
|
| 171 |
-
return {}
|
| 172 |
-
|
| 173 |
-
effect_names = [
|
| 174 |
-
"correct_guess",
|
| 175 |
-
"incorrect_guess",
|
| 176 |
-
"hit",
|
| 177 |
-
"miss",
|
| 178 |
-
"congratulations",
|
| 179 |
-
]
|
| 180 |
-
|
| 181 |
-
def _find_effect_file(base: str) -> Optional[str]:
|
| 182 |
-
# Prefer mp3, then wav for backward compatibility
|
| 183 |
-
for ext in (".mp3", ".wav"):
|
| 184 |
-
path = os.path.join(audio_dir, f"{base}{ext}")
|
| 185 |
-
if os.path.exists(path):
|
| 186 |
-
return path
|
| 187 |
-
return None
|
| 188 |
-
|
| 189 |
-
result: dict[str, str] = {}
|
| 190 |
-
for name in effect_names:
|
| 191 |
-
path = _find_effect_file(name)
|
| 192 |
-
if path:
|
| 193 |
-
result[name] = path
|
| 194 |
-
|
| 195 |
-
return result
|
| 196 |
|
| 197 |
def play_sound_effect(effect_name: str, volume: float = 0.5) -> None:
|
| 198 |
"""
|
| 199 |
-
Play a sound effect by name.
|
| 200 |
|
| 201 |
Args:
|
| 202 |
effect_name: One of 'correct_guess', 'incorrect_guess', 'hit', 'miss', 'congratulations'
|
|
@@ -212,17 +180,14 @@ def play_sound_effect(effect_name: str, volume: float = 0.5) -> None:
|
|
| 212 |
pass
|
| 213 |
|
| 214 |
sound_files = get_sound_effect_files()
|
| 215 |
-
|
| 216 |
if effect_name not in sound_files:
|
| 217 |
-
return
|
| 218 |
|
| 219 |
sound_path = sound_files[effect_name]
|
| 220 |
sound_data_url = _load_audio_data_url(sound_path)
|
| 221 |
|
| 222 |
-
|
| 223 |
-
vol = max(0.0, min(1.0, float(volume)))
|
| 224 |
|
| 225 |
-
# Play sound effect using a unique audio element
|
| 226 |
_html(
|
| 227 |
f"""
|
| 228 |
<script>
|
|
@@ -234,7 +199,6 @@ def play_sound_effect(effect_name: str, volume: float = 0.5) -> None:
|
|
| 234 |
audio.style.display = 'none';
|
| 235 |
doc.body.appendChild(audio);
|
| 236 |
|
| 237 |
-
// Play and remove after playback
|
| 238 |
audio.play().catch(e => console.error('Sound effect play error:', e));
|
| 239 |
audio.addEventListener('ended', () => {{
|
| 240 |
doc.body.removeChild(audio);
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Streamlit-specific audio utilities for Wrdler.
|
| 3 |
+
|
| 4 |
+
This module provides audio playback functionality for the Streamlit UI.
|
| 5 |
+
Uses base64 encoding for audio files since Streamlit doesn't serve arbitrary directories.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
import os
|
| 9 |
from typing import Optional
|
| 10 |
import streamlit as st
|
| 11 |
|
| 12 |
+
# Import shared core functions
|
| 13 |
+
from .audio_core import (
|
| 14 |
+
get_music_dir as _get_music_dir,
|
| 15 |
+
get_effects_dir as _get_effects_dir,
|
| 16 |
+
get_audio_tracks,
|
| 17 |
+
get_sound_effect_files,
|
| 18 |
+
get_background_music_path,
|
| 19 |
+
clamp_volume
|
| 20 |
+
)
|
| 21 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
@st.cache_data(show_spinner=False)
|
| 24 |
def _load_audio_data_url(path: str) -> str:
|
| 25 |
+
"""
|
| 26 |
+
Convert audio file to data URL for browser playback.
|
| 27 |
+
|
| 28 |
+
Streamlit requires base64 encoding since it doesn't serve arbitrary directories.
|
| 29 |
+
|
| 30 |
+
Args:
|
| 31 |
+
path: Absolute path to audio file
|
| 32 |
+
|
| 33 |
+
Returns:
|
| 34 |
+
Base64-encoded data URL
|
| 35 |
+
"""
|
| 36 |
import base64, mimetypes
|
| 37 |
mime, _ = mimetypes.guess_type(path)
|
| 38 |
if not mime:
|
|
|
|
| 39 |
mime = "audio/mpeg"
|
| 40 |
with open(path, "rb") as fp:
|
| 41 |
encoded = base64.b64encode(fp.read()).decode("ascii")
|
| 42 |
return f"data:{mime};base64,{encoded}"
|
| 43 |
|
| 44 |
+
|
| 45 |
def _mount_background_audio(enabled: bool, src_data_url: Optional[str], volume: float, loop: bool = True) -> None:
|
| 46 |
+
"""
|
| 47 |
+
Create/update a single hidden <audio> element in the page.
|
| 48 |
|
| 49 |
Args:
|
| 50 |
+
enabled: Whether the background audio should be active
|
| 51 |
+
src_data_url: Base64 data URL for the audio source
|
| 52 |
+
volume: Volume level (0.0 to 1.0)
|
| 53 |
+
loop: Whether the audio should loop (default True)
|
| 54 |
"""
|
| 55 |
from streamlit.components.v1 import html as _html
|
| 56 |
|
|
|
|
| 69 |
)
|
| 70 |
return
|
| 71 |
|
| 72 |
+
vol = clamp_volume(volume)
|
|
|
|
| 73 |
should_loop = "true" if loop else "false"
|
| 74 |
|
|
|
|
| 75 |
_html(
|
| 76 |
f"""
|
| 77 |
<script>
|
|
|
|
| 85 |
doc.body.appendChild(audio);
|
| 86 |
}}
|
| 87 |
|
|
|
|
| 88 |
const shouldLoop = {should_loop};
|
| 89 |
audio.loop = shouldLoop;
|
| 90 |
if (shouldLoop) {{
|
|
|
|
| 104 |
|
| 105 |
const tryPlay = () => {{
|
| 106 |
const p = audio.play();
|
| 107 |
+
if (p && p.catch) {{ p.catch(() => {{}}); }}
|
| 108 |
}};
|
| 109 |
tryPlay();
|
| 110 |
|
| 111 |
+
const unlock = () => {{ tryPlay(); }};
|
|
|
|
|
|
|
|
|
|
| 112 |
doc.addEventListener('pointerdown', unlock, {{ once: true }});
|
| 113 |
doc.addEventListener('keydown', unlock, {{ once: true }});
|
| 114 |
doc.addEventListener('touchstart', unlock, {{ once: true }});
|
|
|
|
| 118 |
height=0,
|
| 119 |
)
|
| 120 |
|
| 121 |
+
|
| 122 |
def _inject_audio_control_sync():
|
| 123 |
"""Inject JS to sync volume and enable/disable state immediately."""
|
| 124 |
from streamlit.components.v1 import html as _html
|
|
|
|
| 129 |
const doc = window.parent?.document || document;
|
| 130 |
const audio = doc.getElementById('bw-bg-audio');
|
| 131 |
if (!audio) return;
|
|
|
|
| 132 |
const volInput = doc.querySelector('input[type="range"][aria-label="Volume"]');
|
| 133 |
const enableInput = doc.querySelector('input[type="checkbox"][aria-label="Enable music"]');
|
| 134 |
if (volInput) {
|
| 135 |
volInput.addEventListener('input', function(){
|
| 136 |
audio.volume = parseFloat(this.value)/100;
|
| 137 |
});
|
|
|
|
| 138 |
audio.volume = parseFloat(volInput.value)/100;
|
| 139 |
}
|
| 140 |
if (enableInput) {
|
|
|
|
| 147 |
audio.pause();
|
| 148 |
}
|
| 149 |
});
|
|
|
|
| 150 |
if (enableInput.checked) {
|
| 151 |
audio.muted = false;
|
| 152 |
audio.play().catch(()=>{});
|
|
|
|
| 161 |
height=0,
|
| 162 |
)
|
| 163 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
|
| 165 |
def play_sound_effect(effect_name: str, volume: float = 0.5) -> None:
|
| 166 |
"""
|
| 167 |
+
Play a sound effect by name (Streamlit implementation).
|
| 168 |
|
| 169 |
Args:
|
| 170 |
effect_name: One of 'correct_guess', 'incorrect_guess', 'hit', 'miss', 'congratulations'
|
|
|
|
| 180 |
pass
|
| 181 |
|
| 182 |
sound_files = get_sound_effect_files()
|
|
|
|
| 183 |
if effect_name not in sound_files:
|
| 184 |
+
return
|
| 185 |
|
| 186 |
sound_path = sound_files[effect_name]
|
| 187 |
sound_data_url = _load_audio_data_url(sound_path)
|
| 188 |
|
| 189 |
+
vol = clamp_volume(volume)
|
|
|
|
| 190 |
|
|
|
|
| 191 |
_html(
|
| 192 |
f"""
|
| 193 |
<script>
|
|
|
|
| 199 |
audio.style.display = 'none';
|
| 200 |
doc.body.appendChild(audio);
|
| 201 |
|
|
|
|
| 202 |
audio.play().catch(e => console.error('Sound effect play error:', e));
|
| 203 |
audio.addEventListener('ended', () => {{
|
| 204 |
doc.body.removeChild(audio);
|
wrdler/audio_core.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Core audio utilities for Wrdler - Framework-agnostic.
|
| 3 |
+
|
| 4 |
+
This module provides shared audio functionality that works with both
|
| 5 |
+
Streamlit and Gradio interfaces. Framework-specific implementations
|
| 6 |
+
should import these functions and add their own rendering logic.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import os
|
| 10 |
+
from typing import Dict, List, Optional, Tuple
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def get_music_dir() -> str:
|
| 14 |
+
"""Get absolute path to music directory."""
|
| 15 |
+
return os.path.join(os.path.dirname(__file__), "assets", "audio", "music")
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def get_effects_dir() -> str:
|
| 19 |
+
"""Get absolute path to effects directory."""
|
| 20 |
+
return os.path.join(os.path.dirname(__file__), "assets", "audio", "effects")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def get_audio_tracks() -> List[Tuple[str, str]]:
|
| 24 |
+
"""
|
| 25 |
+
Get available music tracks.
|
| 26 |
+
|
| 27 |
+
Returns:
|
| 28 |
+
List of (track_name, absolute_path) tuples for .mp3 files
|
| 29 |
+
"""
|
| 30 |
+
audio_dir = get_music_dir()
|
| 31 |
+
if not os.path.isdir(audio_dir):
|
| 32 |
+
return []
|
| 33 |
+
|
| 34 |
+
tracks = []
|
| 35 |
+
for fname in os.listdir(audio_dir):
|
| 36 |
+
if fname.lower().endswith('.mp3'):
|
| 37 |
+
path = os.path.join(audio_dir, fname)
|
| 38 |
+
name = os.path.splitext(fname)[0]
|
| 39 |
+
tracks.append((name, path))
|
| 40 |
+
|
| 41 |
+
return sorted(tracks)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def get_sound_effect_files() -> Dict[str, str]:
|
| 45 |
+
"""
|
| 46 |
+
Get available sound effect files.
|
| 47 |
+
|
| 48 |
+
Returns:
|
| 49 |
+
Dictionary of effect_name -> absolute_path
|
| 50 |
+
Prefers .mp3 files, falls back to .wav
|
| 51 |
+
"""
|
| 52 |
+
audio_dir = get_effects_dir()
|
| 53 |
+
if not os.path.isdir(audio_dir):
|
| 54 |
+
return {}
|
| 55 |
+
|
| 56 |
+
effect_names = [
|
| 57 |
+
"correct_guess",
|
| 58 |
+
"incorrect_guess",
|
| 59 |
+
"hit",
|
| 60 |
+
"miss",
|
| 61 |
+
"congratulations",
|
| 62 |
+
]
|
| 63 |
+
|
| 64 |
+
def _find_effect_file(base: str) -> Optional[str]:
|
| 65 |
+
"""Find effect file, preferring .mp3 over .wav."""
|
| 66 |
+
for ext in (".mp3", ".wav"):
|
| 67 |
+
path = os.path.join(audio_dir, f"{base}{ext}")
|
| 68 |
+
if os.path.exists(path):
|
| 69 |
+
return path
|
| 70 |
+
return None
|
| 71 |
+
|
| 72 |
+
result: Dict[str, str] = {}
|
| 73 |
+
for name in effect_names:
|
| 74 |
+
path = _find_effect_file(name)
|
| 75 |
+
if path:
|
| 76 |
+
result[name] = path
|
| 77 |
+
|
| 78 |
+
return result
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def get_background_music_path(prefer_name: str = "background") -> Optional[str]:
|
| 82 |
+
"""
|
| 83 |
+
Get path to background music file.
|
| 84 |
+
|
| 85 |
+
Args:
|
| 86 |
+
prefer_name: Preferred track name (default: "background")
|
| 87 |
+
|
| 88 |
+
Returns:
|
| 89 |
+
Absolute path to music file, or None if no tracks available
|
| 90 |
+
"""
|
| 91 |
+
try:
|
| 92 |
+
tracks = get_audio_tracks()
|
| 93 |
+
print(f"[Audio Debug] get_audio_tracks() returned: {tracks}")
|
| 94 |
+
|
| 95 |
+
if not tracks:
|
| 96 |
+
print("[Audio Debug] No audio tracks found")
|
| 97 |
+
return None
|
| 98 |
+
|
| 99 |
+
# Try to find preferred track
|
| 100 |
+
for name, path in tracks:
|
| 101 |
+
if name.lower() == prefer_name.lower():
|
| 102 |
+
print(f"[Audio Debug] Found preferred track '{prefer_name}' at: {path}")
|
| 103 |
+
# Verify it's a file, not a directory
|
| 104 |
+
if not os.path.isfile(path):
|
| 105 |
+
print(f"[Audio Debug] Path is not a file: {path}")
|
| 106 |
+
continue
|
| 107 |
+
return path
|
| 108 |
+
|
| 109 |
+
# Fall back to first track
|
| 110 |
+
fallback_name, fallback_path = tracks[0]
|
| 111 |
+
print(f"[Audio Debug] Using fallback track '{fallback_name}' at: {fallback_path}")
|
| 112 |
+
|
| 113 |
+
# Verify it's a file, not a directory
|
| 114 |
+
if not os.path.isfile(fallback_path):
|
| 115 |
+
print(f"[Audio Debug] Fallback path is not a file: {fallback_path}")
|
| 116 |
+
return None
|
| 117 |
+
|
| 118 |
+
return fallback_path
|
| 119 |
+
except Exception as e:
|
| 120 |
+
print(f"[Audio Debug] Error in get_background_music_path: {e}")
|
| 121 |
+
import traceback
|
| 122 |
+
traceback.print_exc()
|
| 123 |
+
return None
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def clamp_volume(volume: float) -> float:
|
| 127 |
+
"""
|
| 128 |
+
Clamp volume to valid range [0.0, 1.0].
|
| 129 |
+
|
| 130 |
+
Args:
|
| 131 |
+
volume: Volume value to clamp
|
| 132 |
+
|
| 133 |
+
Returns:
|
| 134 |
+
Clamped volume in range [0.0, 1.0]
|
| 135 |
+
"""
|
| 136 |
+
return max(0.0, min(1.0, float(volume)))
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def get_audio_file_url(file_path: str) -> str:
|
| 140 |
+
"""
|
| 141 |
+
Convert local file path to Gradio-served URL.
|
| 142 |
+
|
| 143 |
+
Gradio's file serving endpoint is /gradio_api/file= with paths
|
| 144 |
+
relative to directories in allowed_paths.
|
| 145 |
+
|
| 146 |
+
Args:
|
| 147 |
+
file_path: Absolute path to audio file
|
| 148 |
+
|
| 149 |
+
Returns:
|
| 150 |
+
URL for Gradio's file serving endpoint with full relative path
|
| 151 |
+
"""
|
| 152 |
+
import os
|
| 153 |
+
|
| 154 |
+
# Get the project root (parent of wrdler package)
|
| 155 |
+
project_root = os.path.dirname(os.path.dirname(__file__))
|
| 156 |
+
|
| 157 |
+
# Get path relative to project root (website root)
|
| 158 |
+
try:
|
| 159 |
+
rel_path = os.path.relpath(file_path, project_root)
|
| 160 |
+
except ValueError:
|
| 161 |
+
# Fallback for Windows different drives
|
| 162 |
+
rel_path = os.path.basename(file_path)
|
| 163 |
+
|
| 164 |
+
# Normalize to forward slashes for URLs
|
| 165 |
+
url_path = rel_path.replace(os.sep, '/')
|
| 166 |
+
|
| 167 |
+
# Gradio's file serving endpoint
|
| 168 |
+
url = f"/gradio_api/file={url_path}"
|
| 169 |
+
|
| 170 |
+
print(f"[Audio URL] File: {file_path}")
|
| 171 |
+
print(f"[Audio URL] Project root: {project_root}")
|
| 172 |
+
print(f"[Audio URL] Relative path: {url_path}")
|
| 173 |
+
print(f"[Audio URL] Final URL: {url}")
|
| 174 |
+
|
| 175 |
+
return url
|
wrdler/gradio_ui.py
CHANGED
|
@@ -46,6 +46,30 @@ GRID_ROWS = 6
|
|
| 46 |
GRID_COLS = 8
|
| 47 |
MAX_FREE_LETTERS = 2
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
# ---------------------------------------------------------------------------
|
| 50 |
# Settings Loading
|
| 51 |
# ---------------------------------------------------------------------------
|
|
@@ -59,9 +83,9 @@ def load_settings() -> Dict[str, Any]:
|
|
| 59 |
"enable_free_letters": True,
|
| 60 |
"show_challenge_links": True,
|
| 61 |
"sound_effects_enabled": True,
|
| 62 |
-
"sound_effects_volume": 50
|
| 63 |
"music_enabled": False,
|
| 64 |
-
"music_volume": 30
|
| 65 |
"default_wordlist": "classic.txt",
|
| 66 |
"default_game_mode": "classic"
|
| 67 |
}
|
|
@@ -105,48 +129,31 @@ MIN_REQUIRED = 25
|
|
| 105 |
# Audio Utilities (Gradio-compatible)
|
| 106 |
# ---------------------------------------------------------------------------
|
| 107 |
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
audio_dir = _get_music_dir()
|
| 119 |
-
if not os.path.isdir(audio_dir):
|
| 120 |
-
return []
|
| 121 |
-
tracks = []
|
| 122 |
-
for fname in os.listdir(audio_dir):
|
| 123 |
-
if fname.lower().endswith('.mp3'):
|
| 124 |
-
path = os.path.join(audio_dir, fname)
|
| 125 |
-
name = os.path.splitext(fname)[0]
|
| 126 |
-
tracks.append((name, path))
|
| 127 |
-
return sorted(tracks)
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
def get_sound_effect_files() -> Dict[str, str]:
|
| 131 |
-
"""Return dict of effect_name -> path."""
|
| 132 |
-
audio_dir = _get_effects_dir()
|
| 133 |
-
if not os.path.isdir(audio_dir):
|
| 134 |
-
return {}
|
| 135 |
-
|
| 136 |
-
effect_names = ["correct_guess", "incorrect_guess", "hit", "miss", "congratulations"]
|
| 137 |
-
result = {}
|
| 138 |
-
for name in effect_names:
|
| 139 |
-
for ext in (".mp3", ".wav"):
|
| 140 |
-
path = os.path.join(audio_dir, f"{name}{ext}")
|
| 141 |
-
if os.path.exists(path):
|
| 142 |
-
result[name] = path
|
| 143 |
-
break
|
| 144 |
-
return result
|
| 145 |
|
| 146 |
|
| 147 |
@lru_cache(maxsize=32)
|
| 148 |
def load_audio_data_url(path: str) -> str:
|
| 149 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
mime, _ = mimetypes.guess_type(path)
|
| 151 |
if not mime:
|
| 152 |
mime = "audio/mpeg"
|
|
@@ -158,102 +165,72 @@ def load_audio_data_url(path: str) -> str:
|
|
| 158 |
return ""
|
| 159 |
|
| 160 |
|
| 161 |
-
def
|
| 162 |
-
"""
|
| 163 |
|
| 164 |
-
|
| 165 |
-
|
| 166 |
|
| 167 |
Args:
|
| 168 |
-
|
| 169 |
-
volume: Volume level (0.0 to 1.0)
|
| 170 |
|
| 171 |
Returns:
|
| 172 |
-
|
| 173 |
"""
|
| 174 |
-
|
| 175 |
-
tracks = get_audio_tracks()
|
| 176 |
-
if not tracks:
|
| 177 |
return ""
|
| 178 |
|
| 179 |
-
#
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
data_url = load_audio_data_url(music_path)
|
| 189 |
-
if not data_url:
|
| 190 |
-
return ""
|
| 191 |
-
|
| 192 |
-
vol = max(0.0, min(1.0, float(volume)))
|
| 193 |
-
|
| 194 |
-
if enabled:
|
| 195 |
-
return f'''
|
| 196 |
-
<script>
|
| 197 |
-
(function(){{
|
| 198 |
-
const doc = window.parent?.document || document;
|
| 199 |
-
let audio = doc.getElementById('wrdler-bg-music');
|
| 200 |
-
|
| 201 |
-
if (!audio) {{
|
| 202 |
-
audio = doc.createElement('audio');
|
| 203 |
-
audio.id = 'wrdler-bg-music';
|
| 204 |
-
audio.loop = true;
|
| 205 |
-
audio.style.display = 'none';
|
| 206 |
-
doc.body.appendChild(audio);
|
| 207 |
-
}}
|
| 208 |
-
|
| 209 |
-
const newSrc = "{data_url}";
|
| 210 |
-
if (audio.src !== newSrc) {{
|
| 211 |
-
audio.src = newSrc;
|
| 212 |
-
}}
|
| 213 |
|
| 214 |
-
|
| 215 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
if (p && p.catch) {{
|
| 220 |
-
p.catch(() => {{
|
| 221 |
-
console.log('Music autoplay blocked - waiting for user interaction');
|
| 222 |
-
console.log('Click anywhere on the page to start music');
|
| 223 |
-
}});
|
| 224 |
-
}}
|
| 225 |
-
}};
|
| 226 |
|
| 227 |
-
|
|
|
|
|
|
|
| 228 |
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
doc.removeEventListener('touchstart', unlock);
|
| 235 |
-
}};
|
| 236 |
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
|
|
|
| 257 |
|
| 258 |
|
| 259 |
def render_audio_player_html(
|
|
@@ -261,10 +238,10 @@ def render_audio_player_html(
|
|
| 261 |
volume: float = 0.5,
|
| 262 |
enabled: bool = True
|
| 263 |
) -> str:
|
| 264 |
-
"""
|
|
|
|
| 265 |
|
| 266 |
-
|
| 267 |
-
Sound effects are independent of background music.
|
| 268 |
|
| 269 |
Args:
|
| 270 |
effect_name: Name of the sound effect to play
|
|
@@ -272,55 +249,39 @@ def render_audio_player_html(
|
|
| 272 |
enabled: Whether sound effects are enabled
|
| 273 |
|
| 274 |
Returns:
|
| 275 |
-
HTML
|
| 276 |
"""
|
| 277 |
if not enabled or not effect_name:
|
| 278 |
-
return ""
|
| 279 |
-
|
| 280 |
-
sound_files = get_sound_effect_files()
|
| 281 |
-
if effect_name not in sound_files:
|
| 282 |
-
return ""
|
| 283 |
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
|
|
|
| 287 |
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
(
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
doc.body.removeChild(audio);
|
| 312 |
-
}}
|
| 313 |
-
}});
|
| 314 |
-
|
| 315 |
-
// Fallback cleanup after 10 seconds
|
| 316 |
-
setTimeout(() => {{
|
| 317 |
-
if (audio.parentNode) {{
|
| 318 |
-
doc.body.removeChild(audio);
|
| 319 |
-
}}
|
| 320 |
-
}}, 10000);
|
| 321 |
-
}})();
|
| 322 |
-
</script>
|
| 323 |
-
'''
|
| 324 |
|
| 325 |
|
| 326 |
# ---------------------------------------------------------------------------
|
|
@@ -789,7 +750,7 @@ def render_score_panel_html(state: Dict[str, Any]) -> str:
|
|
| 789 |
{incorrect_html}
|
| 790 |
<div class="score-header">Score: <span class="score-value">{score}</span></div>
|
| 791 |
{timer_html}
|
| 792 |
-
<table class='shiny-border' style
|
| 793 |
{table_inner}
|
| 794 |
</table>
|
| 795 |
</div>
|
|
@@ -802,7 +763,7 @@ def render_pending_audio_html(state: Dict[str, Any]) -> str:
|
|
| 802 |
"""Render audio HTML for any pending sound effect."""
|
| 803 |
pending = state.get("pending_sound")
|
| 804 |
if not pending:
|
| 805 |
-
return ""
|
| 806 |
|
| 807 |
enabled = state.get("sound_effects_enabled", True)
|
| 808 |
volume = state.get("sound_effects_volume", 50) / 100.0
|
|
@@ -831,7 +792,7 @@ def render_challenge_leaderboard_html(state: Dict[str, Any]) -> str:
|
|
| 831 |
]
|
| 832 |
|
| 833 |
# Sort by score desc, then time asc
|
| 834 |
-
sorted_lb = sorted(leaderboard, key=lambda x: (-x.get("score", 0), x.get("time", 9999)))
|
| 835 |
|
| 836 |
medals = ["🥇", "🥈", "🥉"]
|
| 837 |
for i, entry in enumerate(sorted_lb[:5]): # Top 5
|
|
@@ -861,12 +822,15 @@ def render_game_over_html(state: Dict[str, Any]) -> str:
|
|
| 861 |
|
| 862 |
# Calculate final time
|
| 863 |
if end_time and start_time:
|
| 864 |
-
|
| 865 |
-
|
| 866 |
-
|
| 867 |
-
|
| 868 |
-
|
| 869 |
-
|
|
|
|
|
|
|
|
|
|
| 870 |
else:
|
| 871 |
time_str = "--:--"
|
| 872 |
|
|
@@ -958,7 +922,7 @@ def get_letter_button_updates(state: Dict[str, Any]) -> List[gr.Button]:
|
|
| 958 |
|
| 959 |
|
| 960 |
def build_ui_outputs(state: Dict[str, Any], audio_html: str = "") -> tuple:
|
| 961 |
-
"""Build the complete UI output
|
| 962 |
|
| 963 |
Returns: (48 grid buttons, 26 letter buttons, score_panel, status_msg, audio, game_over, free_letter_status, free_letter_row, game_over_modal, share_section, state)
|
| 964 |
"""
|
|
@@ -1189,7 +1153,7 @@ def handle_audio_settings_change(
|
|
| 1189 |
music_volume: int,
|
| 1190 |
state: Dict[str, Any]
|
| 1191 |
) -> Tuple[Dict[str, Any], str, str]:
|
| 1192 |
-
"""Handle audio settings changes and return updated state + music HTML + preview audio.
|
| 1193 |
|
| 1194 |
Returns:
|
| 1195 |
Tuple of (updated_state, music_html, preview_audio_html)
|
|
@@ -1203,13 +1167,13 @@ def handle_audio_settings_change(
|
|
| 1203 |
new_state["music_enabled"] = music_enabled
|
| 1204 |
new_state["music_volume"] = music_volume
|
| 1205 |
|
| 1206 |
-
# Generate music
|
| 1207 |
music_html = render_background_music_html(music_enabled, music_volume / 100.0)
|
| 1208 |
|
| 1209 |
# No preview audio by default
|
| 1210 |
-
|
| 1211 |
|
| 1212 |
-
return new_state, music_html,
|
| 1213 |
|
| 1214 |
|
| 1215 |
def handle_sfx_volume_change(
|
|
@@ -1233,15 +1197,15 @@ def handle_sfx_volume_change(
|
|
| 1233 |
new_state["music_enabled"] = music_enabled
|
| 1234 |
new_state["music_volume"] = music_volume
|
| 1235 |
|
| 1236 |
-
# Generate music
|
| 1237 |
music_html = render_background_music_html(music_enabled, music_volume / 100.0)
|
| 1238 |
|
| 1239 |
# Play preview sound effect at new volume level (only if enabled)
|
| 1240 |
-
|
| 1241 |
if sfx_enabled:
|
| 1242 |
-
|
| 1243 |
|
| 1244 |
-
return new_state, music_html,
|
| 1245 |
|
| 1246 |
|
| 1247 |
def handle_music_volume_change(
|
|
@@ -1265,13 +1229,14 @@ def handle_music_volume_change(
|
|
| 1265 |
new_state["music_enabled"] = music_enabled
|
| 1266 |
new_state["music_volume"] = music_volume
|
| 1267 |
|
| 1268 |
-
# Generate music
|
| 1269 |
music_html = render_background_music_html(music_enabled, music_volume / 100.0)
|
| 1270 |
|
| 1271 |
# No preview audio by default
|
| 1272 |
-
|
| 1273 |
|
| 1274 |
-
return new_state, music_html,
|
|
|
|
| 1275 |
|
| 1276 |
def handle_show_incorrect_change(show_incorrect, wordlist, ai_topic, game_mode, sfx_en, sfx_vol, music_en, music_vol, enable_fl, show_chal, state):
|
| 1277 |
state = ensure_state(state)
|
|
@@ -1473,9 +1438,11 @@ def create_app() -> gr.Blocks:
|
|
| 1473 |
sfx_volume = gr.Slider(
|
| 1474 |
minimum=0,
|
| 1475 |
maximum=100,
|
| 1476 |
-
value=
|
| 1477 |
step=1,
|
| 1478 |
-
|
|
|
|
|
|
|
| 1479 |
)
|
| 1480 |
music_enabled = gr.Checkbox(
|
| 1481 |
value=APP_SETTINGS.get("music_enabled", False),
|
|
@@ -1484,16 +1451,24 @@ def create_app() -> gr.Blocks:
|
|
| 1484 |
music_volume = gr.Slider(
|
| 1485 |
minimum=0,
|
| 1486 |
maximum=100,
|
| 1487 |
-
value=
|
| 1488 |
step=1,
|
| 1489 |
-
|
|
|
|
|
|
|
| 1490 |
)
|
| 1491 |
|
| 1492 |
-
# Audio
|
| 1493 |
-
|
|
|
|
|
|
|
|
|
|
| 1494 |
|
| 1495 |
-
# Background music player
|
| 1496 |
-
|
|
|
|
|
|
|
|
|
|
| 1497 |
|
| 1498 |
# Timer for updating elapsed time (ticks every second)
|
| 1499 |
game_timer = gr.Timer(value=1, active=True)
|
|
@@ -1520,9 +1495,9 @@ def create_app() -> gr.Blocks:
|
|
| 1520 |
share_status = gr.Markdown(value="", elem_id="share-status")
|
| 1521 |
share_url_display = gr.Textbox(
|
| 1522 |
label="Share URL (click to copy)",
|
| 1523 |
-
value="",
|
| 1524 |
interactive=False,
|
| 1525 |
-
visible=
|
| 1526 |
elem_id="share-url-display"
|
| 1527 |
)
|
| 1528 |
|
|
@@ -1535,7 +1510,7 @@ def create_app() -> gr.Blocks:
|
|
| 1535 |
*letter_buttons,
|
| 1536 |
score_panel_html,
|
| 1537 |
status_msg,
|
| 1538 |
-
audio_player_html
|
| 1539 |
game_over_html,
|
| 1540 |
free_letter_status,
|
| 1541 |
free_letter_row,
|
|
@@ -1551,7 +1526,7 @@ def create_app() -> gr.Blocks:
|
|
| 1551 |
score_panel_html,
|
| 1552 |
status_msg,
|
| 1553 |
guess_input, # Will be cleared
|
| 1554 |
-
audio_player_html
|
| 1555 |
game_over_html,
|
| 1556 |
free_letter_status,
|
| 1557 |
free_letter_row,
|
|
@@ -1758,22 +1733,22 @@ def create_app() -> gr.Blocks:
|
|
| 1758 |
sfx_enabled.change(
|
| 1759 |
fn=handle_audio_settings_change,
|
| 1760 |
inputs=[sfx_enabled, sfx_volume, music_enabled, music_volume, game_state],
|
| 1761 |
-
outputs=[game_state,
|
| 1762 |
)
|
| 1763 |
sfx_volume.change(
|
| 1764 |
fn=handle_sfx_volume_change,
|
| 1765 |
inputs=[sfx_enabled, sfx_volume, music_enabled, music_volume, game_state],
|
| 1766 |
-
outputs=[game_state,
|
| 1767 |
)
|
| 1768 |
music_enabled.change(
|
| 1769 |
fn=handle_audio_settings_change,
|
| 1770 |
inputs=[sfx_enabled, sfx_volume, music_enabled, music_volume, game_state],
|
| 1771 |
-
outputs=[game_state,
|
| 1772 |
)
|
| 1773 |
music_volume.change(
|
| 1774 |
fn=handle_music_volume_change,
|
| 1775 |
inputs=[sfx_enabled, sfx_volume, music_enabled, music_volume, game_state],
|
| 1776 |
-
outputs=[game_state,
|
| 1777 |
)
|
| 1778 |
|
| 1779 |
# Show incorrect guesses toggle handler - starts new game
|
|
@@ -1833,7 +1808,7 @@ def create_app() -> gr.Blocks:
|
|
| 1833 |
new_state["show_challenge_links"] = enabled
|
| 1834 |
if not enable_fl:
|
| 1835 |
new_state["last_action"] = "Free letters disabled. Click grid cells to reveal letters!"
|
| 1836 |
-
return
|
| 1837 |
|
| 1838 |
show_challenge_links.change(
|
| 1839 |
fn=handle_show_challenge_links_change,
|
|
@@ -1894,7 +1869,90 @@ def create_app() -> gr.Blocks:
|
|
| 1894 |
demo.load(
|
| 1895 |
fn=initialize_ui,
|
| 1896 |
inputs=[game_state],
|
| 1897 |
-
outputs=[*common_outputs,
|
| 1898 |
)
|
| 1899 |
|
| 1900 |
-
return demo
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
GRID_COLS = 8
|
| 47 |
MAX_FREE_LETTERS = 2
|
| 48 |
|
| 49 |
+
# ---------------------------------------------------------------------------
|
| 50 |
+
# Register Static Paths for Audio Files (Gradio file serving)
|
| 51 |
+
# ---------------------------------------------------------------------------
|
| 52 |
+
|
| 53 |
+
# Register audio directory at module level so Gradio can serve files directly
|
| 54 |
+
_AUDIO_DIR = os.path.join(os.path.dirname(__file__), "assets", "audio")
|
| 55 |
+
_STATIC_SERVE_ENABLED = False
|
| 56 |
+
if os.path.isdir(_AUDIO_DIR):
|
| 57 |
+
try:
|
| 58 |
+
gr.set_static_paths(paths=[_AUDIO_DIR])
|
| 59 |
+
_STATIC_SERVE_ENABLED = True
|
| 60 |
+
except (AttributeError, Exception):
|
| 61 |
+
# Graceful fallback if set_static_paths not available or fails
|
| 62 |
+
_STATIC_SERVE_ENABLED = False
|
| 63 |
+
pass
|
| 64 |
+
|
| 65 |
+
# Register static directory for favicon and other static assets
|
| 66 |
+
_STATIC_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "static")
|
| 67 |
+
if os.path.isdir(_STATIC_DIR):
|
| 68 |
+
try:
|
| 69 |
+
gr.set_static_paths(paths=[_STATIC_DIR])
|
| 70 |
+
except (AttributeError, Exception):
|
| 71 |
+
pass
|
| 72 |
+
|
| 73 |
# ---------------------------------------------------------------------------
|
| 74 |
# Settings Loading
|
| 75 |
# ---------------------------------------------------------------------------
|
|
|
|
| 83 |
"enable_free_letters": True,
|
| 84 |
"show_challenge_links": True,
|
| 85 |
"sound_effects_enabled": True,
|
| 86 |
+
"sound_effects_volume": 10, # Changed from 50 to 10
|
| 87 |
"music_enabled": False,
|
| 88 |
+
"music_volume": 10, # Changed from 30 to 10
|
| 89 |
"default_wordlist": "classic.txt",
|
| 90 |
"default_game_mode": "classic"
|
| 91 |
}
|
|
|
|
| 129 |
# Audio Utilities (Gradio-compatible)
|
| 130 |
# ---------------------------------------------------------------------------
|
| 131 |
|
| 132 |
+
# Import shared core functions
|
| 133 |
+
from .audio_core import (
|
| 134 |
+
get_music_dir as _get_music_dir,
|
| 135 |
+
get_effects_dir as _get_effects_dir,
|
| 136 |
+
get_audio_tracks,
|
| 137 |
+
get_sound_effect_files,
|
| 138 |
+
get_background_music_path,
|
| 139 |
+
get_audio_file_url,
|
| 140 |
+
clamp_volume
|
| 141 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
|
| 143 |
|
| 144 |
@lru_cache(maxsize=32)
|
| 145 |
def load_audio_data_url(path: str) -> str:
|
| 146 |
+
"""
|
| 147 |
+
Convert audio file to data URL for browser playback.
|
| 148 |
+
|
| 149 |
+
Fallback method using base64 encoding. Used when direct file serving fails.
|
| 150 |
+
|
| 151 |
+
Args:
|
| 152 |
+
path: Absolute path to audio file
|
| 153 |
+
|
| 154 |
+
Returns:
|
| 155 |
+
Base64-encoded data URL
|
| 156 |
+
"""
|
| 157 |
mime, _ = mimetypes.guess_type(path)
|
| 158 |
if not mime:
|
| 159 |
mime = "audio/mpeg"
|
|
|
|
| 165 |
return ""
|
| 166 |
|
| 167 |
|
| 168 |
+
def _resolve_audio_src(path: Optional[str]) -> str:
|
| 169 |
+
"""Resolve audio source to a playable URL using Gradio's file serving.
|
| 170 |
|
| 171 |
+
Uses relative paths from the project root that work both locally
|
| 172 |
+
and on Hugging Face Spaces.
|
| 173 |
|
| 174 |
Args:
|
| 175 |
+
path: Absolute path to audio file
|
|
|
|
| 176 |
|
| 177 |
Returns:
|
| 178 |
+
Gradio /file= URL with relative path
|
| 179 |
"""
|
| 180 |
+
if not path:
|
|
|
|
|
|
|
| 181 |
return ""
|
| 182 |
|
| 183 |
+
# Always use Gradio's file serving endpoint
|
| 184 |
+
# This works both locally and on Hugging Face
|
| 185 |
+
try:
|
| 186 |
+
url = get_audio_file_url(path)
|
| 187 |
+
if url:
|
| 188 |
+
return url
|
| 189 |
+
except Exception:
|
| 190 |
+
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
|
| 192 |
+
# Fallback to base64 only if file URL generation fails
|
| 193 |
+
return load_audio_data_url(path)
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def render_background_music_html(enabled: bool, volume: float) -> str:
|
| 197 |
+
"""
|
| 198 |
+
Generate HTML for background music player (Gradio implementation).
|
| 199 |
|
| 200 |
+
Returns just the audio element HTML with autoplay attribute.
|
| 201 |
+
Note: Autoplay may be blocked by browser until first user interaction.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
|
| 203 |
+
Args:
|
| 204 |
+
enabled: Whether background music should play
|
| 205 |
+
volume: Volume level (0.0 to 1.0)
|
| 206 |
|
| 207 |
+
Returns:
|
| 208 |
+
HTML string with audio element
|
| 209 |
+
"""
|
| 210 |
+
if not enabled:
|
| 211 |
+
return "<div style='display:none;' id='music-player-container'></div>"
|
|
|
|
|
|
|
| 212 |
|
| 213 |
+
try:
|
| 214 |
+
music_path = get_background_music_path("background")
|
| 215 |
+
if not music_path or not os.path.exists(music_path) or not os.path.isfile(music_path):
|
| 216 |
+
return "<div style='display:none;' id='music-player-container'></div>"
|
| 217 |
+
|
| 218 |
+
audio_url = get_audio_file_url(music_path)
|
| 219 |
+
vol = clamp_volume(volume)
|
| 220 |
+
|
| 221 |
+
# Simple HTML5 audio with autoplay
|
| 222 |
+
# Volume will be set by browser default initially, updated on user interaction
|
| 223 |
+
html = f"""
|
| 224 |
+
<div id='music-player-container' style='display:none;'>
|
| 225 |
+
<audio id='bg-music-player' autoplay loop preload='auto' volume='{vol:.2f}'>
|
| 226 |
+
<source src='{audio_url}' type='audio/mpeg'>
|
| 227 |
+
</audio>
|
| 228 |
+
</div>
|
| 229 |
+
"""
|
| 230 |
+
return html
|
| 231 |
+
except Exception as e:
|
| 232 |
+
print(f"[Audio] Error in render_background_music_html: {e}")
|
| 233 |
+
return "<div style='display:none;' id='music-player-container'></div>"
|
| 234 |
|
| 235 |
|
| 236 |
def render_audio_player_html(
|
|
|
|
| 238 |
volume: float = 0.5,
|
| 239 |
enabled: bool = True
|
| 240 |
) -> str:
|
| 241 |
+
"""
|
| 242 |
+
Generate HTML for sound effect playback (Gradio implementation).
|
| 243 |
|
| 244 |
+
Returns simple HTML5 audio element with autoplay.
|
|
|
|
| 245 |
|
| 246 |
Args:
|
| 247 |
effect_name: Name of the sound effect to play
|
|
|
|
| 249 |
enabled: Whether sound effects are enabled
|
| 250 |
|
| 251 |
Returns:
|
| 252 |
+
HTML string with audio element
|
| 253 |
"""
|
| 254 |
if not enabled or not effect_name:
|
| 255 |
+
return "<div style='display:none;'></div>"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
|
| 257 |
+
try:
|
| 258 |
+
sound_files = get_sound_effect_files()
|
| 259 |
+
if effect_name not in sound_files:
|
| 260 |
+
return "<div style='display:none;'></div>"
|
| 261 |
|
| 262 |
+
sound_path = sound_files[effect_name]
|
| 263 |
+
if not os.path.exists(sound_path) or not os.path.isfile(sound_path):
|
| 264 |
+
return "<div style='display:none;'></div>"
|
| 265 |
+
|
| 266 |
+
audio_url = get_audio_file_url(sound_path)
|
| 267 |
+
vol = clamp_volume(volume)
|
| 268 |
+
|
| 269 |
+
# Use timestamp to ensure unique ID
|
| 270 |
+
import time
|
| 271 |
+
audio_id = f"sfx-{int(time.time() * 1000)}"
|
| 272 |
+
|
| 273 |
+
# Simple autoplay audio (will play after user interaction)
|
| 274 |
+
html = f"""
|
| 275 |
+
<div style='display:none;'>
|
| 276 |
+
<audio id='{audio_id}' autoplay preload='auto' volume='{vol:.2f}'>
|
| 277 |
+
<source src='{audio_url}' type='audio/mpeg'>
|
| 278 |
+
</audio>
|
| 279 |
+
</div>
|
| 280 |
+
"""
|
| 281 |
+
return html
|
| 282 |
+
except Exception as e:
|
| 283 |
+
print(f"[Audio] Error in render_audio_player_html: {e}")
|
| 284 |
+
return "<div style='display:none;'></div>"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 285 |
|
| 286 |
|
| 287 |
# ---------------------------------------------------------------------------
|
|
|
|
| 750 |
{incorrect_html}
|
| 751 |
<div class="score-header">Score: <span class="score-value">{score}</span></div>
|
| 752 |
{timer_html}
|
| 753 |
+
<table class='shiny-border' style="border-radius:0.75rem; overflow:hidden; width:100%; margin:0 auto; border-collapse:separate; border-spacing: 0;">
|
| 754 |
{table_inner}
|
| 755 |
</table>
|
| 756 |
</div>
|
|
|
|
| 763 |
"""Render audio HTML for any pending sound effect."""
|
| 764 |
pending = state.get("pending_sound")
|
| 765 |
if not pending:
|
| 766 |
+
return "<div style='display:none;'></div>"
|
| 767 |
|
| 768 |
enabled = state.get("sound_effects_enabled", True)
|
| 769 |
volume = state.get("sound_effects_volume", 50) / 100.0
|
|
|
|
| 792 |
]
|
| 793 |
|
| 794 |
# Sort by score desc, then time asc
|
| 795 |
+
sorted_lb = sorted(leaderboard, key=lambda x: (-(x.get("score", 0)), x.get("time", 9999)))
|
| 796 |
|
| 797 |
medals = ["🥇", "🥈", "🥉"]
|
| 798 |
for i, entry in enumerate(sorted_lb[:5]): # Top 5
|
|
|
|
| 822 |
|
| 823 |
# Calculate final time
|
| 824 |
if end_time and start_time:
|
| 825 |
+
try:
|
| 826 |
+
start = datetime.fromisoformat(start_time)
|
| 827 |
+
end = datetime.fromisoformat(end_time)
|
| 828 |
+
elapsed = (end - start).total_seconds()
|
| 829 |
+
minutes = int(elapsed // 60)
|
| 830 |
+
seconds = int(elapsed % 60)
|
| 831 |
+
time_str = f"{minutes:02d}:{seconds:02d}"
|
| 832 |
+
except Exception:
|
| 833 |
+
time_str = "--:--"
|
| 834 |
else:
|
| 835 |
time_str = "--:--"
|
| 836 |
|
|
|
|
| 922 |
|
| 923 |
|
| 924 |
def build_ui_outputs(state: Dict[str, Any], audio_html: str = "") -> tuple:
|
| 925 |
+
"""Build the complete UI output for all handlers.
|
| 926 |
|
| 927 |
Returns: (48 grid buttons, 26 letter buttons, score_panel, status_msg, audio, game_over, free_letter_status, free_letter_row, game_over_modal, share_section, state)
|
| 928 |
"""
|
|
|
|
| 1153 |
music_volume: int,
|
| 1154 |
state: Dict[str, Any]
|
| 1155 |
) -> Tuple[Dict[str, Any], str, str]:
|
| 1156 |
+
"""Handle audio settings changes and return updated state + music HTML + preview audio HTML.
|
| 1157 |
|
| 1158 |
Returns:
|
| 1159 |
Tuple of (updated_state, music_html, preview_audio_html)
|
|
|
|
| 1167 |
new_state["music_enabled"] = music_enabled
|
| 1168 |
new_state["music_volume"] = music_volume
|
| 1169 |
|
| 1170 |
+
# Generate music HTML (updates background music volume)
|
| 1171 |
music_html = render_background_music_html(music_enabled, music_volume / 100.0)
|
| 1172 |
|
| 1173 |
# No preview audio by default
|
| 1174 |
+
preview_audio_html = "<div style='display:none;'></div>"
|
| 1175 |
|
| 1176 |
+
return new_state, music_html, preview_audio_html
|
| 1177 |
|
| 1178 |
|
| 1179 |
def handle_sfx_volume_change(
|
|
|
|
| 1197 |
new_state["music_enabled"] = music_enabled
|
| 1198 |
new_state["music_volume"] = music_volume
|
| 1199 |
|
| 1200 |
+
# Generate music HTML
|
| 1201 |
music_html = render_background_music_html(music_enabled, music_volume / 100.0)
|
| 1202 |
|
| 1203 |
# Play preview sound effect at new volume level (only if enabled)
|
| 1204 |
+
preview_audio_html = "<div style='display:none;'></div>"
|
| 1205 |
if sfx_enabled:
|
| 1206 |
+
preview_audio_html = render_audio_player_html("hit", sfx_volume / 100.0, True)
|
| 1207 |
|
| 1208 |
+
return new_state, music_html, preview_audio_html
|
| 1209 |
|
| 1210 |
|
| 1211 |
def handle_music_volume_change(
|
|
|
|
| 1229 |
new_state["music_enabled"] = music_enabled
|
| 1230 |
new_state["music_volume"] = music_volume
|
| 1231 |
|
| 1232 |
+
# Generate music HTML (this updates the volume in real-time)
|
| 1233 |
music_html = render_background_music_html(music_enabled, music_volume / 100.0)
|
| 1234 |
|
| 1235 |
# No preview audio by default
|
| 1236 |
+
preview_audio_html = "<div style='display:none;'></div>"
|
| 1237 |
|
| 1238 |
+
return new_state, music_html, preview_audio_html
|
| 1239 |
+
|
| 1240 |
|
| 1241 |
def handle_show_incorrect_change(show_incorrect, wordlist, ai_topic, game_mode, sfx_en, sfx_vol, music_en, music_vol, enable_fl, show_chal, state):
|
| 1242 |
state = ensure_state(state)
|
|
|
|
| 1438 |
sfx_volume = gr.Slider(
|
| 1439 |
minimum=0,
|
| 1440 |
maximum=100,
|
| 1441 |
+
value=10, # Changed from 50 to 10
|
| 1442 |
step=1,
|
| 1443 |
+
elem_id="sfx-volume-slider",
|
| 1444 |
+
label="Sound Effects Volume",
|
| 1445 |
+
key="sfx-volume-slider"
|
| 1446 |
)
|
| 1447 |
music_enabled = gr.Checkbox(
|
| 1448 |
value=APP_SETTINGS.get("music_enabled", False),
|
|
|
|
| 1451 |
music_volume = gr.Slider(
|
| 1452 |
minimum=0,
|
| 1453 |
maximum=100,
|
| 1454 |
+
value=10, # Changed from 30 to 10
|
| 1455 |
step=1,
|
| 1456 |
+
elem_id="music-volume-slider",
|
| 1457 |
+
label="Music Volume",
|
| 1458 |
+
key="music-volume-slider"
|
| 1459 |
)
|
| 1460 |
|
| 1461 |
+
# Audio players using HTML components for better compatibility
|
| 1462 |
+
audio_player = gr.HTML(
|
| 1463 |
+
value="<div style='display:none;'></div>",
|
| 1464 |
+
elem_id="audio-player"
|
| 1465 |
+
)
|
| 1466 |
|
| 1467 |
+
# Background music player using HTML component
|
| 1468 |
+
music_player = gr.HTML(
|
| 1469 |
+
value="<div style='display:none;'></div>",
|
| 1470 |
+
elem_id="music-player"
|
| 1471 |
+
)
|
| 1472 |
|
| 1473 |
# Timer for updating elapsed time (ticks every second)
|
| 1474 |
game_timer = gr.Timer(value=1, active=True)
|
|
|
|
| 1495 |
share_status = gr.Markdown(value="", elem_id="share-status")
|
| 1496 |
share_url_display = gr.Textbox(
|
| 1497 |
label="Share URL (click to copy)",
|
| 1498 |
+
value="//aiprod.wrh.r.appspot.com/file=hf_space_challenge_16bee9a3-65a7-4b5a-8060-5052458808d0",
|
| 1499 |
interactive=False,
|
| 1500 |
+
visible=True,
|
| 1501 |
elem_id="share-url-display"
|
| 1502 |
)
|
| 1503 |
|
|
|
|
| 1510 |
*letter_buttons,
|
| 1511 |
score_panel_html,
|
| 1512 |
status_msg,
|
| 1513 |
+
audio_player, # Changed from audio_player_html
|
| 1514 |
game_over_html,
|
| 1515 |
free_letter_status,
|
| 1516 |
free_letter_row,
|
|
|
|
| 1526 |
score_panel_html,
|
| 1527 |
status_msg,
|
| 1528 |
guess_input, # Will be cleared
|
| 1529 |
+
audio_player, # Changed from audio_player_html
|
| 1530 |
game_over_html,
|
| 1531 |
free_letter_status,
|
| 1532 |
free_letter_row,
|
|
|
|
| 1733 |
sfx_enabled.change(
|
| 1734 |
fn=handle_audio_settings_change,
|
| 1735 |
inputs=[sfx_enabled, sfx_volume, music_enabled, music_volume, game_state],
|
| 1736 |
+
outputs=[game_state, music_player, audio_player]
|
| 1737 |
)
|
| 1738 |
sfx_volume.change(
|
| 1739 |
fn=handle_sfx_volume_change,
|
| 1740 |
inputs=[sfx_enabled, sfx_volume, music_enabled, music_volume, game_state],
|
| 1741 |
+
outputs=[game_state, music_player, audio_player]
|
| 1742 |
)
|
| 1743 |
music_enabled.change(
|
| 1744 |
fn=handle_audio_settings_change,
|
| 1745 |
inputs=[sfx_enabled, sfx_volume, music_enabled, music_volume, game_state],
|
| 1746 |
+
outputs=[game_state, music_player, audio_player]
|
| 1747 |
)
|
| 1748 |
music_volume.change(
|
| 1749 |
fn=handle_music_volume_change,
|
| 1750 |
inputs=[sfx_enabled, sfx_volume, music_enabled, music_volume, game_state],
|
| 1751 |
+
outputs=[game_state, music_player, audio_player]
|
| 1752 |
)
|
| 1753 |
|
| 1754 |
# Show incorrect guesses toggle handler - starts new game
|
|
|
|
| 1808 |
new_state["show_challenge_links"] = enabled
|
| 1809 |
if not enable_fl:
|
| 1810 |
new_state["last_action"] = "Free letters disabled. Click grid cells to reveal letters!"
|
| 1811 |
+
return build_uioutputs(new_state)
|
| 1812 |
|
| 1813 |
show_challenge_links.change(
|
| 1814 |
fn=handle_show_challenge_links_change,
|
|
|
|
| 1869 |
demo.load(
|
| 1870 |
fn=initialize_ui,
|
| 1871 |
inputs=[game_state],
|
| 1872 |
+
outputs=[*common_outputs, music_player]
|
| 1873 |
)
|
| 1874 |
|
| 1875 |
+
return demo
|
| 1876 |
+
|
| 1877 |
+
def handle_share_challenge(username: str, state: Dict[str, Any]) -> Tuple[str, Optional[str], Dict[str, Any]]:
|
| 1878 |
+
"""Create or reuse a shareable challenge link for the finished game.
|
| 1879 |
+
|
| 1880 |
+
Args:
|
| 1881 |
+
username: Player provided name (optional / may be empty)
|
| 1882 |
+
state: Current game state dictionary
|
| 1883 |
+
|
| 1884 |
+
Returns:
|
| 1885 |
+
(status_message, share_url or None, updated_state)
|
| 1886 |
+
"""
|
| 1887 |
+
state = ensure_state(state)
|
| 1888 |
+
|
| 1889 |
+
# Must be game over to share
|
| 1890 |
+
if not state.get("game_over"):
|
| 1891 |
+
return ("Finish the game before generating a share link.", None, state)
|
| 1892 |
+
|
| 1893 |
+
# If already generated, just return existing
|
| 1894 |
+
existing_url = state.get("share_url")
|
| 1895 |
+
if existing_url:
|
| 1896 |
+
return ("Share link already generated.", existing_url, state)
|
| 1897 |
+
|
| 1898 |
+
# Normalize username
|
| 1899 |
+
name = (username or "Anonymous").strip()
|
| 1900 |
+
if not name:
|
| 1901 |
+
name = "Anonymous"
|
| 1902 |
+
|
| 1903 |
+
# Compute elapsed time in seconds
|
| 1904 |
+
try:
|
| 1905 |
+
start_time = datetime.fromisoformat(state.get("start_time")) if state.get("start_time") else None
|
| 1906 |
+
end_time = datetime.fromisoformat(state.get("end_time")) if state.get("end_time") else datetime.now()
|
| 1907 |
+
if start_time and end_time:
|
| 1908 |
+
time_seconds = int((end_time - start_time).total_seconds())
|
| 1909 |
+
else:
|
| 1910 |
+
time_seconds = 0
|
| 1911 |
+
except Exception:
|
| 1912 |
+
time_seconds = 0
|
| 1913 |
+
|
| 1914 |
+
# Extract word list used in this puzzle
|
| 1915 |
+
word_list = [w[0] for w in state.get("puzzle_words", [])]
|
| 1916 |
+
|
| 1917 |
+
# Prepare parameters
|
| 1918 |
+
score = state.get("score", 0)
|
| 1919 |
+
game_mode = state.get("game_mode", "classic")
|
| 1920 |
+
wordlist_source = state.get("wordlist")
|
| 1921 |
+
show_incorrect = state.get("show_incorrect_guesses", True)
|
| 1922 |
+
enable_free = state.get("enable_free_letters", True)
|
| 1923 |
+
game_title = APP_SETTINGS.get("game_title")
|
| 1924 |
+
|
| 1925 |
+
try:
|
| 1926 |
+
challenge_id, full_url, sid = save_game_to_hf(
|
| 1927 |
+
word_list=word_list,
|
| 1928 |
+
username=name,
|
| 1929 |
+
score=score,
|
| 1930 |
+
time_seconds=time_seconds,
|
| 1931 |
+
game_mode=game_mode,
|
| 1932 |
+
wordlist_source=wordlist_source,
|
| 1933 |
+
game_title=game_title,
|
| 1934 |
+
show_incorrect_guesses=show_incorrect,
|
| 1935 |
+
enable_free_letters=enable_free
|
| 1936 |
+
)
|
| 1937 |
+
if sid:
|
| 1938 |
+
share_url = get_shareable_url(sid)
|
| 1939 |
+
# Create a deep copy to ensure Gradio notices state change
|
| 1940 |
+
new_state = copy.deepcopy(state)
|
| 1941 |
+
new_state["share_url"] = share_url
|
| 1942 |
+
new_state["challenge_sid"] = sid
|
| 1943 |
+
new_state["challenge_mode"] = True
|
| 1944 |
+
# Initialize or extend leaderboard
|
| 1945 |
+
lb_entry = {"username": name, "score": score, "time": time_seconds}
|
| 1946 |
+
if not new_state.get("challenge_leaderboard"):
|
| 1947 |
+
new_state["challenge_leaderboard"] = [lb_entry]
|
| 1948 |
+
else:
|
| 1949 |
+
# Avoid duplicate user entries with same uid/score/time
|
| 1950 |
+
exists = any(e.get("username") == name and e.get("score") == score and e.get("time") == time_seconds for e in new_state["challenge_leaderboard"])
|
| 1951 |
+
if not exists:
|
| 1952 |
+
new_state["challenge_leaderboard"].append(lb_entry)
|
| 1953 |
+
status = f"Challenge created! Share with friends: {share_url}";
|
| 1954 |
+
return (status, share_url, new_state)
|
| 1955 |
+
else:
|
| 1956 |
+
return ("Challenge saved but short link creation failed.", full_url, state)
|
| 1957 |
+
except Exception as e:
|
| 1958 |
+
return (f"Failed to create share link: {e}", None, state)
|
wrdler/word_loader_ai.py
CHANGED
|
@@ -793,6 +793,126 @@ if __name__ == "__main__": # pragma: no cover
|
|
| 793 |
logger.info(f"Difficulties: {diff}")
|
| 794 |
logger.info(f"Metadata: {meta}")
|
| 795 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 796 |
hf_root = os.path.join(TMPDIR, "hf-cache")
|
| 797 |
os.makedirs(hf_root, exist_ok=True)
|
| 798 |
|
|
|
|
| 793 |
logger.info(f"Difficulties: {diff}")
|
| 794 |
logger.info(f"Metadata: {meta}")
|
| 795 |
|
| 796 |
+
# ---------------------------------------------------------------------------
|
| 797 |
+
# MCP Server Integration (Gradio built-in)
|
| 798 |
+
# ---------------------------------------------------------------------------
|
| 799 |
+
|
| 800 |
+
# Only expose MCP function when running locally (not when deployed to HF Spaces)
|
| 801 |
+
# This is controlled by the USE_HF_WORDS environment variable
|
| 802 |
+
try:
|
| 803 |
+
import gradio as gr
|
| 804 |
+
_GRADIO_AVAILABLE = True
|
| 805 |
+
except Exception:
|
| 806 |
+
_GRADIO_AVAILABLE = False
|
| 807 |
+
|
| 808 |
+
if _GRADIO_AVAILABLE and not USE_HF_WORDS:
|
| 809 |
+
@gr.mcp_server_function(
|
| 810 |
+
name="generate_ai_words",
|
| 811 |
+
description="""
|
| 812 |
+
Generate 75 AI-selected words (25 each of lengths 4, 5, 6) related to a topic.
|
| 813 |
+
|
| 814 |
+
This function generates vocabulary words for the Wrdler game using local AI models.
|
| 815 |
+
It validates words against game constraints and returns difficulty scores.
|
| 816 |
+
|
| 817 |
+
Only available when running locally (USE_HF_WORDS=false).
|
| 818 |
+
""",
|
| 819 |
+
input_schema={
|
| 820 |
+
"type": "object",
|
| 821 |
+
"properties": {
|
| 822 |
+
"topic": {
|
| 823 |
+
"type": "string",
|
| 824 |
+
"description": "Semantic theme for word generation (e.g., 'Ocean Life', 'Space', 'Medieval History')",
|
| 825 |
+
"default": "English"
|
| 826 |
+
},
|
| 827 |
+
"model_name": {
|
| 828 |
+
"type": "string",
|
| 829 |
+
"description": "Optional: Override default AI model name",
|
| 830 |
+
"default": None
|
| 831 |
+
},
|
| 832 |
+
"seed": {
|
| 833 |
+
"type": "integer",
|
| 834 |
+
"description": "Optional: Random seed for reproducibility",
|
| 835 |
+
"default": None
|
| 836 |
+
},
|
| 837 |
+
"use_dictionary_filter": {
|
| 838 |
+
"type": "boolean",
|
| 839 |
+
"description": "Whether to filter words against dictionary (kept for compatibility, currently ignored)",
|
| 840 |
+
"default": True
|
| 841 |
+
},
|
| 842 |
+
"selected_file": {
|
| 843 |
+
"type": "string",
|
| 844 |
+
"description": "Optional: Word list file name for dictionary context",
|
| 845 |
+
"default": None
|
| 846 |
+
}
|
| 847 |
+
},
|
| 848 |
+
"required": ["topic"]
|
| 849 |
+
},
|
| 850 |
+
output_schema={
|
| 851 |
+
"type": "object",
|
| 852 |
+
"properties": {
|
| 853 |
+
"words": {
|
| 854 |
+
"type": "array",
|
| 855 |
+
"items": {"type": "string"},
|
| 856 |
+
"description": "Final 75 words (uppercase A-Z), 25 each of lengths 4, 5, 6"
|
| 857 |
+
},
|
| 858 |
+
"difficulties": {
|
| 859 |
+
"type": "object",
|
| 860 |
+
"description": "Difficulty scores for each word (word -> float)",
|
| 861 |
+
"additionalProperties": {"type": "number"}
|
| 862 |
+
},
|
| 863 |
+
"metadata": {
|
| 864 |
+
"type": "object",
|
| 865 |
+
"description": "Generation metadata (model used, counts, diagnostics)",
|
| 866 |
+
"properties": {
|
| 867 |
+
"model_used": {"type": "string"},
|
| 868 |
+
"transformers_available": {"type": "string"},
|
| 869 |
+
"gradio_client_available": {"type": "string"},
|
| 870 |
+
"use_hf_words": {"type": "string"},
|
| 871 |
+
"raw_output_length": {"type": "string"},
|
| 872 |
+
"raw_output_snippet": {"type": "string"},
|
| 873 |
+
"ai_initial_count": {"type": "string"},
|
| 874 |
+
"topic": {"type": "string"},
|
| 875 |
+
"dictionary_filter": {"type": "string"},
|
| 876 |
+
"new_words_saved": {"type": "string"}
|
| 877 |
+
}
|
| 878 |
+
}
|
| 879 |
+
}
|
| 880 |
+
}
|
| 881 |
+
)
|
| 882 |
+
def mcp_generate_ai_words(
|
| 883 |
+
topic: str = "English",
|
| 884 |
+
model_name: Optional[str] = None,
|
| 885 |
+
seed: Optional[int] = None,
|
| 886 |
+
use_dictionary_filter: bool = True,
|
| 887 |
+
selected_file: Optional[str] = None,
|
| 888 |
+
) -> dict:
|
| 889 |
+
"""
|
| 890 |
+
MCP-compatible wrapper for generate_ai_words function.
|
| 891 |
+
|
| 892 |
+
Returns a dictionary with words, difficulties, and metadata keys.
|
| 893 |
+
This format is compatible with MCP tool requirements.
|
| 894 |
+
"""
|
| 895 |
+
words, difficulties, metadata = generate_ai_words(
|
| 896 |
+
topic=topic,
|
| 897 |
+
model_name=model_name,
|
| 898 |
+
seed=seed,
|
| 899 |
+
use_dictionary_filter=use_dictionary_filter,
|
| 900 |
+
selected_file=selected_file,
|
| 901 |
+
)
|
| 902 |
+
|
| 903 |
+
return {
|
| 904 |
+
"words": words,
|
| 905 |
+
"difficulties": difficulties,
|
| 906 |
+
"metadata": metadata
|
| 907 |
+
}
|
| 908 |
+
|
| 909 |
+
logger.info("✅ MCP server function 'generate_ai_words' registered (local mode)")
|
| 910 |
+
else:
|
| 911 |
+
if not _GRADIO_AVAILABLE:
|
| 912 |
+
logger.debug("ℹ️ Gradio not available; MCP function not registered")
|
| 913 |
+
elif USE_HF_WORDS:
|
| 914 |
+
logger.debug("ℹ️ USE_HF_WORDS=true; MCP function not registered (remote mode)")
|
| 915 |
+
|
| 916 |
hf_root = os.path.join(TMPDIR, "hf-cache")
|
| 917 |
os.makedirs(hf_root, exist_ok=True)
|
| 918 |
|