Surn commited on
Commit
8899cc8
·
1 Parent(s): a33cd25

Switch to Gradio UI; Add MCP support

Browse files

Transitioned 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 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: 8501
10
- app_file: app.py
11
  suggested_hardware: cpu-basic
12
  pinned: false
13
  tags:
14
  - game
15
  - vocabulary
16
- - streamlit
17
  - gradio
18
  - education
19
  - ai
20
- short_description: Fast paced word guessing game with AI-generated word lists
 
 
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
- 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.
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
- **Dual UI Support:** Play with Streamlit (default) or Gradio 5.50+ (alternative) - same great gameplay, your choice of framework!
397
 
398
- **🎮 Live Demo:** [Play on Hugging Face Spaces](https://huggingface.co/spaces/Surn/Wrdler)
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 (alternative):**
509
  ```bash
 
 
510
  python -m wrdler.gradio_ui
511
- # or run the Gradio demo file directly
 
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
- demo.launch(
21
- server_name="0.0.0.0",
22
- server_port=7860,
23
- share=False,
24
- show_error=True
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.2"
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.4"
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
- def _get_music_dir() -> str:
6
- return os.path.join(os.path.dirname(__file__), "assets", "audio", "music")
7
-
8
- def _get_effects_dir() -> str:
9
- return os.path.join(os.path.dirname(__file__), "assets", "audio", "effects")
10
-
11
- def get_audio_tracks() -> list[tuple[str, str]]:
12
- """Return list of (label, absolute_path) for .mp3 files in assets/audio/music."""
13
- audio_dir = _get_music_dir()
14
- if not os.path.isdir(audio_dir):
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
- """Return a data: URL for the given audio file so the browser can play it."""
 
 
 
 
 
 
 
 
 
 
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
- """Create/update a single hidden <audio> element in the top page and play/pause it.
 
39
 
40
  Args:
41
- enabled: Whether the background audio should be active.
42
- src_data_url: data: URL for the audio source.
43
- volume: 0.01.0 volume level.
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
- # Clamp volume
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(() => {{ /* ignore autoplay block until user gesture */ }}); }}
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 # Sound file doesn't exist, silently skip
218
 
219
  sound_path = sound_files[effect_name]
220
  sound_data_url = _load_audio_data_url(sound_path)
221
 
222
- # Clamp volume
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
- def _get_music_dir() -> str:
109
- return os.path.join(os.path.dirname(__file__), "assets", "audio", "music")
110
-
111
-
112
- def _get_effects_dir() -> str:
113
- return os.path.join(os.path.dirname(__file__), "assets", "audio", "effects")
114
-
115
-
116
- def get_audio_tracks() -> List[Tuple[str, str]]:
117
- """Return list of (label, path) for music files."""
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
- """Convert audio file to data URL for browser playback."""
 
 
 
 
 
 
 
 
 
 
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 render_background_music_html(enabled: bool, volume: float) -> str:
162
- """Generate HTML to control background music player.
163
 
164
- Creates or updates a persistent audio element in the parent document.
165
- The music will loop continuously when enabled.
166
 
167
  Args:
168
- enabled: Whether background music should play
169
- volume: Volume level (0.0 to 1.0)
170
 
171
  Returns:
172
- HTML/JavaScript to inject into the page
173
  """
174
- # Get the first available music track (background.mp3 preferred)
175
- tracks = get_audio_tracks()
176
- if not tracks:
177
  return ""
178
 
179
- # Prefer background.mp3, otherwise use first track
180
- music_path = None
181
- for name, path in tracks:
182
- if name.lower() == "background":
183
- music_path = path
184
- break
185
- if not music_path:
186
- music_path = tracks[0][1]
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
- audio.volume = {vol:.3f};
215
- audio.muted = false;
 
 
 
 
 
216
 
217
- const tryPlay = () => {{
218
- const p = audio.play();
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
- tryPlay();
 
 
228
 
229
- // Add user interaction listeners to unlock autoplay
230
- const unlock = () => {{
231
- tryPlay();
232
- doc.removeEventListener('pointerdown', unlock);
233
- doc.removeEventListener('keydown', unlock);
234
- doc.removeEventListener('touchstart', unlock);
235
- }};
236
 
237
- doc.addEventListener('pointerdown', unlock, {{ once: true }});
238
- doc.addEventListener('keydown', unlock, {{ once: true }});
239
- doc.addEventListener('touchstart', unlock, {{ once: true }});
240
- }})();
241
- </script>
242
- '''
243
- else:
244
- # Pause music when disabled
245
- return '''
246
- <script>
247
- (function(){
248
- const doc = window.parent?.document || document;
249
- const audio = doc.getElementById('wrdler-bg-music');
250
- if (audio) {
251
- audio.pause();
252
- audio.muted = true;
253
- }
254
- })();
255
- </script>
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
- """Generate HTML to play a sound effect.
 
265
 
266
- Creates a temporary audio element that plays once and removes itself.
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/JavaScript to inject into the page
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
- data_url = load_audio_data_url(sound_files[effect_name])
285
- if not data_url:
286
- return ""
 
287
 
288
- vol = max(0.0, min(1.0, float(volume)))
289
-
290
- return f'''
291
- <script>
292
- (function(){{
293
- const doc = window.parent?.document || document;
294
- const audio = doc.createElement('audio');
295
- audio.src = "{data_url}";
296
- audio.volume = {vol:.3f};
297
- audio.style.display = 'none';
298
- doc.body.appendChild(audio);
299
-
300
- // Play and remove after playback
301
- const playPromise = audio.play();
302
- if (playPromise) {{
303
- playPromise.catch(e => {{
304
- console.log('Sound effect play blocked:', e.message);
305
- doc.body.removeChild(audio);
306
- }});
307
- }}
308
-
309
- audio.addEventListener('ended', () => {{
310
- if (audio.parentNode) {{
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=\"border-radius:0.75rem; overflow:hidden; width:100%; margin:0 auto; border-collapse:separate; border-spacing: 0;\">
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
- start = datetime.fromisoformat(start_time)
865
- end = datetime.fromisoformat(end_time)
866
- elapsed = (end - start).total_seconds()
867
- minutes = int(elapsed // 60)
868
- seconds = int(elapsed % 60)
869
- time_str = f"{minutes:02d}:{seconds:02d}"
 
 
 
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 tuple for all handlers.
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 control HTML (updates background music volume)
1207
  music_html = render_background_music_html(music_enabled, music_volume / 100.0)
1208
 
1209
  # No preview audio by default
1210
- preview_audio = ""
1211
 
1212
- return new_state, music_html, preview_audio
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 control HTML
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
- preview_audio = ""
1241
  if sfx_enabled:
1242
- preview_audio = render_audio_player_html("hit", sfx_volume / 100.0, True)
1243
 
1244
- return new_state, music_html, preview_audio
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 control HTML (this updates the volume in real-time)
1269
  music_html = render_background_music_html(music_enabled, music_volume / 100.0)
1270
 
1271
  # No preview audio by default
1272
- preview_audio = ""
1273
 
1274
- return new_state, music_html, preview_audio
 
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=APP_SETTINGS.get("sound_effects_volume", 50),
1477
  step=1,
1478
- label="Sound Effects Volume"
 
 
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=APP_SETTINGS.get("music_volume", 30),
1488
  step=1,
1489
- label="Music Volume"
 
 
1490
  )
1491
 
1492
- # Audio player for sound effects (hidden via CSS but still executes)
1493
- audio_player_html = gr.HTML(value="", elem_id="audio-player", elem_classes=["hidden-audio"])
 
 
 
1494
 
1495
- # Background music player (hidden but persistent)
1496
- music_player_html = gr.HTML(value="", elem_id="music-player", elem_classes=["hidden-audio"])
 
 
 
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=False,
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, music_player_html, audio_player_html]
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, music_player_html, audio_player_html]
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, music_player_html, audio_player_html]
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, music_player_html, audio_player_html]
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 build_ui_outputs(new_state)
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, music_player_html]
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