Surn commited on
Commit
c60fa59
·
1 Parent(s): af6faa7

Basic Only version step 2

Browse files
LOCALHOST_PWA_README.md DELETED
@@ -1,267 +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 battlewords/static/ battlewords/ui.py
105
- git commit -m "Add PWA support"
106
- git push
107
-
108
- # HF Spaces auto-deploys
109
- # Visit: https://surn-battlewords.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 battlewords/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
- ---
191
-
192
- ## Summary Table
193
-
194
- | Feature | Localhost | Localhost + ngrok | HF Spaces (Production) |
195
- |---------|-----------|-------------------|------------------------|
196
- | **Game works** | ✅ | ✅ | ✅ |
197
- | **Challenge Mode** | ✅ (if .env configured) | ✅ | ✅ |
198
- | **PWA manifest loads** | ❌ | ✅ | ✅ |
199
- | **Service worker registers** | ❌ | ✅ | ✅ |
200
- | **Install prompt** | ❌ | ✅ | ✅ |
201
- | **Icons display** | ❌ | ✅ | ✅ |
202
- | **Full-screen mode** | ❌ | ✅ | ✅ |
203
-
204
- ---
205
-
206
- ## What You Should Do
207
-
208
- ### For Development:
209
- ✅ **Just develop normally on localhost**
210
- - Game features work fine
211
- - Challenge Mode works (if .env configured)
212
- - PWA features won't work, but that's okay
213
- - Test PWA when you deploy
214
-
215
- ### For PWA Testing:
216
- ✅ **Use ngrok for quick local PWA testing**
217
- - 5 minutes to setup
218
- - Full PWA functionality
219
- - Test on real phone
220
-
221
- ### For Production:
222
- ✅ **Deploy to HuggingFace Spaces**
223
- - PWA works automatically
224
- - No configuration needed
225
- - `/app/static/` path works out-of-the-box
226
-
227
- ---
228
-
229
- ## Bottom Line
230
-
231
- **Your question:** "Should I see something at the bottom of the screen?"
232
-
233
- **Answer:**
234
-
235
- 1. **PWA install prompt**: ❌ Not on `localhost:8501` (Streamlit limitation)
236
- - **Will work** on HF Spaces production deployment ✅
237
- - **Will work** with ngrok HTTPS tunnel ✅
238
-
239
- 2. **Challenge Mode banner**: ✅ Should appear at TOP (not bottom)
240
- - Check if `?game_id=hDjsB_dl` exists in your HF repo
241
- - Check browser console for errors
242
- - Verify `.env` has `HF_API_TOKEN` configured
243
-
244
- 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!
245
-
246
- ---
247
-
248
- ## Quick Test Command
249
-
250
- ```bash
251
- # Check if .env is configured:
252
- cat .env | grep HF_
253
-
254
- # Should show:
255
- # HF_API_TOKEN=hf_xxxxx
256
- # HF_REPO_ID=Surn/Storage
257
- # SPACE_NAME=Surn/BattleWords
258
-
259
- # If missing, Challenge Mode won't work locally
260
- ```
261
-
262
- ---
263
-
264
- **Next Steps:**
265
- 1. Test game functionality on localhost ✅
266
- 2. Deploy to HF Spaces for PWA testing ✅
267
- 3. Or install ngrok for local PWA testing ✅
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
PWA_INSTALL_GUIDE.mdx DELETED
@@ -1,208 +0,0 @@
1
- # BattleWords PWA Installation Guide
2
-
3
- BattleWords can now be installed as a Progressive Web App (PWA) on your mobile device or desktop, giving you a native app experience directly from your browser!
4
-
5
- ## What is a PWA?
6
-
7
- A Progressive Web App allows you to:
8
- - ✅ Install BattleWords on your home screen (Android/iOS)
9
- - ✅ Run in full-screen mode without browser UI
10
- - ✅ Access the app quickly from your app drawer
11
- - ✅ Get automatic updates (always the latest version)
12
- - ✅ Basic offline functionality (cached assets)
13
-
14
- ## Installation Instructions
15
-
16
- ### Android (Chrome, Edge, Samsung Internet)
17
-
18
- 1. **Visit the app**: Open https://surn-battlewords.hf.space in Chrome
19
- 2. **Look for the install prompt**: A banner will appear at the bottom saying "Add BattleWords to Home screen"
20
- 3. **Tap "Add"** or **"Install"**
21
- 4. **Alternative method** (if no prompt):
22
- - Tap the **three-dot menu** (⋮) in the top-right
23
- - Select **"Install app"** or **"Add to Home screen"**
24
- - Tap **"Install"**
25
- 5. **Launch**: Find the BattleWords icon on your home screen and tap to open!
26
-
27
- **Result**: The app opens full-screen without the browser address bar, just like a native app.
28
-
29
- ---
30
-
31
- ### iOS (Safari)
32
-
33
- **Note**: iOS requires using Safari browser (Chrome/Firefox won't work for PWA installation)
34
-
35
- 1. **Visit the app**: Open https://surn-battlewords.hf.space in Safari
36
- 2. **Tap the Share button**: The square with an arrow pointing up (at the bottom of the screen)
37
- 3. **Scroll down** and tap **"Add to Home Screen"**
38
- 4. **Edit the name** (optional): You can rename it from "BattleWords" if desired
39
- 5. **Tap "Add"** in the top-right corner
40
- 6. **Launch**: Find the BattleWords icon on your home screen and tap to open!
41
-
42
- **Result**: The app opens in standalone mode, similar to a native iOS app.
43
-
44
- ---
45
-
46
- ### Desktop (Chrome, Edge, Brave)
47
-
48
- 1. **Visit the app**: Open https://surn-battlewords.hf.space
49
- 2. **Look for the install icon**:
50
- - Chrome/Edge: Click the **install icon** (⊕) in the address bar
51
- - Or click the **three-dot menu** → **"Install BattleWords"**
52
- 3. **Click "Install"** in the confirmation dialog
53
- 4. **Launch**:
54
- - Windows: Find BattleWords in Start Menu or Desktop
55
- - Mac: Find BattleWords in Applications folder
56
- - Linux: Find in application launcher
57
-
58
- **Result**: BattleWords opens in its own window, separate from your browser.
59
-
60
- ---
61
-
62
- ## Features of the PWA
63
-
64
- ### Works Immediately ✅
65
- - Full game functionality (reveal cells, guess words, scoring)
66
- - Challenge Mode (create and play shared challenges)
67
- - Sound effects and background music
68
- - Ocean-themed animated background
69
- - All current features preserved
70
-
71
- ### Offline Support 🌐
72
- - App shell cached for faster loading
73
- - Icons and static assets available offline
74
- - **Note**: Challenge Mode requires internet connection (needs to fetch/save from HuggingFace)
75
-
76
- ### Updates 🔄
77
- - Automatic updates when you open the app
78
- - Always get the latest features and bug fixes
79
- - No manual update process needed
80
-
81
- ### Privacy & Security 🔒
82
- - No new data collection (same as web version)
83
- - Environment variables stay on server (never exposed to PWA)
84
- - Service worker only caches public assets
85
- - All game data in Challenge Mode handled server-side
86
-
87
- ---
88
-
89
- ## Uninstalling the PWA
90
-
91
- ### Android
92
- 1. Long-press the BattleWords icon
93
- 2. Tap "Uninstall" or drag to "Remove"
94
-
95
- ### iOS
96
- 1. Long-press the BattleWords icon
97
- 2. Tap "Remove App"
98
- 3. Confirm "Delete App"
99
-
100
- ### Desktop
101
- - **Chrome/Edge**: Go to `chrome://apps` or `edge://apps`, right-click BattleWords, select "Uninstall"
102
- - **Windows**: Settings → Apps → BattleWords → Uninstall
103
- - **Mac**: Delete from Applications folder
104
-
105
- ---
106
-
107
- ## Troubleshooting
108
-
109
- ### "Install" option doesn't appear
110
- - **Android**: Make sure you're using Chrome, Edge, or Samsung Internet (not Firefox)
111
- - **iOS**: Must use Safari browser
112
- - **Desktop**: Check if you're using a supported browser (Chrome, Edge, Brave)
113
- - Try refreshing the page (the install prompt may take a moment to appear)
114
-
115
- ### App won't open after installation
116
- - Try uninstalling and reinstalling
117
- - Clear browser cache and try again
118
- - Make sure you have internet connection for first launch
119
-
120
- ### Service worker errors in console
121
- - This is normal during development
122
- - The app will still function without the service worker
123
- - Full offline support requires the service worker to register successfully
124
-
125
- ### Icons don't show up correctly
126
- - Wait a moment after installation (icons may take time to download)
127
- - Try force-refreshing the PWA (close and reopen)
128
-
129
- ---
130
-
131
- ## Technical Details
132
-
133
- ### Files Added for PWA Support
134
-
135
- ```
136
- battlewords/
137
- ├── static/
138
- │ ├── manifest.json # PWA configuration
139
- │ ├── service-worker.js # Offline caching logic
140
- │ ├── icon-192.png # App icon (small)
141
- │ └── icon-512.png # App icon (large)
142
- └── ui.py # Added PWA meta tags
143
- ```
144
-
145
- ### What's Cached Offline
146
-
147
- - App shell (HTML structure)
148
- - Icons (192x192, 512x512)
149
- - Manifest file
150
- - Previous game states (if you were playing before going offline)
151
-
152
- ### What Requires Internet
153
-
154
- - Creating new challenges
155
- - Submitting results to leaderboards
156
- - Loading shared challenges
157
- - Downloading word lists (first time)
158
- - Fetching game updates
159
-
160
- ---
161
-
162
- ## Comparison: PWA vs Native App
163
-
164
- | Feature | PWA | Native App |
165
- |---------|-----|------------|
166
- | Installation | Quick (1 tap) | Slow (app store) |
167
- | Size | ~5-10 MB | ~15-30 MB |
168
- | Updates | Automatic | Manual |
169
- | Platform support | Android, iOS, Desktop | Separate builds |
170
- | Offline mode | Partial | Full |
171
- | Performance | 90% of native | 100% |
172
- | App store presence | No | Yes |
173
- | Development time | 2-4 hours ✅ | 40-60 hours per platform |
174
-
175
- ---
176
-
177
- ## Feedback
178
-
179
- If you encounter issues installing or using the PWA, please:
180
- 1. Check the browser console for errors (F12 → Console tab)
181
- 2. Report issues at: https://github.com/Oncorporation/BattleWords/issues
182
- 3. Include: Device type, OS version, browser version, and error messages
183
-
184
- ---
185
-
186
- ## For Developers
187
-
188
- To regenerate the PWA icons:
189
- ```bash
190
- python3 generate_pwa_icons.py
191
- ```
192
-
193
- To modify PWA behavior:
194
- - Edit `battlewords/static/manifest.json` (app metadata)
195
- - Edit `battlewords/static/service-worker.js` (caching logic)
196
- - Edit `battlewords/ui.py` (PWA meta tags, lines 34-86)
197
-
198
- To test PWA locally:
199
- ```bash
200
- streamlit run app.py
201
- # Open http://localhost:8501 in Chrome
202
- # Chrome DevTools → Application → Manifest (verify manifest.json loads)
203
- # Chrome DevTools → Application → Service Workers (verify registration)
204
- ```
205
-
206
- ---
207
-
208
- **Enjoy BattleWords as a native-like app experience! 🎮🌊**
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
README.md CHANGED
@@ -19,22 +19,19 @@ tags:
19
 
20
  > **This project is used by [huggingface.co](https://huggingface.co/spaces/Surn/BattleWords) as a demonstration of interactive word games in Python.**
21
 
22
- **Current Version:** 0.2.37
23
- **Last Updated:** 2026-02-10
 
24
 
25
  BattleWords is a vocabulary learning game inspired by classic Battleship mechanics. The objective is to discover hidden words on a grid, earning points for strategic guessing before all letters are revealed.
26
 
27
  ## Recent Changes
28
- - version 0.2.37
29
- - Footer navigation uses query-parameter links (`?page=play|leaderboard|settings`) via custom footer links
30
- - In-game "New Game" button behavior is now navigation-style (rerun) rather than a full session reset
31
- - Game over flow continues to support integrated challenge submission and optional share-link visibility
32
-
33
- - version 0.2.36
34
- - Added `user_id` and `subscription_level` to game state/results for user-specific features
35
- - Expanded `game_mode` options (includes `easy`)
36
- - UI polish: improved guess input styling (placeholder, typography) and layout tweaks (dialog/footer)
37
- - Conditional rendering for subscription-based features (difficulty and timer display)
38
 
39
 
40
  ## Features
@@ -50,57 +47,17 @@ BattleWords is a vocabulary learning game inspired by classic Battleship mechani
50
  - 10 incorrect guess limit per game
51
  - Two game modes: Classic (chain guesses) and Too Easy (single guess per reveal)
52
 
53
- ### Audio & Visuals
54
  - Ocean-themed gradient background with wave animations
55
- - Background music system (toggleable with volume control)
56
- - Sound effects for hits, misses, correct/incorrect guesses
57
  - Responsive UI built with Streamlit
58
 
59
- ### Customization
60
  - Multiple word lists (classic, fourth_grade, wordlist)
61
- - Wordlist controls (picker + sort/filter)
62
- - Configurable word spacing (0-2 cells between words)
63
- - Audio volume controls (music and effects separate)
64
-
65
- ### ✅ Challenge Mode (v0.2.20+)
66
- - **Shareable challenge links** via short URLs (`?game_id=<sid>`)
67
- - **Multi-user leaderboards** sorted by score and time
68
- - **Remote storage** via Hugging Face datasets
69
- - **Word list difficulty calculation** and display
70
- - **Submit results** to existing challenges or create new ones
71
- - **Top 5 leaderboard** display in Challenge Mode banner
72
- - **"Show Challenge Share Links" toggle** (default OFF) to control URL visibility
73
- - Each player gets different random words from the same wordlist
74
-
75
- ### UI & Navigation Updates (v0.2.35+)
76
- - **Spinner implementation** for loading states
77
- - **Updated graphics** for improved visuals
78
- - **Favicon** added for browser tab branding
79
- - **Footer navigation** links to Leaderboard, Play, and Settings pages (not sidebar)
80
- - **Leaderboard navigation** is now in the footer menu
81
- - **Leaderboard page routing** uses query parameters and custom navigation links
82
- - **Game over dialog** integrates leaderboard submission and displays qualification results
83
- - **Leaderboard page routing** uses query parameters and custom navigation links
84
- - **Game over dialog** integrates leaderboard submission and displays qualification results
85
 
86
  ### Deployment & Technical
87
  - **Dockerfile-based deployment** supported for Hugging Face Spaces and other container platforms
88
- - **Environment variables** for Challenge Mode (HF_API_TOKEN, HF_REPO_ID, SPACE_NAME)
89
- - Works offline without HF credentials (Challenge Mode features disabled gracefully)
90
-
91
- ### Progressive Web App (PWA) - v0.2.28
92
- - Installable on desktop and mobile from your browser:
93
- - Chrome/Edge: Menu → “Install app”
94
- - Android Chrome: “Add to Home screen”
95
- - iOS Safari: Share → “Add to Home Screen”
96
- - Includes `service worker` and `manifest.json` with basic offline caching of static assets
97
- - See `INSTALL_GUIDE.md` for platform-specific steps
98
-
99
- ### Planned (v0.3.0)
100
- - Local persistent storage for personal game history
101
- - Personal high scores sidebar (offline-capable)
102
- - Player statistics tracking
103
- - Deterministic seed UI for custom puzzles
104
 
105
  ### Beta (v0.5.0)
106
  - Word overlaps on shared letters (crossword-style)
@@ -113,10 +70,6 @@ BattleWords is a vocabulary learning game inspired by classic Battleship mechani
113
  - Multiple difficulty levels
114
  - Internationalization (i18n) support
115
 
116
- ## Challenge Mode & Leaderboard
117
-
118
- 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.
119
-
120
  ## Installation
121
  1. Clone the repository:
122
  ```
@@ -162,26 +115,8 @@ docker build -t battlewords .
162
  docker run -p8501:8501 battlewords
163
  ```
164
 
165
- ### Environment Variables (for Challenge Mode)
166
-
167
- Challenge Mode requires a `.env` file in the project root with HuggingFace Hub credentials:
168
-
169
- ```bash
170
- # Required for Challenge Mode
171
- HF_API_TOKEN=hf_xxxxxxxxxxxxxxxxxxxxx # or HF_TOKEN
172
- HF_REPO_ID=YourUsername/YourRepo # Target HF dataset repo
173
- SPACE_NAME=YourUsername/BattleWords # Your HF Space name
174
-
175
- # Optional
176
- CRYPTO_PK= # Reserved for future signing
177
- ```
178
-
179
- **How to get your HF_API_TOKEN:**
180
- 1. Go to https://huggingface.co/settings/tokens
181
- 2. Create a new token with `write` access
182
- 3. Add to `.env` file as `HF_API_TOKEN=hf_...`
183
-
184
- **Note:** The app works without these variables, but Challenge Mode features (sharing, leaderboards) will be disabled.
185
 
186
  ## Folder Structure
187
 
@@ -192,12 +127,8 @@ CRYPTO_PK= # Reserved for future signing
192
  - `generator.py` – word placement logic
193
  - `logic.py` – game mechanics (reveal, guess, scoring)
194
  - `ui.py` – Streamlit UI composition
195
- - `game_storage.py` – Hugging Face remote storage integration and challenge sharing
196
- - `local_storage.py` – local JSON storage for results and high scores
197
- - `storage.py` – (legacy) local storage and high scores
198
  - `words/wordlist.txt` – candidate words
199
- - `specs/` – documentation (`specs.md`, `requirements.md`)
200
- - `tests/` – unit tests
201
 
202
  ## How to Play
203
 
@@ -205,251 +136,15 @@ CRYPTO_PK= # Reserved for future signing
205
  2. After revealing a letter, enter a guess for a word in the text box.
206
  3. Earn points for correct guesses and bonus points for unrevealed letters.
207
  4. **The game ends when all six words are found or all word letters are revealed. Your score tier is displayed.**
208
- 5. **To play a shared challenge, use a link with `?game_id=<sid>`. Your result will be added to the challenge leaderboard.**
209
 
210
  ## Changelog
211
 
212
- ### v0.3.0 (planned)
213
- - Local persistent storage for personal game history (offline-capable)
214
- - Personal high scores sidebar with filtering
215
- - Player statistics tracking (games played, averages, bests)
216
-
217
- - version 0.2.37
218
- - Footer navigation uses query-parameter links (Play/Leaderboard/Settings)
219
- - Play navigation behaves like a route change (query param) rather than invoking the New Game reset callback
220
-
221
- - version 0.2.36
222
- - Added `user_id` and `subscription_level` to game state/results for user-specific features
223
- - Expanded `game_mode` options (includes `easy`)
224
- - UI polish: improved guess input styling (placeholder, typography) and layout tweaks (dialog/footer)
225
- - Conditional rendering for subscription-based features (difficulty and timer display)
226
-
227
- - version 0.2.35
228
- - New spinner implementation for loading states
229
- - Updated graphics and improved visual polish
230
- - Favicon added for browser tab branding
231
- - Leaderboard navigation moved to footer menu (not sidebar)
232
- - Game over dialog integrates leaderboard submission and displays qualification results
233
- - Leaderboard page routing uses query parameters and custom navigation links
234
- - Footer navigation links to Leaderboard, Play, and Settings pages
235
- - Minimal documentation and UI updates for these changes
236
-
237
- > For the detailed porting checklist (Wrdler → BattleWords), see `specs/upgrade.mdx`.
238
-
239
- - 0.2.33
240
- - Simplified UI and improved gameplay experience
241
- - Enhanced subheader text clarity
242
- - Refined radar animation for smoother visuals
243
- - Disabled guess input when appropriate
244
- - Removed timer display
245
- - Hid leaderboard and settings footer links
246
- - Integrated game-over flow with score submission
247
- - Improved styling for cleaner, faster transitions
248
-
249
- -0.2.32
250
- - updated .md spec files to .mdx
251
-
252
- -0.2.31
253
- - Updated README.md with new Streamlit SDK (1.52.1) and Python (3.12.8).
254
- - Incremented version to 0.2.31 in `__init__.py` and `pyproject.toml`.
255
- - Enhanced `audio.py` with error handling and smoother playback.
256
- - Added `validate_puzzle` and `filter_word_file` functions in `generator.py`.
257
- - Introduced new constants and `load_settings` in `constants.py`.
258
- - Improved settings page with wordlist controls and audio settings.
259
- - Refined UI in `ui.py` with compact layouts and performance optimizations.
260
- - Modularized helpers in `ui_helpers.py` and reintroduced PWA support.
261
-
262
- -0.2.29
263
- - change difficulty calculation
264
- - add test_compare_difficulty_functions
265
- - streamlit version update to 1.51.0
266
-
267
- -0.2.28
268
- - PWA INSTALL_GUIDE.md added
269
- - PWA implementation with service worker and manifest.json added
270
- - Footer navigation links to Leaderboard, Play, and Settings pages
271
- - Leaderboard navigation moved to footer menu (not sidebar)
272
- - Leaderboard page routing uses query parameters and custom navigation links
273
- - Game over dialog integrates leaderboard submission and displays qualification results
274
-
275
- -0.2.27
276
- - Add "Show Challenge Share Links" setting (default: off)
277
- - When disabled:
278
- - Header Challenge Mode: hides the Share Challenge link
279
- - Game Over: allows submitting results but suppresses displaying the generated share URL
280
- - The setting is saved in session state and preserved across "New Game"
281
- - No changes to game logic or storage; only UI visibility behavior
282
-
283
- -0.2.26
284
- - fix copy/share link button
285
-
286
- -0.2.25
287
- - Share challenge from expander
288
- - fix incorrect guess overlap of guess box
289
-
290
- -0.2.24
291
- - compress height
292
- - change incorrect guess tooltip location
293
- - update final screen layout
294
- - add word difficulty formula
295
- - update documentation
296
-
297
- -0.2.23
298
- - Update miss and correct guess sound effects to new versions
299
- - allow iframe hosted version to pass url as a query string parameter (&iframe_host=https%3A%2F%2Fwww.battlewords.com%2Fplaynow.html) url encoding is required.
300
- - minimal security added to prevent users from changing the options in a challenge.
301
-
302
- -0.2.22
303
- - fix challenge mode link
304
- - challenge mode UI improvements
305
-
306
- -0.2.21
307
- - fix tests
308
-
309
- -0.2.20
310
- - Remote Storage game_id:
311
- - Per-game JSON settings uploaded to a storage server (Hugging Face repo) under unique `games/{uid}/settings.json`
312
- - A shortened URL id (sid) is generated; shareable link: `?game_id=<sid>`
313
- - On load with `game_id`, the app resolves sid to the JSON and applies word_list, game_mode, grid_size, puzzle options
314
- - High Scores: add remote `highscores/highscores.json` (repo) alongside local highscores
315
- - Dependencies: add `huggingface_hub` and `python-dotenv`
316
- - Env: `.env` should include `HF_API_TOKEN` (or `HF_TOKEN`), `CRYPTO_PK`, `HF_REPO_ID`, `SPACE_NAME`
317
 
318
- ### Environment Variables
319
- - HF_API_TOKEN or HF_TOKEN: HF Hub access token
320
- - CRYPTO_PK: reserved for signing (optional)
321
- - HF_REPO_ID: e.g., Surn/Storage
322
- - SPACE_NAME: e.g., Surn/BattleWords
323
-
324
- ### Remote Storage Structure
325
- - shortener.json
326
- - games/{uid}/settings.json
327
- - highscores/highscores.json
328
-
329
- Note
330
- - `battlewords/storage.py` remains local-only storage; a separate HF integration wrapper is provided as `game_storage.py` for remote challenge mode.
331
-
332
- -0.2.19
333
- - Fix music and sound effect volume issues
334
- - Update documentation for proposed new features
335
-
336
- -0.2.18
337
- - Fix sound effect volume wiring and apply volume to all effects (hit/miss/correct/incorrect)
338
- - Respect "Enable music" and "Volume" when playing congratulations music and when resuming background music (uses selected track)
339
- - Add "Enable Sound Effects" checkbox (on by default) and honor it across the app
340
- - Save generated effects to `assets/audio/effects/` so they are picked up by the app
341
- - Add `requests` dependency for sound effect generation
342
-
343
- -0.2.17
344
- - documentation updates and corrections
345
- - updated CLAUDE.md with accurate feature status and project structure
346
- - clarified v0.3.0 planned features vs current implementation
347
-
348
- -0.2.16
349
- - replace question marks in score panel with underscores
350
- - add option to toggle incorrect guess history display in settings (enabled by default)
351
- - game over popup updated to ensure it is fully visible on screen
352
-
353
- -0.2.15
354
- - fix music playing after game end
355
- - change incorrect guesses icon
356
- - fix sound effect and music volume issues
357
-
358
- -0.2.14
359
- - bug fix on final score popup
360
- - score panel alignment centered
361
- - change incorrect guess history UI
362
-
363
- -0.2.13
364
- - upgrade background ocean view
365
- - apply volume control to sound effects
366
-
367
- -0.2.12
368
- - fix music looping on congratulations screen
369
-
370
- -0.2.11
371
- - update timer to be live during gameplay, but reset with each action
372
- - compact design
373
- - remove fullscreen image tooltip
374
-
375
- -0.2.10
376
- - reduce sonar graphic size
377
- - update music and special effects file locations
378
- - remove some music and sound effects
379
- - change Guess Text input color
380
- - incorrect guess UI update
381
- - scoreboard update
382
-
383
- -0.2.9
384
- - fix sonar grid alignment issue on some browsers
385
- - When all letters of a word are revealed, it is automatically marked as found.
386
-
387
- -0.2.8
388
- - Add10 incorrect guess limit per game
389
-
390
- -0.2.7
391
- - fix background music playback issue on some browsers
392
- - add sound effects
393
- - enhance sonar grid visualization
394
- - add claude.md documentation
395
-
396
- -0.2.6
397
- - fix sonar grid alignment
398
- - improve score summary layout and styling
399
- - Add timer to game display in sidebar
400
-
401
- -0.2.5
402
- - fix finale pop up issue
403
- - make grid cells square on wider devices
404
-
405
- -0.2.4
406
- - Add music files to repo
407
- - disable music by default
408
-
409
- -0.2.3
410
- - Update version information display
411
- - adjust sonar grid alignment
412
- - fix settings scroll issue
413
-
414
- -0.2.2
415
- - Add Musical background and settings to toggle sound on/off.
416
-
417
- -0.2.1
418
- - Add Theme toggle (light/dark/custom) in sidebar.
419
-
420
- -0.2.0
421
- - Added a loading screen when starting a new game.
422
- - Added a congratulations screen with your final score and tier when the game ends.
423
-
424
- -0.1.13
425
- - Improved score summary layout for clarity and style.
426
-
427
- -0.1.12
428
- - Improved score summary layout and styling.
429
- - Enhanced overall appearance and readability.
430
-
431
- -0.1.11
432
- - Game now ends when all words are found or revealed.
433
- - Added word spacing logic and improved settings.
434
-
435
- -0.1.10
436
- - Added game mode selector and improved UI feedback.
437
-
438
- -0.1.9
439
- - Improved background and mobile layout.
440
-
441
- -0.1.8
442
- - Updated to Python3.12.
443
-
444
- -0.1.5
445
- - Added hit/miss indicator and improved grid feedback.
446
-
447
- -0.1.4
448
- - Radar visualization improved and mobile layout enhanced.
449
-
450
- -0.1.3
451
- - Added wordlist picker and sort feature.
452
- - Improved score panel and final score display.
453
 
454
  ## Known Issues / TODO
455
 
@@ -514,95 +209,4 @@ Spaces can be embedded in other sites using an `<iframe>`:
514
 
515
  For full configuration options, see [Spaces Config Reference](https://huggingface.co/docs/hub/spaces-config-reference) and [Streamlit SDK Guide](https://huggingface.co/docs/hub/spaces-sdks-streamlit).
516
 
517
- # Assets Setup
518
-
519
- To fully experience BattleWords, especially the audio elements, ensure you set up the following assets:
520
-
521
- - Place your background music `.mp3` files in `battlewords/assets/audio/music/` to enable music.
522
- - Place your sound effect files (`.mp3` or `.wav`) in `battlewords/assets/audio/effects/` for sound effects.
523
-
524
- Refer to the documentation for guidance on compatible audio formats and common troubleshooting tips.
525
-
526
- # Sound Asset Generation
527
-
528
- To generate and save custom sound effects for BattleWords, you can use the `generate_sound_effect` function.
529
-
530
- ## Function: `generate_sound_effect`
531
-
532
- ```python
533
- def generate_sound_effect(effect: str, save_to_assets: bool = False, use_api: str = "huggingface") -> str:
534
- """
535
- Generate a sound effect and save it as a file.
536
-
537
- Parameters:
538
- - `effect`: Name of the effect to generate.
539
- - `save_to_assets`: If `True`, saves the effect to the assets directory;
540
- if `False`, saves to a temporary location. Default is `False`.
541
- - `use_api`: API to use for generation. Options are "huggingface" or "replicate". Default is "huggingface".
542
-
543
- Returns:
544
- - File path to the saved sound effect.
545
- ```
546
-
547
- ## Parameters
548
-
549
- - `effect`: The name of the sound effect you want to generate (e.g., "explosion", "powerup").
550
- - `save_to_assets` (optional): Set to `True` to save the generated sound effect to the game's assets directory. If `False`, the effect is saved to a temporary location. Default is `False`.
551
- - `use_api` (optional): The API to use for generating the sound. Options are `"huggingface"` or `"replicate"`. Default is `"huggingface"`.
552
-
553
- ## Returns
554
-
555
- - The function returns the file path to the saved sound effect, whether it's in the assets directory or a temporary location.
556
-
557
- ## Usage Example
558
-
559
- To generate a sound effect and save it to the assets directory:
560
-
561
- ```python
562
- generate_sound_effect("your_effect_name", save_to_assets=True)
563
- ```
564
-
565
- To generate a sound effect and keep it in a temporary location:
566
-
567
- ```python
568
- temp_path = generate_sound_effect("your_effect_name", save_to_assets=False)
569
- ```
570
-
571
- ## Note
572
-
573
- Ensure you have the necessary permissions and API access (if required) to use the sound generation service. Generated sounds are subject to the terms of use of the respective API.
574
-
575
- For any issues or enhancements, please refer to the project documentation or contact the project maintainer.
576
-
577
- Happy gaming and sound designing!
578
-
579
- ## What's New in v0.2.20-0.2.27: Challenge Mode 🎯
580
-
581
- ### Remote Challenge Sharing 🔗
582
- - Share challenges with friends via short URLs (`?game_id=<sid>`)
583
- - Each player gets different random words from the same wordlist
584
- - Multi-user leaderboards sorted by score and time
585
- - Word list difficulty calculation and display
586
- - Compare your performance against others!
587
-
588
- ### Leaderboards 🏆
589
- - Top 5 players displayed in Challenge Mode banner
590
- - Results sorted by: highest score → fastest time → highest difficulty
591
- - Submit results to existing challenges or create new ones
592
- - Player names supported (optional, defaults to "Anonymous")
593
-
594
- ### Remote Storage 💾
595
- - Challenge data stored in Hugging Face dataset repositories
596
- - Automatic save on game completion (with user consent)
597
- - "Show Challenge Share Links" toggle for privacy control (default OFF)
598
- - Works offline when HF credentials not configured
599
-
600
- ## What's Planned for v0.3.0
601
-
602
- ### Local Player History (Coming Soon)
603
- - Personal game results saved locally in `~/.battlewords/data/`
604
- - Offline-capable high score tracking
605
- - Player statistics (games played, averages, bests)
606
- - Privacy-first: no cloud dependency for personal data
607
- - Easy data management (delete `~/.battlewords/data/` to reset)
608
 
 
19
 
20
  > **This project is used by [huggingface.co](https://huggingface.co/spaces/Surn/BattleWords) as a demonstration of interactive word games in Python.**
21
 
22
+ **Current Version:** 0.2.37 BASIC
23
+ **Branch:** `basic`
24
+ **Last Updated:** 2026-02-11
25
 
26
  BattleWords is a vocabulary learning game inspired by classic Battleship mechanics. The objective is to discover hidden words on a grid, earning points for strategic guessing before all letters are revealed.
27
 
28
  ## Recent Changes
29
+ - version 0.2.37 (basic)
30
+ - Basic branch scope enforced: single play experience only
31
+ - No query-param routing, no footer navigation, no sidebar
32
+ - Challenge mode / share links / remote storage removed
33
+ - PWA support removed
34
+ - No audio system
 
 
 
 
35
 
36
 
37
  ## Features
 
47
  - 10 incorrect guess limit per game
48
  - Two game modes: Classic (chain guesses) and Too Easy (single guess per reveal)
49
 
50
+ ### Visuals
51
  - Ocean-themed gradient background with wave animations
 
 
52
  - Responsive UI built with Streamlit
53
 
54
+ ### Word lists
55
  - Multiple word lists (classic, fourth_grade, wordlist)
56
+ - Word lists are loaded locally from `battlewords/words/`
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
 
58
  ### Deployment & Technical
59
  - **Dockerfile-based deployment** supported for Hugging Face Spaces and other container platforms
60
+ - No special environment variables required
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
 
62
  ### Beta (v0.5.0)
63
  - Word overlaps on shared letters (crossword-style)
 
70
  - Multiple difficulty levels
71
  - Internationalization (i18n) support
72
 
 
 
 
 
73
  ## Installation
74
  1. Clone the repository:
75
  ```
 
115
  docker run -p8501:8501 battlewords
116
  ```
117
 
118
+ ### Environment Variables
119
+ None required.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
 
121
  ## Folder Structure
122
 
 
127
  - `generator.py` – word placement logic
128
  - `logic.py` – game mechanics (reveal, guess, scoring)
129
  - `ui.py` – Streamlit UI composition
 
 
 
130
  - `words/wordlist.txt` – candidate words
131
+ - `specs/` – documentation (`specs/specs.mdx`, `specs/requirements.mdx`)
 
132
 
133
  ## How to Play
134
 
 
136
  2. After revealing a letter, enter a guess for a word in the text box.
137
  3. Earn points for correct guesses and bonus points for unrevealed letters.
138
  4. **The game ends when all six words are found or all word letters are revealed. Your score tier is displayed.**
139
+ 5. Start a new run with **New Game**.
140
 
141
  ## Changelog
142
 
143
+ - version 0.2.37 (basic)
144
+ - Removed Challenge Mode / Share Links / Remote Storage (HF)
145
+ - Removed PWA support
146
+ - Documentation trimmed to basic branch scope
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
148
 
149
  ## Known Issues / TODO
150
 
 
209
 
210
  For full configuration options, see [Spaces Config Reference](https://huggingface.co/docs/hub/spaces-config-reference) and [Streamlit SDK Guide](https://huggingface.co/docs/hub/spaces-sdks-streamlit).
211
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
 
battlewords/__init__.py CHANGED
@@ -1,2 +1,2 @@
1
- __version__ = "0.2.37"
2
- __all__ = ["models", "generator", "logic", "ui", "game_storage", "modules"]
 
1
+ __version__ = "0.2.37 BASIC"
2
+ __all__ = ["models", "generator", "logic", "ui", "modules"]
battlewords/game_storage.py DELETED
@@ -1,581 +0,0 @@
1
- # file: battlewords/game_storage.py
2
- """
3
- BattleWords-specific storage wrapper for HuggingFace storage operations.
4
-
5
- This module provides high-level functions for saving and loading BattleWords games
6
- using the shared storage module from battlewords.modules.
7
- """
8
- __version__ = "0.1.3"
9
-
10
- import json
11
- import tempfile
12
- import os
13
- from datetime import datetime, timezone
14
- from typing import Dict, Any, List, Optional, Tuple
15
- import logging
16
- from urllib.parse import unquote
17
-
18
- from battlewords.modules import (
19
- upload_files_to_repo,
20
- gen_full_url,
21
- HF_REPO_ID,
22
- SHORTENER_JSON_FILE,
23
- SPACE_NAME,
24
- _list_repo_folders,
25
- _list_repo_files_in_folder
26
- )
27
- from battlewords.modules.storage import _get_json_from_repo
28
- from battlewords.local_storage import save_json_to_file
29
- from battlewords.word_loader import compute_word_difficulties
30
-
31
- # Configure logging
32
- logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
33
- logger = logging.getLogger(__name__)
34
-
35
-
36
- def list_hf_folders(path_prefix: str, repo_id: Optional[str] = None) -> List[str]:
37
- """
38
- List unique folders under a path in the HF repository.
39
- Useful for folder-based discovery of leaderboards (e.g., discovery by date).
40
-
41
- Args:
42
- path_prefix: The prefix path to list folders under
43
- repo_id: Optional HF repository ID (uses default if None)
44
-
45
- Returns:
46
- List[str]: Sorted list of folder names
47
- """
48
- if repo_id is None:
49
- repo_id = HF_REPO_ID
50
- return _list_repo_folders(repo_id=repo_id, path_prefix=path_prefix)
51
-
52
-
53
- def list_hf_files(folder_path: str, repo_id: Optional[str] = None) -> List[str]:
54
- """
55
- List file names directly in a folder in the HF repository.
56
-
57
- Args:
58
- folder_path: The folder path to list files from
59
- repo_id: Optional HF repository ID (uses default if None)
60
-
61
- Returns:
62
- List[str]: Sorted list of file names
63
- """
64
- if repo_id is None:
65
- repo_id = HF_REPO_ID
66
- return _list_repo_files_in_folder(repo_id=repo_id, folder_path=folder_path)
67
-
68
-
69
- def generate_uid() -> str:
70
- """
71
- Generate a unique identifier for a game.
72
-
73
- Format: YYYYMMDDTHHMMSSZ-RANDOM
74
- Example: 20250123T153045Z-A7B9C2
75
-
76
- Returns:
77
- str: Unique game identifier
78
- """
79
- import random
80
- import string
81
-
82
- timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
83
- random_suffix = ''.join(random.choices(string.ascii_uppercase + string.digits, k=6))
84
- return f"{timestamp}-{random_suffix}"
85
-
86
-
87
- def serialize_game_settings(
88
- word_list: List[str],
89
- username: str,
90
- score: int,
91
- time_seconds: int,
92
- game_mode: str,
93
- grid_size: int = 12,
94
- spacer: int = 1,
95
- may_overlap: bool = False,
96
- wordlist_source: Optional[str] = None,
97
- challenge_id: Optional[str] = None
98
- ) -> Dict[str, Any]:
99
- """
100
- Serialize game settings into a JSON-compatible dictionary.
101
- Creates initial structure with one user's result.
102
- Each user has their own uid and word_list.
103
-
104
- Args:
105
- word_list: List of words used in THIS user's game
106
- username: Player's name
107
- score: Final score achieved
108
- time_seconds: Time taken to complete (in seconds)
109
- game_mode: Game mode ("classic" or "too_easy")
110
- grid_size: Grid size (default: 12)
111
- spacer: Word spacing configuration (0-2, default: 1)
112
- may_overlap: Whether words can overlap (default: False)
113
- wordlist_source: Source file name (e.g., "classic.txt")
114
- challenge_id: Optional challenge ID (generated if not provided)
115
-
116
- Returns:
117
- dict: Serialized game settings with users array
118
- """
119
- if challenge_id is None:
120
- challenge_id = generate_uid()
121
-
122
- # Try compute difficulty using the source file; optional
123
- difficulty_value: Optional[float] = None
124
- try:
125
- if wordlist_source:
126
- words_dir = os.path.join(os.path.dirname(__file__), "words")
127
- wordlist_path = os.path.join(words_dir, wordlist_source)
128
- if os.path.exists(wordlist_path):
129
- total_diff, _ = compute_word_difficulties(wordlist_path, words_array=word_list)
130
- difficulty_value = float(total_diff)
131
- except Exception as _e:
132
- # optional field, swallow errors
133
- difficulty_value = None
134
-
135
- # Build user result with desired ordering: uid, username, word_list, word_list_difficulty, score, time, timestamp
136
- user_result = {
137
- "uid": generate_uid(),
138
- "username": username,
139
- "word_list": word_list,
140
- }
141
- if difficulty_value is not None:
142
- user_result["word_list_difficulty"] = difficulty_value
143
- user_result["score"] = score
144
- user_result["time"] = time_seconds
145
- user_result["timestamp"] = datetime.now(timezone.utc).isoformat()
146
-
147
- settings = {
148
- "challenge_id": challenge_id,
149
- "game_mode": game_mode,
150
- "grid_size": grid_size,
151
- "puzzle_options": {
152
- "spacer": spacer,
153
- "may_overlap": may_overlap
154
- },
155
- "users": [user_result],
156
- "created_at": datetime.now(timezone.utc).isoformat(),
157
- "version": __version__
158
- }
159
-
160
- if wordlist_source:
161
- settings["wordlist_source"] = wordlist_source
162
-
163
- return settings
164
-
165
-
166
- def add_user_result_to_game(
167
- sid: str,
168
- username: str,
169
- word_list: List[str],
170
- score: int,
171
- time_seconds: int,
172
- repo_id: Optional[str] = None
173
- ) -> bool:
174
- """
175
- Add a user's result to an existing shared challenge.
176
- Each user gets their own uid and word_list.
177
-
178
- Args:
179
- sid: Short ID of the existing challenge
180
- username: Player's name
181
- word_list: List of words THIS user played
182
- score: Score achieved
183
- time_seconds: Time taken (seconds)
184
- repo_id: HF repository ID (uses HF_REPO_ID from env if None)
185
-
186
- Returns:
187
- bool: True if successfully added, False otherwise
188
- """
189
- if repo_id is None:
190
- repo_id = HF_REPO_ID
191
-
192
- logger.info(f"➕ Adding user result to challenge {sid}")
193
-
194
- try:
195
- # Load existing game settings
196
- settings = load_game_from_sid(sid, repo_id)
197
- if not settings:
198
- logger.error(f"❌ Challenge not found: {sid}")
199
- return False
200
-
201
- # Compute optional difficulty using the saved wordlist_source if available
202
- difficulty_value: Optional[float] = None
203
- try:
204
- wordlist_source = settings.get("wordlist_source")
205
- if wordlist_source:
206
- words_dir = os.path.join(os.path.dirname(__file__), "words")
207
- wordlist_path = os.path.join(words_dir, wordlist_source)
208
- if os.path.exists(wordlist_path):
209
- total_diff, _ = compute_word_difficulties(wordlist_path, words_array=word_list)
210
- difficulty_value = float(total_diff)
211
- except Exception:
212
- difficulty_value = None
213
-
214
- # Create new user result with ordering and optional difficulty
215
- user_result = {
216
- "uid": generate_uid(),
217
- "username": username,
218
- "word_list": word_list,
219
- }
220
- if difficulty_value is not None:
221
- user_result["word_list_difficulty"] = difficulty_value
222
- user_result["score"] = score
223
- user_result["time"] = time_seconds
224
- user_result["timestamp"] = datetime.now(timezone.utc).isoformat()
225
-
226
- # Add to users array
227
- if "users" not in settings:
228
- settings["users"] = []
229
- settings["users"].append(user_result)
230
-
231
- logger.info(f"👥 Now {len(settings['users'])} users in game")
232
-
233
- # Get the file path from the sid
234
- status, full_url = gen_full_url(
235
- short_url=sid,
236
- repo_id=repo_id,
237
- json_file=SHORTENER_JSON_FILE
238
- )
239
-
240
- if status != "success_retrieved_full" or not full_url:
241
- logger.error(f"❌ Could not resolve sid: {sid}")
242
- return False
243
-
244
- # Extract challenge_id from URL
245
- url_parts = full_url.split("/resolve/main/")
246
- if len(url_parts) != 2:
247
- logger.error(f"❌ Invalid URL format: {full_url}")
248
- return False
249
-
250
- file_path = url_parts[1] # e.g., "games/{challenge_id}/settings.json"
251
- challenge_id = file_path.split("/")[1] # Extract challenge_id
252
- folder_name = f"games/{challenge_id}"
253
-
254
- # Save updated settings back to HF
255
- try:
256
- with tempfile.TemporaryDirectory() as tmpdir:
257
- settings_path = save_json_to_file(settings, tmpdir, "settings.json")
258
- logger.info(f"📤 Updating {folder_name}/settings.json")
259
-
260
- response = upload_files_to_repo(
261
- files=[settings_path],
262
- repo_id=repo_id,
263
- folder_name=folder_name,
264
- repo_type="dataset"
265
- )
266
-
267
- logger.info(f"✅ User result added for {username}")
268
- return True
269
-
270
- except Exception as e:
271
- logger.error(f"❌ Failed to upload updated settings: {e}")
272
- return False
273
-
274
- except Exception as e:
275
- logger.error(f"❌ Failed to add user result: {e}")
276
- return False
277
-
278
-
279
- def save_game_to_hf(
280
- word_list: List[str],
281
- username: str,
282
- score: int,
283
- time_seconds: int,
284
- game_mode: str,
285
- grid_size: int = 12,
286
- spacer: int = 1,
287
- may_overlap: bool = False,
288
- repo_id: Optional[str] = None,
289
- wordlist_source: Optional[str] = None
290
- ) -> Tuple[str, Optional[str], Optional[str]]:
291
- """
292
- Save game settings to HuggingFace repository and generate shareable URL.
293
- Creates a new game entry with the first user's result.
294
-
295
- This function:
296
- 1. Generates a unique UID for the game
297
- 2. Serializes game settings to JSON with first user
298
- 3. Uploads settings.json to HF repo under games/{uid}/
299
- 4. Creates a shortened URL (sid) for sharing
300
- 5. Returns the full URL and short ID
301
-
302
- Args:
303
- word_list: List of words used in the game
304
- username: Player's name
305
- score: Final score achieved
306
- time_seconds: Time taken to complete (in seconds)
307
- game_mode: Game mode ("classic" or "too_easy")
308
- grid_size: Grid size (default: 12)
309
- spacer: Word spacing configuration (0-2, default: 1)
310
- may_overlap: Whether words can overlap (default: False)
311
- repo_id: HF repository ID (uses HF_REPO_ID from env if None)
312
- wordlist_source: Source wordlist file name (e.g., "classic.txt")
313
-
314
- Returns:
315
- tuple: (challenge_id, full_url, sid) where:
316
- - challenge_id: Unique challenge identifier
317
- - full_url: Full URL to settings.json
318
- - sid: Shortened ID for sharing (8 characters)
319
-
320
- Raises:
321
- Exception: If upload or URL shortening fails
322
-
323
- Example:
324
- >>> uid, full_url, sid = save_game_to_hf(
325
- ... word_list=["WORD", "TEST", "GAME", "PLAY", "SCORE", "FINAL"],
326
- ... username="Alice",
327
- ... score=42,
328
- ... time_seconds=180,
329
- ... game_mode="classic",
330
- ... wordlist_source="classic.txt"
331
- ... )
332
- >>> print(f"Share: https://{SPACE_NAME}/?game_id={sid}")
333
- """
334
- if repo_id is None:
335
- repo_id = HF_REPO_ID
336
-
337
- logger.info(f"💾 Saving game to HuggingFace repo: {repo_id}")
338
-
339
- # Generate challenge ID and serialize settings
340
- challenge_id = generate_uid()
341
- settings = serialize_game_settings(
342
- word_list=word_list,
343
- username=username,
344
- score=score,
345
- time_seconds=time_seconds,
346
- game_mode=game_mode,
347
- grid_size=grid_size,
348
- spacer=spacer,
349
- may_overlap=may_overlap,
350
- challenge_id=challenge_id,
351
- wordlist_source=wordlist_source
352
- )
353
-
354
- logger.debug(f"🆔 Generated Challenge ID: {challenge_id}")
355
-
356
- # Write settings to a temp directory using a fixed filename 'settings.json'
357
- folder_name = f"games/{challenge_id}"
358
- try:
359
- with tempfile.TemporaryDirectory() as tmpdir:
360
- settings_path = save_json_to_file(settings, tmpdir, "settings.json")
361
- logger.info(f"📤 Uploading to {folder_name}/settings.json")
362
- # Upload to HF repo under games/{uid}/settings.json
363
- response = upload_files_to_repo(
364
- files=[settings_path],
365
- repo_id=repo_id,
366
- folder_name=folder_name,
367
- repo_type="dataset"
368
- )
369
-
370
- # Construct full URL to settings.json
371
- full_url = f"https://huggingface.co/datasets/{repo_id}/resolve/main/{folder_name}/settings.json"
372
- logger.info(f"✅ Uploaded: {full_url}")
373
-
374
- # Generate short URL
375
- logger.info("🔗 Creating short URL...")
376
- status, sid = gen_full_url(
377
- full_url=full_url,
378
- repo_id=repo_id,
379
- json_file=SHORTENER_JSON_FILE
380
- )
381
-
382
- if status in ["created_short", "success_retrieved_short", "exists_match"]:
383
- logger.info(f"✅ Short ID created: {sid}")
384
- share_url = f"https://{SPACE_NAME}/?game_id={sid}"
385
- logger.info(f"🎮 Share URL: {share_url}")
386
- return challenge_id, full_url, sid
387
- else:
388
- logger.warning(f"⚠️ URL shortening failed: {status}")
389
- return challenge_id, full_url, None
390
-
391
- except Exception as e:
392
- logger.error(f"❌ Failed to save game: {e}")
393
- raise
394
-
395
-
396
- def load_game_from_sid(
397
- sid: str,
398
- repo_id: Optional[str] = None
399
- ) -> Optional[Dict[str, Any]]:
400
- """
401
- Load game settings from a short ID (sid).
402
- If settings.json cannot be found, return None and allow normal game loading.
403
-
404
- Args:
405
- sid: Short ID (8 characters) from shareable URL
406
- repo_id: HF repository ID (uses HF_REPO_ID from env if None)
407
-
408
- Returns:
409
- dict | None: Game settings or None if not found
410
-
411
- dict: Challenge settings containing:
412
- - challenge_id: Unique challenge identifier
413
- - wordlist_source: Source wordlist file (e.g., "classic.txt")
414
- - game_mode: Game mode
415
- - grid_size: Grid size
416
- - puzzle_options: Puzzle configuration (spacer, may_overlap)
417
- - users: Array of user results, each with:
418
- - uid: Unique user game identifier
419
- - username: Player name
420
- - word_list: Words THIS user played
421
- - score: Score achieved
422
- - time: Time taken (seconds)
423
- - timestamp: When result was recorded
424
- - created_at: When challenge was created
425
- - version: Storage version
426
-
427
- Returns None if sid not found or download fails
428
-
429
- Example:
430
- >>> settings = load_game_from_sid("abc12345")
431
- >>> if settings:
432
- ... print(f"Challenge ID: {settings['challenge_id']}")
433
- ... print(f"Wordlist: {settings['wordlist_source']}")
434
- ... for user in settings['users']:
435
- ... print(f"{user['username']}: {user['score']} pts")
436
- """
437
- if repo_id is None:
438
- repo_id = HF_REPO_ID
439
-
440
- logger.info(f"🔍 Loading game from sid: {sid}")
441
-
442
- try:
443
- # Resolve sid to full URL
444
- status, full_url = gen_full_url(
445
- short_url=sid,
446
- repo_id=repo_id,
447
- json_file=SHORTENER_JSON_FILE
448
- )
449
-
450
- if status != "success_retrieved_full" or not full_url:
451
- logger.warning(f"⚠️ Could not resolve sid: {sid} (status: {status})")
452
- return None
453
-
454
- logger.info(f"✅ Resolved to: {full_url}")
455
-
456
- # Extract the file path from the full URL
457
- # URL format: https://huggingface.co/datasets/{repo_id}/resolve/main/{path}
458
- # We need just the path part: games/{uid}/settings.json
459
- try:
460
- url_parts = full_url.split("/resolve/main/")
461
- if len(url_parts) != 2:
462
- logger.error(f"❌ Invalid URL format: {full_url}")
463
- return None
464
-
465
- file_path = url_parts[1]
466
- logger.info(f"📥 Downloading {file_path} using authenticated API...")
467
-
468
- settings = _get_json_from_repo(repo_id, file_path, repo_type="dataset")
469
- if not settings:
470
- logger.error(f"❌ settings.json not found for sid: {sid}. Loading normal game.")
471
- return None
472
-
473
- logger.info(f"✅ Loaded challenge: {settings.get('challenge_id', 'unknown')}")
474
- users = settings.get('users', [])
475
- logger.debug(f"Users in challenge: {len(users)}")
476
-
477
- return settings
478
-
479
- except Exception as e:
480
- logger.error(f"❌ Failed to parse URL or download: {e}")
481
- return None
482
-
483
- except Exception as e:
484
- logger.error(f"❌ Unexpected error loading game: {e}")
485
- return None
486
-
487
-
488
- def get_shareable_url(sid: str, base_url: str = None) -> str:
489
- """
490
- Generate a shareable URL from a short ID.
491
- If running locally, use localhost. Otherwise, use HuggingFace Space domain.
492
- Additionally, if an "iframe_host" query parameter is present in the current
493
- Streamlit request, it takes precedence and will be used as the base URL.
494
-
495
- Args:
496
- sid: Short ID (8 characters)
497
- base_url: Optional override for the base URL (for testing or custom deployments)
498
-
499
- Returns:
500
- str: Full shareable URL
501
-
502
- Example:
503
- >>> url = get_shareable_url("abc12345")
504
- >>> print(url)
505
- https://surn-battlewords.hf.space/?game_id=abc12345
506
- """
507
- import os
508
- from battlewords.modules.constants import SPACE_NAME
509
-
510
- # 0) If not explicitly provided, try to read iframe_host from Streamlit query params
511
- if base_url is None:
512
- try:
513
- import streamlit as st # local import to avoid hard dependency
514
- params = getattr(st, "query_params", None)
515
- if params is None and hasattr(st, "experimental_get_query_params"):
516
- params = st.experimental_get_query_params()
517
- if params and "iframe_host" in params:
518
- raw_host = params.get("iframe_host")
519
- # st.query_params may return str or list[str]
520
- if isinstance(raw_host, (list, tuple)):
521
- raw_host = raw_host[0] if raw_host else None
522
- if raw_host:
523
- decoded = unquote(str(raw_host))
524
- if decoded:
525
- base_url = decoded
526
- except Exception:
527
- # Ignore any errors here and fall back to defaults below
528
- pass
529
-
530
- # 1) If base_url is provided (either parameter or iframe_host), use it directly
531
- if base_url:
532
- sep = '&' if '?' in base_url else '?'
533
- return f"{base_url}{sep}game_id={sid}"
534
-
535
- if os.environ.get("IS_LOCAL", "true").lower() == "true":
536
- # 2) Check for local development (common Streamlit env vars)
537
- port = os.environ.get("PORT") or os.environ.get("STREAMLIT_SERVER_PORT") or "8501"
538
- host = os.environ.get("HOST") or os.environ.get("STREAMLIT_SERVER_ADDRESS") or "localhost"
539
- if host in ("localhost", "127.0.0.1") or os.environ.get("IS_LOCAL", "").lower() == "true":
540
- return f"http://{host}:{port}/?game_id={sid}"
541
-
542
- # 3) Otherwise, build HuggingFace Space URL from SPACE_NAME
543
- space = (SPACE_NAME or "surn/battlewords").lower().replace("/", "-")
544
- return f"https://{space}.hf.space/?game_id={sid}"
545
-
546
-
547
- if __name__ == "__main__":
548
- # Example usage
549
- print("BattleWords Game Storage Module")
550
- print(f"Version: {__version__}")
551
- print(f"Target Repository: {HF_REPO_ID}")
552
- print(f"Space Name: {SPACE_NAME}")
553
-
554
- # Example: Save a game
555
- print("\n--- Example: Save Game ---")
556
- try:
557
- challenge_id, full_url, sid = save_game_to_hf(
558
- word_list=["WORD", "TEST", "GAME", "PLAY", "SCORE", "FINAL"],
559
- username="Alice",
560
- score=42,
561
- time_seconds=180,
562
- game_mode="classic"
563
- )
564
- print(f"Challenge ID: {challenge_id}")
565
- print(f"Full URL: {full_url}")
566
- print(f"Short ID: {sid}")
567
- print(f"Share: {get_shareable_url(sid)}")
568
- except Exception as e:
569
- print(f"Error: {e}")
570
-
571
- # Example: Load a game
572
- print("\n--- Example: Load Game ---")
573
- if sid:
574
- settings = load_game_from_sid(sid)
575
- if settings:
576
- print(f"Loaded Challenge: {settings['challenge_id']}")
577
- print(f"Wordlist Source: {settings.get('wordlist_source', 'N/A')}")
578
- users = settings.get('users', [])
579
- print(f"Users: {len(users)}")
580
- for user in users:
581
- print(f" - {user['username']}: {user['score']} pts in {user['time']}s")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
battlewords/modules/__init__.py CHANGED
@@ -6,26 +6,8 @@ These modules are imported from the OpenBadge project and provide
6
  reusable functionality for storage, constants, and file utilities.
7
  """
8
 
9
- from .storage import (
10
- upload_files_to_repo,
11
- gen_full_url,
12
- generate_permalink,
13
- generate_permalink_from_urls,
14
- store_issuer_keypair,
15
- get_issuer_keypair,
16
- get_verification_methods_registry,
17
- list_issuer_ids,
18
- _list_repo_folders,
19
- _list_repo_files_in_folder
20
- )
21
-
22
  from .constants import (
23
  APP_SETTINGS,
24
- HF_API_TOKEN,
25
- CRYPTO_PK,
26
- HF_REPO_ID,
27
- SPACE_NAME,
28
- SHORTENER_JSON_FILE,
29
  MAX_DISPLAY_ENTRIES,
30
  TMPDIR,
31
  upload_file_types,
@@ -54,25 +36,8 @@ from .file_utils import (
54
  )
55
 
56
  __all__ = [
57
- # storage.py
58
- 'upload_files_to_repo',
59
- 'gen_full_url',
60
- 'generate_permalink',
61
- 'generate_permalink_from_urls',
62
- 'store_issuer_keypair',
63
- 'get_issuer_keypair',
64
- 'get_verification_methods_registry',
65
- 'list_issuer_ids',
66
- '_list_repo_folders',
67
- '_list_repo_files_in_folder',
68
-
69
  # constants.py
70
  'APP_SETTINGS',
71
- 'HF_API_TOKEN',
72
- 'CRYPTO_PK',
73
- 'HF_REPO_ID',
74
- 'SPACE_NAME',
75
- 'SHORTENER_JSON_FILE',
76
  'MAX_DISPLAY_ENTRIES',
77
  'TMPDIR',
78
  'upload_file_types',
 
6
  reusable functionality for storage, constants, and file utilities.
7
  """
8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  from .constants import (
10
  APP_SETTINGS,
 
 
 
 
 
11
  MAX_DISPLAY_ENTRIES,
12
  TMPDIR,
13
  upload_file_types,
 
36
  )
37
 
38
  __all__ = [
 
 
 
 
 
 
 
 
 
 
 
 
39
  # constants.py
40
  'APP_SETTINGS',
 
 
 
 
 
41
  'MAX_DISPLAY_ENTRIES',
42
  'TMPDIR',
43
  'upload_file_types',
battlewords/modules/constants.py CHANGED
@@ -1,7 +1,9 @@
1
  # battlewords/modules/constants.py
2
- """
3
- Storage-related constants for BattleWords.
4
- Trimmed version of OpenBadge constants - only includes what's needed for storage.py
 
 
5
  """
6
  import os
7
  import json
@@ -9,20 +11,6 @@ import tempfile
9
  import logging
10
  from pathlib import Path
11
  from typing import Dict, Any
12
- from dotenv import load_dotenv
13
-
14
- # Load environment variables from .env file
15
- dotenv_path = Path(__file__).parent.parent.parent / '.env'
16
- load_dotenv(dotenv_path)
17
-
18
- # Hugging Face Configuration
19
- HF_API_TOKEN = os.getenv("HF_TOKEN", os.getenv("HF_API_TOKEN", None))
20
- CRYPTO_PK = os.getenv("CRYPTO_PK", None)
21
-
22
- # Repository Configuration
23
- HF_REPO_ID = os.getenv("HF_REPO_ID", "Surn/Storage")
24
- SPACE_NAME = os.getenv('SPACE_NAME', 'Surn/BattleWords')
25
- SHORTENER_JSON_FILE = "shortener.json"
26
  MAX_DISPLAY_ENTRIES = int(os.getenv("MAX_DISPLAY_ENTRIES", 25))
27
 
28
  # ---------------------------------------------------------------------------
@@ -50,7 +38,7 @@ def load_settings() -> Dict[str, Any]:
50
  # Display settings
51
  "show_incorrect_guesses": True,
52
  "enable_free_letters": False,
53
- "show_challenge_links": True,
54
 
55
  # Game defaults
56
  "default_wordlist": "classic.txt",
 
1
  # battlewords/modules/constants.py
2
+ """battlewords/modules/constants.py
3
+
4
+ Basic branch constants.
5
+
6
+ Note: Hugging Face storage/challenge-mode constants were removed in the `basic` branch.
7
  """
8
  import os
9
  import json
 
11
  import logging
12
  from pathlib import Path
13
  from typing import Dict, Any
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  MAX_DISPLAY_ENTRIES = int(os.getenv("MAX_DISPLAY_ENTRIES", 25))
15
 
16
  # ---------------------------------------------------------------------------
 
38
  # Display settings
39
  "show_incorrect_guesses": True,
40
  "enable_free_letters": False,
41
+ "show_challenge_links": False,
42
 
43
  # Game defaults
44
  "default_wordlist": "classic.txt",
battlewords/modules/storage.md DELETED
@@ -1,268 +0,0 @@
1
- # Storage Module (`modules/storage.py`) Usage Guide
2
-
3
- The `storage.py` module provides helper functions for:
4
- - Generating permalinks for 3D viewer projects.
5
- - Uploading files in batches to a Hugging Face repository.
6
- - Managing URL shortening by storing (short URL, full URL) pairs in a JSON file on the repository.
7
- - Retrieving full URLs from short URL IDs and vice versa.
8
- - Handle specific file types for 3D models, images, video and audio.
9
- - **📁 Listing folders and files in HuggingFace repositories.**
10
- - **🔑 Cryptographic key management for Open Badge 3.0 issuers.**
11
-
12
- ## Key Functions
13
-
14
- ### 1. `generate_permalink(valid_files, base_url_external, permalink_viewer_url="surn-3d-viewer.hf.space")`
15
- - **Purpose:**
16
- Given a list of file paths, it looks for exactly one model file (with an extension defined in `model_extensions`) and exactly two image files (extensions defined in `image_extensions`). If the criteria are met, it returns a permalink URL built from the base URL and query parameters.
17
- - **Usage Example:**from modules.storage import generate_permalink
18
-
19
- valid_files = [
20
- "models/3d_model.glb",
21
- "images/model_texture.png",
22
- "images/model_depth.png"
23
- ]
24
- base_url_external = "https://huggingface.co/datasets/Surn/Storage/resolve/main/saved_models/my_model"
25
- permalink = generate_permalink(valid_files, base_url_external)
26
- if permalink:
27
- print("Permalink:", permalink)
28
- ### 2. `generate_permalink_from_urls(model_url, hm_url, img_url, permalink_viewer_url="surn-3d-viewer.hf.space")`
29
- - **Purpose:**
30
- Constructs a permalink URL by combining individual URLs for a 3D model (`model_url`), height map (`hm_url`), and image (`img_url`) into a single URL with corresponding query parameters.
31
- - **Usage Example:**from modules.storage import generate_permalink_from_urls
32
-
33
- model_url = "https://example.com/model.glb"
34
- hm_url = "https://example.com/heightmap.png"
35
- img_url = "https://example.com/source.png"
36
-
37
- permalink = generate_permalink_from_urls(model_url, hm_url, img_url)
38
- print("Generated Permalink:", permalink)
39
- ### 3. `upload_files_to_repo(files, repo_id, folder_name, create_permalink=False, repo_type="dataset", permalink_viewer_url="surn-3d-viewer.hf.space")`
40
- - **Purpose:**
41
- Uploads a batch of files (each file represented as a path string) to a specified Hugging Face repository (e.g. `"Surn/Storage"`) under a given folder.
42
- The function's return type is `Union[Dict[str, Any], List[Tuple[Any, str]]]`.
43
- - When `create_permalink` is `True` and exactly three valid files (one model and two images) are provided, the function returns a dictionary:{
44
- "response": <upload_folder_response>,
45
- "permalink": "<full_permalink_url>",
46
- "short_permalink": "<shortened_permalink_url_with_sid>"
47
- } - Otherwise (or if `create_permalink` is `False` or conditions for permalink creation are not met), it returns a list of tuples, where each tuple is `(upload_folder_response, individual_file_link)`.
48
- - If no valid files are provided, it returns an empty list `[]` (this case should ideally also return the dictionary with empty/None values for consistency, but currently returns `[]` as per the code).
49
- - **Usage Example:**
50
-
51
- **a. Uploading with permalink creation:**from modules.storage import upload_files_to_repo
52
-
53
- files_for_permalink = [
54
- "local/path/to/model.glb",
55
- "local/path/to/heightmap.png",
56
- "local/path/to/image.png"
57
- ]
58
- repo_id = "Surn/Storage" # Make sure this is defined, e.g., from constants or environment variables
59
- folder_name = "my_new_model_with_permalink"
60
-
61
- upload_result = upload_files_to_repo(
62
- files_for_permalink,
63
- repo_id,
64
- folder_name,
65
- create_permalink=True
66
- )
67
-
68
- if isinstance(upload_result, dict):
69
- print("Upload Response:", upload_result.get("response"))
70
- print("Full Permalink:", upload_result.get("permalink"))
71
- print("Short Permalink:", upload_result.get("short_permalink"))
72
- elif upload_result: # Check if list is not empty
73
- print("Upload Response for individual files:")
74
- for res, link in upload_result:
75
- print(f" Response: {res}, Link: {link}")
76
- else:
77
- print("No files uploaded or error occurred.")
78
- **b. Uploading without permalink creation (or if conditions for permalink are not met):**from modules.storage import upload_files_to_repo
79
-
80
- files_individual = [
81
- "local/path/to/another_model.obj",
82
- "local/path/to/texture.jpg"
83
- ]
84
- repo_id = "Surn/Storage"
85
- folder_name = "my_other_uploads"
86
-
87
- upload_results_list = upload_files_to_repo(
88
- files_individual,
89
- repo_id,
90
- folder_name,
91
- create_permalink=False # Or if create_permalink=True but not 1 model & 2 images
92
- )
93
-
94
- if upload_results_list: # Will be a list of tuples
95
- print("Upload results for individual files:")
96
- for res, link in upload_results_list:
97
- print(f" Upload Response: {res}, File Link: {link}")
98
- else:
99
- print("No files uploaded or error occurred.")
100
- ### 4. URL Shortening Functions: `gen_full_url(...)` and Helpers
101
- The module also enables URL shortening by managing a JSON file (e.g. `shortener.json`) in a Hugging Face repository. It supports CRUD-like operations:
102
- - **Read:** Look up the full URL using a provided short URL ID.
103
- - **Create:** Generate a new short URL ID for a full URL if no existing mapping exists.
104
- - **Update/Conflict Handling:**
105
- If both short URL ID and full URL are provided, it checks consistency and either confirms or reports a conflict.
106
-
107
- #### `gen_full_url(short_url=None, full_url=None, repo_id=None, repo_type="dataset", permalink_viewer_url="surn-3d-viewer.hf.space", json_file="shortener.json")`
108
- - **Purpose:**
109
- Based on which parameter is provided, it retrieves or creates a mapping between a short URL ID and a full URL.
110
- - If only `short_url` (the ID) is given, it returns the corresponding `full_url`.
111
- - If only `full_url` is given, it looks up an existing `short_url` ID or generates and stores a new one.
112
- - If both are given, it validates and returns the mapping or an error status.
113
- - **Returns:** A tuple `(status_message, result_url)`, where `status_message` indicates the outcome (e.g., `"success_retrieved_full"`, `"created_short"`) and `result_url` is the relevant URL (full or short ID).
114
- - **Usage Examples:**
115
-
116
- **a. Convert a full URL into a short URL ID:**from modules.storage import gen_full_url
117
- from modules.constants import HF_REPO_ID, SHORTENER_JSON_FILE # Assuming these are defined
118
-
119
- full_permalink = "https://surn-3d-viewer.hf.space/?3d=https%3A%2F%2Fexample.com%2Fmodel.glb&hm=https%3A%2F%2Fexample.com%2Fheightmap.png&image=https%3A%2F%2Fexample.com%2Fsource.png"
120
-
121
- status, short_id = gen_full_url(
122
- full_url=full_permalink,
123
- repo_id=HF_REPO_ID,
124
- json_file=SHORTENER_JSON_FILE
125
- )
126
- print("Status:", status)
127
- if status == "created_short" or status == "success_retrieved_short":
128
- print("Shortened URL ID:", short_id)
129
- # Construct the full short URL for sharing:
130
- # permalink_viewer_url = "surn-3d-viewer.hf.space" # Or from constants
131
- # shareable_short_url = f"https://{permalink_viewer_url}/?sid={short_id}"
132
- # print("Shareable Short URL:", shareable_short_url)
133
- **b. Retrieve the full URL from a short URL ID:**from modules.storage import gen_full_url
134
- from modules.constants import HF_REPO_ID, SHORTENER_JSON_FILE # Assuming these are defined
135
-
136
- short_id_to_lookup = "aBcDeFg1" # Example short URL ID
137
-
138
- status, retrieved_full_url = gen_full_url(
139
- short_url=short_id_to_lookup,
140
- repo_id=HF_REPO_ID,
141
- json_file=SHORTENER_JSON_FILE
142
- )
143
- print("Status:", status)
144
- if status == "success_retrieved_full":
145
- print("Retrieved Full URL:", retrieved_full_url)
146
- ## 📁 Repository Folder Listing Functions
147
-
148
- ### 5. `_list_repo_folders(repo_id, path_prefix, repo_type="dataset")`
149
- - **Purpose:**
150
- List folder names under a given path in a HuggingFace repository. Enables folder-based discovery without index files.
151
- - **Parameters:**
152
- - `repo_id` (str): The repository ID on Hugging Face
153
- - `path_prefix` (str): The path prefix to list folders under
154
- - `repo_type` (str): Repository type. Default is `"dataset"`.
155
- - **Returns:** `List[str]` - List of folder names found under the path_prefix.
156
- - **Usage Example:**
157
- ```python
158
- from modules.storage import _list_repo_folders
159
-
160
- # List all date folders in daily leaderboards
161
- folders = _list_repo_folders("Surn/Wrdler-Data", "games/leaderboards/daily")
162
- print("Available dates:", folders)
163
- # Output: ['2025-01-27', '2025-01-26', '2025-01-25']
164
- ```
165
-
166
- ### 6. `_list_repo_files_in_folder(repo_id, folder_path, repo_type="dataset")`
167
- - **Purpose:**
168
- List file names directly under a folder in a HuggingFace repository.
169
- - **Parameters:**
170
- - `repo_id` (str): The repository ID on Hugging Face
171
- - `folder_path` (str): The folder path to list files under
172
- - `repo_type` (str): Repository type. Default is `"dataset"`.
173
- - **Returns:** `List[str]` - List of file names found directly in the folder.
174
- - **Usage Example:**
175
- ```python
176
- from modules.storage import _list_repo_files_in_folder
177
-
178
- files = _list_repo_files_in_folder(
179
- "Surn/Wrdler-Data",
180
- "games/leaderboards/daily/2025-01-27/classic-classic-0"
181
- )
182
- print("Files:", files)
183
- # Output: ['settings.json']
184
- ```
185
-
186
- ## 🔑 Cryptographic Key Management Functions
187
-
188
- ### 7. `store_issuer_keypair(issuer_id, public_key, private_key, repo_id=None)`
189
- - **Purpose:**
190
- Securely store cryptographic keys for an issuer in a private Hugging Face repository. Private keys are encrypted before storage.
191
- - **⚠️ IMPORTANT:** This function requires a PRIVATE Hugging Face repository to ensure the security of stored private keys. Never use this with public repositories.
192
- - **Storage Structure:**keys/issuers/{issuer_id}/
193
- ├── private_key.json (encrypted)
194
- └── public_key.json- **Returns:** `bool` - True if keys were stored successfully, False otherwise.
195
- - **Usage Example:**from modules.storage import store_issuer_keypair
196
-
197
- # Example Ed25519 keys (multibase encoded)
198
- issuer_id = "https://example.edu/issuers/565049"
199
- public_key = "z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK"
200
- private_key = "z3u2MQhLnQw7nvJRGJCdKdqfXHV4N7BLKuEGFWnJqsVSdgYv"
201
-
202
- success = store_issuer_keypair(issuer_id, public_key, private_key)
203
- if success:
204
- print("Keys stored successfully")
205
- else:
206
- print("Failed to store keys")
207
- ### 8. `get_issuer_keypair(issuer_id, repo_id=None)`
208
- - **Purpose:**
209
- Retrieve and decrypt stored cryptographic keys for an issuer from the private Hugging Face repository.
210
- - **⚠️ IMPORTANT:** This function accesses a PRIVATE Hugging Face repository containing encrypted private keys. Ensure proper access control and security measures.
211
- - **Returns:** `Tuple[Optional[str], Optional[str]]` - (public_key, private_key) or (None, None) if not found.
212
- - **Usage Example:**from modules.storage import get_issuer_keypair
213
-
214
- issuer_id = "https://example.edu/issuers/565049"
215
- public_key, private_key = get_issuer_keypair(issuer_id)
216
-
217
- if public_key and private_key:
218
- print("Keys retrieved successfully")
219
- print(f"Public key: {public_key}")
220
- # Use private_key for signing operations
221
- else:
222
- print("Keys not found or error occurred")
223
- ### 9. `get_verification_methods_registry(repo_id=None)`
224
- - **Purpose:**
225
- Retrieve the global verification methods registry containing all registered issuer public keys.
226
- - **Returns:** `Dict[str, Any]` - Registry data containing all verification methods.
227
- - **Usage Example:**from modules.storage import get_verification_methods_registry
228
-
229
- registry = get_verification_methods_registry()
230
- methods = registry.get("verification_methods", [])
231
-
232
- for method in methods:
233
- print(f"Issuer: {method['issuer_id']}")
234
- print(f"Public Key: {method['public_key']}")
235
- print(f"Key Type: {method['key_type']}")
236
- print("---")
237
- ### 10. `list_issuer_ids(repo_id=None)`
238
- - **Purpose:**
239
- List all issuer IDs that have stored keys in the repository.
240
- - **Returns:** `List[str]` - List of issuer IDs.
241
- - **Usage Example:**from modules.storage import list_issuer_ids
242
-
243
- issuer_ids = list_issuer_ids()
244
- print("Registered issuers:")
245
- for issuer_id in issuer_ids:
246
- print(f" - {issuer_id}")
247
- ## Notes
248
- - **Authentication:** All functions that interact with Hugging Face Hub use the HF API token defined as `HF_API_TOKEN` in `modules/constants.py`. Ensure this environment variable is correctly set.
249
- - **Constants:** Functions like `gen_full_url` and `upload_files_to_repo` (when creating short links) rely on `HF_REPO_ID` and `SHORTENER_JSON_FILE` from `modules/constants.py` for the URL shortening feature.
250
- - **🔐 Private Repository Requirement:** Key management functions require a PRIVATE Hugging Face repository to ensure the security of stored encrypted private keys. Never use these functions with public repositories.
251
- - **File Types:** Only files with extensions included in `upload_file_types` (a combination of `model_extensions` and `image_extensions` from `modules/constants.py`) are processed by `upload_files_to_repo`.
252
- - **Repository Configuration:** When using URL shortening, file uploads, and key management, ensure that the specified Hugging Face repository (e.g., defined by `HF_REPO_ID`) exists and that you have write permissions.
253
- - **Temporary Directory:** `upload_files_to_repo` temporarily copies files to a local directory (configured by `TMPDIR` in `modules/constants.py`) before uploading.
254
- - **Key Encryption:** Private keys are encrypted using basic XOR encryption (demo implementation). In production environments, upgrade to proper encryption like Fernet from the cryptography library.
255
- - **Error Handling:** Functions include basic error handling (e.g., catching `RepositoryNotFoundError`, `EntryNotFoundError`, JSON decoding errors, or upload issues) and print messages to the console for debugging. Review function return values to handle these cases appropriately in your application.
256
-
257
- ## 🔒 Security Considerations for Key Management
258
-
259
- 1. **Private Repository Only:** Always use private repositories for key storage to protect cryptographic material.
260
- 2. **Key Sanitization:** Issuer IDs are sanitized for file system compatibility (replacing special characters with underscores).
261
- 3. **Encryption:** Private keys are encrypted before storage. Upgrade to Fernet encryption in production.
262
- 4. **Access Control:** Implement proper authentication and authorization for key access.
263
- 5. **Key Rotation:** Consider implementing key rotation mechanisms for enhanced security.
264
- 6. **Audit Logging:** Monitor key access and usage patterns for security auditing.
265
-
266
- ---
267
-
268
- This guide provides the essential usage examples for interacting with the storage, URL-shortening, folder listing, and cryptographic key management functionality. You can integrate these examples into your application or use them as a reference when extending functionality.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
battlewords/modules/storage.py DELETED
@@ -1,799 +0,0 @@
1
- # modules/storage.py
2
- __version__ = "0.1.6"
3
- import os
4
- import urllib.parse
5
- import tempfile
6
- import shutil
7
- import json
8
- import base64
9
- import logging
10
- from datetime import datetime, timezone
11
- from huggingface_hub import login, upload_folder, hf_hub_download, HfApi
12
- from huggingface_hub.utils import RepositoryNotFoundError, EntryNotFoundError
13
- from .constants import HF_API_TOKEN, upload_file_types, model_extensions, image_extensions, audio_extensions, video_extensions, doc_extensions, HF_REPO_ID, SHORTENER_JSON_FILE
14
- from typing import Any, Dict, List, Tuple, Union, Optional
15
-
16
- # Configure professional logging
17
- logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
18
- logger = logging.getLogger(__name__)
19
-
20
- # see storage.md for detailed information about the storage module and its functions.
21
-
22
- def generate_permalink(valid_files, base_url_external, permalink_viewer_url="surn-3d-viewer.hf.space"):
23
- """
24
- Given a list of valid files, checks if they contain exactly 1 model file and 2 image files.
25
- Constructs and returns a permalink URL with query parameters if the criteria is met.
26
- Otherwise, returns None.
27
- """
28
- model_link = None
29
- images_links = []
30
- audio_links = []
31
- video_links = []
32
- doc_links = []
33
- for f in valid_files:
34
- filename = os.path.basename(f)
35
- ext = os.path.splitext(filename)[1].lower()
36
- if ext in model_extensions:
37
- if model_link is None:
38
- model_link = f"{base_url_external}/{filename}"
39
- elif ext in image_extensions:
40
- images_links.append(f"{base_url_external}/{filename}")
41
- elif ext in audio_extensions:
42
- audio_links.append(f"{base_url_external}/{filename}")
43
- elif ext in video_extensions:
44
- video_links.append(f"{base_url_external}/{filename}")
45
- elif ext in doc_extensions:
46
- doc_links.append(f"{base_url_external}/{filename}")
47
- if model_link and len(images_links) == 2:
48
- # Construct a permalink to the viewer project with query parameters.
49
- permalink_viewer_url = f"https://{permalink_viewer_url}/"
50
- params = {"3d": model_link, "hm": images_links[0], "image": images_links[1]}
51
- query_str = urllib.parse.urlencode(params)
52
- return f"{permalink_viewer_url}?{query_str}"
53
- return None
54
-
55
- def generate_permalink_from_urls(model_url, hm_url, img_url, permalink_viewer_url="surn-3d-viewer.hf.space"):
56
- """
57
- Constructs and returns a permalink URL with query string parameters for the viewer.
58
- Each parameter is passed separately so that the image positions remain consistent.
59
-
60
- Parameters:
61
- model_url (str): Processed URL for the 3D model.
62
- hm_url (str): Processed URL for the height map image.
63
- img_url (str): Processed URL for the main image.
64
- permalink_viewer_url (str): The base viewer URL.
65
-
66
- Returns:
67
- str: The generated permalink URL.
68
- """
69
- import urllib.parse
70
- params = {"3d": model_url, "hm": hm_url, "image": img_url}
71
- query_str = urllib.parse.urlencode(params)
72
- return f"https://{permalink_viewer_url}/?{query_str}"
73
-
74
- def upload_files_to_repo(
75
- files: List[Any],
76
- repo_id: str,
77
- folder_name: str,
78
- create_permalink: bool = False,
79
- repo_type: str = "dataset",
80
- permalink_viewer_url: str = "surn-3d-viewer.hf.space"
81
- ) -> Union[Dict[str, Any], List[Tuple[Any, str]]]:
82
- """
83
- Uploads multiple files to a Hugging Face repository using a batch upload approach via upload_folder.
84
-
85
- Parameters:
86
- files (list): A list of file paths (str) to upload.
87
- repo_id (str): The repository ID on Hugging Face for storage, e.g. "Surn/Storage".
88
- folder_name (str): The subfolder within the repository where files will be saved.
89
- create_permalink (bool): If True and if exactly three files are uploaded (1 model and 2 images),
90
- returns a single permalink to the project with query parameters.
91
- Otherwise, returns individual permalinks for each file.
92
- repo_type (str): Repository type ("space", "dataset", etc.). Default is "dataset".
93
- permalink_viewer_url (str): The base viewer URL.
94
-
95
- Returns:
96
- Union[Dict[str, Any], List[Tuple[Any, str]]]:
97
- If create_permalink is True and files match the criteria:
98
- dict: {
99
- "response": <upload response>,
100
- "permalink": <full_permalink URL>,
101
- "short_permalink": <shortened permalink URL>
102
- }
103
- Otherwise:
104
- list: A list of tuples (response, permalink) for each file.
105
- """
106
- logger.info(f"📤 Starting batch upload to repository: {repo_id}")
107
- logger.debug(f"📁 Target folder: {folder_name}")
108
- logger.debug(f"🔗 Create permalink: {create_permalink}")
109
-
110
- # Log in using the HF API token.
111
- try:
112
- login(token=HF_API_TOKEN)
113
- logger.debug("🔑 Authenticated with Hugging Face")
114
- except Exception as e:
115
- logger.error(f"🚫 Authentication failed: {e}")
116
- return {"response": "Authentication failed", "permalink": None, "short_permalink": None} if create_permalink else []
117
-
118
- valid_files = []
119
- permalink_short = None
120
-
121
- # Ensure folder_name does not have a trailing slash.
122
- folder_name = folder_name.rstrip("/")
123
-
124
- # Filter for valid files based on allowed extensions.
125
- logger.debug("🔍 Filtering valid files...")
126
- for f in files:
127
- file_name = f if isinstance(f, str) else f.name if hasattr(f, "name") else None
128
- if file_name is None:
129
- continue
130
- ext = os.path.splitext(file_name)[1].lower()
131
- if ext in upload_file_types:
132
- valid_files.append(f)
133
- logger.debug(f"✅ Valid file: {os.path.basename(file_name)}")
134
- else:
135
- logger.debug(f"⚠️ Skipped file with invalid extension: {os.path.basename(file_name)}")
136
-
137
- logger.info(f"📊 Found {len(valid_files)} valid files out of {len(files)} total")
138
-
139
- if not valid_files:
140
- logger.warning("⚠️ No valid files to upload")
141
- if create_permalink:
142
- return {
143
- "response": "No valid files to upload.",
144
- "permalink": None,
145
- "short_permalink": None
146
- }
147
- return []
148
-
149
- # Create a temporary directory and copy valid files
150
- logger.debug("📁 Creating temporary directory for batch upload...")
151
- with tempfile.TemporaryDirectory(dir=os.getenv("TMPDIR", "/tmp")) as temp_dir:
152
- for file_path in valid_files:
153
- filename = os.path.basename(file_path)
154
- dest_path = os.path.join(temp_dir, filename)
155
- shutil.copy(file_path, dest_path)
156
- logger.debug(f"📄 Copied: {filename}")
157
-
158
- logger.info("🚀 Starting batch upload to Hugging Face...")
159
- # Batch upload all files in the temporary folder.
160
- try:
161
- response = upload_folder(
162
- folder_path=temp_dir,
163
- repo_id=repo_id,
164
- repo_type=repo_type,
165
- path_in_repo=folder_name,
166
- commit_message="Batch upload files"
167
- )
168
- logger.info("✅ Batch upload completed successfully")
169
- except Exception as e:
170
- logger.error(f"❌ Batch upload failed: {e}")
171
- return {"response": f"Upload failed: {e}", "permalink": None, "short_permalink": None} if create_permalink else []
172
-
173
- # Construct external URLs for each uploaded file.
174
- base_url_external = f"https://huggingface.co/datasets/{repo_id}/resolve/main/{folder_name}"
175
- individual_links = []
176
- for file_path in valid_files:
177
- filename = os.path.basename(file_path)
178
- link = f"{base_url_external}/{filename}"
179
- individual_links.append(link)
180
- logger.debug(f"🔗 Generated link: {link}")
181
-
182
- # Handle permalink creation if requested
183
- if create_permalink:
184
- logger.info("🔗 Attempting to create permalink...")
185
- permalink = generate_permalink(valid_files, base_url_external, permalink_viewer_url)
186
- if permalink:
187
- logger.info(f"✅ Generated permalink: {permalink}")
188
- logger.debug("🔗 Creating short URL...")
189
- status, short_id = gen_full_url(
190
- full_url=permalink,
191
- repo_id=HF_REPO_ID,
192
- json_file=SHORTENER_JSON_FILE
193
- )
194
- if status in ["created_short", "success_retrieved_short", "exists_match"]:
195
- permalink_short = f"https://{permalink_viewer_url}/?sid={short_id}"
196
- logger.info(f"✅ Created short permalink: {permalink_short}")
197
- else:
198
- permalink_short = None
199
- logger.warning(f"⚠️ URL shortening failed: {status} for {permalink}")
200
-
201
- return {
202
- "response": response,
203
- "permalink": permalink,
204
- "short_permalink": permalink_short
205
- }
206
- else:
207
- logger.warning("⚠️ Permalink generation failed (criteria not met)")
208
- return {
209
- "response": response,
210
- "permalink": None,
211
- "short_permalink": None
212
- }
213
-
214
- # Return individual tuples for each file
215
- logger.info(f"📋 Returning individual links for {len(individual_links)} files")
216
- return [(response, link) for link in individual_links]
217
-
218
- def _generate_short_id(length=8):
219
- """Generates a random base64 URL-safe string."""
220
- return base64.urlsafe_b64encode(os.urandom(length * 2))[:length].decode('utf-8')
221
-
222
- def _get_json_from_repo(repo_id, json_file_name, repo_type="dataset"):
223
- """Downloads and loads the JSON file from the repo. Returns empty list if not found or error."""
224
- try:
225
- login(token=HF_API_TOKEN)
226
- json_path = hf_hub_download(
227
- repo_id=repo_id,
228
- filename=json_file_name,
229
- repo_type=repo_type,
230
- token=HF_API_TOKEN
231
- )
232
- with open(json_path, 'r') as f:
233
- data = json.load(f)
234
- os.remove(json_path)
235
- return data
236
- except RepositoryNotFoundError:
237
- logger.warning(f"Repository {repo_id} not found.")
238
- return []
239
- except EntryNotFoundError:
240
- logger.warning(f"JSON file {json_file_name} not found in {repo_id}. Initializing with empty list.")
241
- return []
242
- except json.JSONDecodeError:
243
- logger.error(f"Error decoding JSON from {json_file_name}. Returning empty list.")
244
- return []
245
- except Exception as e:
246
- logger.error(f"An unexpected error occurred while fetching {json_file_name}: {e}")
247
- return []
248
-
249
- def _get_files_from_repo(repo_id, file_name, repo_type="dataset"):
250
- """Downloads and loads the file from the repo. File must be in upload_file_types. Returns empty list if not found or error."""
251
- filename = os.path.basename(file_name)
252
- ext = os.path.splitext(file_name)[1].lower()
253
- if ext not in upload_file_types:
254
- logger.error(f"File {filename} with extension {ext} is not allowed for upload.")
255
- return None
256
- else:
257
- try:
258
- login(token=HF_API_TOKEN)
259
- file_path = hf_hub_download(
260
- repo_id=repo_id,
261
- filename=file_name,
262
- repo_type=repo_type,
263
- token=HF_API_TOKEN
264
- )
265
- if not file_path:
266
- return None
267
- return file_path
268
- except RepositoryNotFoundError:
269
- logger.warning(f"Repository {repo_id} not found.")
270
- return None
271
- except EntryNotFoundError:
272
- logger.warning(f"file {file_name} not found in {repo_id}. Initializing with empty list.")
273
- return None
274
- except Exception as e:
275
- logger.error(f"Error fetching {file_name} from {repo_id}: {e}")
276
- return None
277
-
278
- def _upload_json_to_repo(data, repo_id, json_file_name, repo_type="dataset"):
279
- """Uploads the JSON data to the specified file in the repo."""
280
- try:
281
- login(token=HF_API_TOKEN)
282
- api = HfApi()
283
- # Use a temporary directory specified by TMPDIR or default to system temp
284
- temp_dir_for_json = os.getenv("TMPDIR", tempfile.gettempdir())
285
- os.makedirs(temp_dir_for_json, exist_ok=True)
286
-
287
- with tempfile.NamedTemporaryFile(mode="w+", delete=False, suffix=".json", dir=temp_dir_for_json) as tmp_file:
288
- json.dump(data, tmp_file, indent=2)
289
- tmp_file_path = tmp_file.name
290
-
291
- logger.info(f"📤 Uploading JSON data to {json_file_name}...")
292
- api.upload_file(
293
- path_or_fileobj=tmp_file_path,
294
- path_in_repo=json_file_name,
295
- repo_id=repo_id,
296
- repo_type=repo_type,
297
- commit_message=f"Update {json_file_name}"
298
- )
299
- os.remove(tmp_file_path) # Clean up temporary file
300
- logger.info("✅ JSON data uploaded successfully")
301
- return True
302
- except Exception as e:
303
- logger.error(f"Failed to upload {json_file_name} to {repo_id}: {e}")
304
- if 'tmp_file_path' in locals() and os.path.exists(tmp_file_path):
305
- os.remove(tmp_file_path) # Ensure cleanup on error too
306
- return False
307
-
308
- def _find_url_in_json(data, short_url=None, full_url=None):
309
- """
310
- Searches the JSON data.
311
- If short_url is provided, returns the corresponding full_url or None.
312
- If full_url is provided, returns the corresponding short_url or None.
313
- """
314
- if not data: # Handles cases where data might be None or empty
315
- return None
316
- if short_url:
317
- for item in data:
318
- if item.get("short_url") == short_url:
319
- return item.get("full_url")
320
- if full_url:
321
- for item in data:
322
- if item.get("full_url") == full_url:
323
- return item.get("short_url")
324
- return None
325
-
326
- def _add_url_to_json(data, short_url, full_url):
327
- """Adds a new short_url/full_url pair to the data. Returns updated data."""
328
- if data is None:
329
- data = []
330
- data.append({"short_url": short_url, "full_url": full_url})
331
- return data
332
-
333
- def gen_full_url(short_url=None, full_url=None, repo_id=None, repo_type="dataset", permalink_viewer_url="surn-3d-viewer.hf.space", json_file="shortener.json"):
334
- """
335
- Manages short URLs and their corresponding full URLs in a JSON file stored in a Hugging Face repository.
336
-
337
- - If short_url is provided, attempts to retrieve and return the full_url.
338
- - If full_url is provided, attempts to retrieve an existing short_url or creates a new one, stores it, and returns the short_url.
339
- - If both are provided, checks for consistency or creates a new entry.
340
- - If neither is provided, or repo_id is missing, returns an error status.
341
-
342
- Returns:
343
- tuple: (status_message, result_url)
344
- status_message can be "success", "created", "exists", "error", "not_found".
345
- result_url is the relevant URL (short or full) or None if an error occurs or not found.
346
- """
347
- if not repo_id:
348
- return "error_repo_id_missing", None
349
- if not short_url and not full_url:
350
- return "error_no_input", None
351
-
352
- login(token=HF_API_TOKEN) # Ensure login at the beginning
353
- url_data = _get_json_from_repo(repo_id, json_file, repo_type)
354
-
355
- # Case 1: Only short_url provided (lookup full_url)
356
- if short_url and not full_url:
357
- found_full_url = _find_url_in_json(url_data, short_url=short_url)
358
- return ("success_retrieved_full", found_full_url) if found_full_url else ("not_found_short", None)
359
-
360
- # Case 2: Only full_url provided (lookup or create short_url)
361
- if full_url and not short_url:
362
- existing_short_url = _find_url_in_json(url_data, full_url=full_url)
363
- if existing_short_url:
364
- return "success_retrieved_short", existing_short_url
365
- else:
366
- # Create new short_url
367
- new_short_id = _generate_short_id()
368
- url_data = _add_url_to_json(url_data, new_short_id, full_url)
369
- if _upload_json_to_repo(url_data, repo_id, json_file, repo_type):
370
- return "created_short", new_short_id
371
- else:
372
- return "error_upload", None
373
-
374
- # Case 3: Both short_url and full_url provided
375
- if short_url and full_url:
376
- found_full_for_short = _find_url_in_json(url_data, short_url=short_url)
377
- found_short_for_full = _find_url_in_json(url_data, full_url=full_url)
378
-
379
- if found_full_for_short == full_url:
380
- return "exists_match", short_url
381
- if found_full_for_short is not None and found_full_for_short != full_url:
382
- return "error_conflict_short_exists_different_full", short_url
383
- if found_short_for_full is not None and found_short_for_full != short_url:
384
- return "error_conflict_full_exists_different_short", found_short_for_full
385
-
386
- # If short_url is provided and not found, or full_url is provided and not found,
387
- # or neither is found, then create a new entry with the provided short_url and full_url.
388
- # This effectively allows specifying a custom short_url if it's not already taken.
389
- url_data = _add_url_to_json(url_data, short_url, full_url)
390
- if _upload_json_to_repo(url_data, repo_id, json_file, repo_type):
391
- return "created_specific_pair", short_url
392
- else:
393
- return "error_upload", None
394
-
395
- return "error_unhandled_case", None # Should not be reached
396
-
397
- def _encrypt_private_key(private_key: str, password: str = None) -> str:
398
- """
399
- Basic encryption for private keys. In production, use proper encryption like Fernet.
400
-
401
- Note: This is a simplified encryption for demonstration. In production environments,
402
- use proper encryption libraries like cryptography.fernet.Fernet with secure key derivation.
403
-
404
- Args:
405
- private_key (str): The private key to encrypt
406
- password (str, optional): Password for encryption. If None, uses a default method.
407
-
408
- Returns:
409
- str: Base64 encoded encrypted private key
410
- """
411
- # WARNING: This is a basic XOR encryption for demo purposes only
412
- # In production, use proper encryption like Fernet from cryptography library
413
- if not password:
414
- password = "default_encryption_key" # In production, use secure key derivation
415
-
416
- encrypted_bytes = []
417
- for i, char in enumerate(private_key):
418
- encrypted_bytes.append(ord(char) ^ ord(password[i % len(password)]))
419
-
420
- encrypted_data = bytes(encrypted_bytes)
421
- return base64.b64encode(encrypted_data).decode('utf-8')
422
-
423
- def _decrypt_private_key(encrypted_private_key: str, password: str = None) -> str:
424
- """
425
- Basic decryption for private keys. In production, use proper decryption like Fernet.
426
-
427
- Args:
428
- encrypted_private_key (str): Base64 encoded encrypted private key
429
- password (str, optional): Password for decryption. If None, uses a default method.
430
-
431
- Returns:
432
- str: Decrypted private key
433
- """
434
- # WARNING: This is a basic XOR decryption for demo purposes only
435
- if not password:
436
- password = "default_encryption_key" # In production, use secure key derivation
437
-
438
- encrypted_data = base64.b64decode(encrypted_private_key)
439
- decrypted_chars = []
440
- for i, byte in enumerate(encrypted_data):
441
- decrypted_chars.append(chr(byte ^ ord(password[i % len(password)])))
442
-
443
- return ''.join(decrypted_chars)
444
-
445
- def store_issuer_keypair(issuer_id: str, public_key: str, private_key: str, repo_id: str = None) -> bool:
446
- """
447
- Store cryptographic keys for an issuer in the private Hugging Face repository.
448
-
449
- **IMPORTANT: This function requires a PRIVATE Hugging Face repository to ensure
450
- the security of stored private keys. Never use this with public repositories.**
451
-
452
- The keys are stored in the following structure:
453
- keys/issuers/{issuer_id}/
454
- ├── private_key.json (encrypted)
455
- └── public_key.json
456
-
457
- Args:
458
- issuer_id (str): Unique identifier for the issuer (e.g., "https://example.edu/issuers/565049")
459
- public_key (str): Multibase-encoded public key
460
- private_key (str): Multibase-encoded private key (will be encrypted before storage)
461
- repo_id (str, optional): Repository ID. If None, uses HF_REPO_ID from constants.
462
-
463
- Returns:
464
- bool: True if keys were stored successfully, False otherwise
465
-
466
- Raises:
467
- ValueError: If issuer_id, public_key, or private_key are empty
468
- Exception: If repository operations fail
469
-
470
- Example:
471
- >>> public_key = "z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK"
472
- >>> private_key = "z3u2MQhLnQw7nvJRGJCdKdqfXHV4N7BLKuEGFWnJqsVSdgYv"
473
- >>> success = store_issuer_keypair("https://example.edu/issuers/565049", public_key, private_key)
474
- >>> print(f"Keys stored: {success}")
475
- """
476
- if not issuer_id or not public_key or not private_key:
477
- logger.error("❌ Missing required parameters: issuer_id, public_key, and private_key are required")
478
- raise ValueError("issuer_id, public_key, and private_key are required")
479
-
480
- if not repo_id:
481
- repo_id = HF_REPO_ID
482
- logger.debug(f"🔧 Using default repository: {repo_id}")
483
-
484
- # Sanitize issuer_id for use as folder name
485
- safe_issuer_id = issuer_id.replace("https://", "").replace("http://", "").replace("/", "_").replace(":", "_")
486
- logger.info(f"🔑 Storing keypair for issuer: {issuer_id}")
487
- logger.debug(f"🗂️ Safe issuer ID: {safe_issuer_id}")
488
-
489
- try:
490
- # Encrypt the private key before storage
491
- encrypted_private_key = _encrypt_private_key(private_key)
492
- logger.debug("🔐 Private key encrypted successfully")
493
-
494
- # Prepare key data structures
495
- private_key_data = {
496
- "issuer_id": issuer_id,
497
- "encrypted_private_key": encrypted_private_key,
498
- "key_type": "Ed25519VerificationKey2020",
499
- "created_at": datetime.now(timezone.utc).isoformat(),
500
- "encryption_method": "basic_xor" # In production, use proper encryption
501
- }
502
-
503
- public_key_data = {
504
- "issuer_id": issuer_id,
505
- "public_key": public_key,
506
- "key_type": "Ed25519VerificationKey2020",
507
- "created_at": datetime.now(timezone.utc).isoformat()
508
- }
509
-
510
- logger.info("📤 Uploading private key...")
511
- # Store private key
512
- private_key_path = f"keys/issuers/{safe_issuer_id}/private_key.json"
513
- private_key_success = _upload_json_to_repo(private_key_data, repo_id, private_key_path, "dataset")
514
-
515
- logger.info("📤 Uploading public key...")
516
- # Store public key
517
- public_key_path = f"keys/issuers/{safe_issuer_id}/public_key.json"
518
- public_key_success = _upload_json_to_repo(public_key_data, repo_id, public_key_path, "dataset")
519
-
520
- # Update global verification methods registry
521
- if private_key_success and public_key_success:
522
- logger.info("📋 Updating verification methods registry...")
523
- _update_verification_methods_registry(issuer_id, safe_issuer_id, public_key, repo_id)
524
- logger.info("✅ Keypair stored successfully and registry updated")
525
- else:
526
- logger.error("❌ Failed to store one or both keys")
527
-
528
- return private_key_success and public_key_success
529
-
530
- except Exception as e:
531
- logger.error(f"💥 Error storing issuer keypair for {issuer_id}: {e}")
532
- return False
533
-
534
- def get_issuer_keypair(issuer_id: str, repo_id: str = None) -> Tuple[Optional[str], Optional[str]]:
535
- """
536
- Retrieve stored cryptographic keys for an issuer from the private Hugging Face repository.
537
-
538
- **IMPORTANT: This function accesses a PRIVATE Hugging Face repository containing
539
- encrypted private keys. Ensure proper access control and security measures.**
540
-
541
- Args:
542
- issuer_id (str): Unique identifier for the issuer
543
- repo_id (str, optional): Repository ID. If None, uses HF_REPO_ID from constants.
544
-
545
- Returns:
546
- Tuple[Optional[str], Optional[str]]: (public_key, private_key) or (None, None) if not found
547
-
548
- Raises:
549
- ValueError: If issuer_id is empty
550
- Exception: If repository operations fail or decryption fails
551
-
552
- Example:
553
- >>> public_key, private_key = get_issuer_keypair("https://example.edu/issuers/565049")
554
- >>> if public_key and private_key:
555
- ... print("Keys retrieved successfully")
556
- ... else:
557
- ... print("Keys not found")
558
- """
559
- if not issuer_id:
560
- logger.error("❌ issuer_id is required")
561
- raise ValueError("issuer_id is required")
562
-
563
- if not repo_id:
564
- repo_id = HF_REPO_ID
565
- logger.debug(f"🔧 Using default repository: {repo_id}")
566
-
567
- # Sanitize issuer_id for use as folder name
568
- safe_issuer_id = issuer_id.replace("https://", "").replace("http://", "").replace("/", "_").replace(":", "_")
569
- logger.info(f"🔍 Retrieving keypair for issuer: {issuer_id}")
570
- logger.debug(f"🗂️ Safe issuer ID: {safe_issuer_id}")
571
-
572
- try:
573
- logger.debug("📥 Retrieving public key...")
574
- # Retrieve public key
575
- public_key_path = f"keys/issuers/{safe_issuer_id}/public_key.json"
576
- public_key_data = _get_json_from_repo(repo_id, public_key_path, "dataset")
577
-
578
- logger.debug("📥 Retrieving private key...")
579
- # Retrieve private key
580
- private_key_path = f"keys/issuers/{safe_issuer_id}/private_key.json"
581
- private_key_data = _get_json_from_repo(repo_id, private_key_path, "dataset")
582
-
583
- if not public_key_data or not private_key_data:
584
- logger.warning(f"⚠️ Keys not found for issuer {issuer_id}")
585
- return None, None
586
-
587
- # Extract and decrypt private key
588
- encrypted_private_key = private_key_data.get("encrypted_private_key")
589
- if not encrypted_private_key:
590
- logger.error(f"❌ No encrypted private key found for issuer {issuer_id}")
591
- return None, None
592
-
593
- logger.debug("🔓 Decrypting private key...")
594
- decrypted_private_key = _decrypt_private_key(encrypted_private_key)
595
- public_key = public_key_data.get("public_key")
596
-
597
- logger.info(f"✅ Successfully retrieved keypair for issuer {issuer_id}")
598
- return public_key, decrypted_private_key
599
-
600
- except Exception as e:
601
- logger.error(f"💥 Error retrieving issuer keypair for {issuer_id}: {e}")
602
- return None, None
603
-
604
- def _update_verification_methods_registry(issuer_id: str, safe_issuer_id: str, public_key: str, repo_id: str):
605
- """
606
- Update the global verification methods registry with new issuer public key.
607
-
608
- Args:
609
- issuer_id (str): Original issuer ID
610
- safe_issuer_id (str): Sanitized issuer ID for file system
611
- public_key (str): Public key to register
612
- repo_id (str): Repository ID
613
- """
614
- try:
615
- registry_path = "keys/global/verification_methods.json"
616
- registry_data = _get_json_from_repo(repo_id, registry_path, "dataset")
617
-
618
- if not registry_data:
619
- registry_data = {"verification_methods": []}
620
-
621
- # Check if issuer already exists in registry
622
- existing_entry = None
623
- for i, method in enumerate(registry_data.get("verification_methods", [])):
624
- if method.get("issuer_id") == issuer_id:
625
- existing_entry = i
626
- break
627
-
628
- # Create new verification method entry
629
- verification_method = {
630
- "issuer_id": issuer_id,
631
- "safe_issuer_id": safe_issuer_id,
632
- "public_key": public_key,
633
- "key_type": "Ed25519VerificationKey2020",
634
- "updated_at": datetime.now(timezone.utc).isoformat()
635
- }
636
-
637
- if existing_entry is not None:
638
- # Update existing entry
639
- registry_data["verification_methods"][existing_entry] = verification_method
640
- logger.info(f"♻️ Updated verification method for issuer {issuer_id}")
641
- else:
642
- # Add new entry
643
- registry_data["verification_methods"].append(verification_method)
644
- logger.info(f"➕ Added new verification method for issuer {issuer_id}")
645
-
646
- # Upload updated registry
647
- _upload_json_to_repo(registry_data, repo_id, registry_path, "dataset")
648
- logger.info("✅ Verification methods registry updated successfully")
649
-
650
- except Exception as e:
651
- logger.error(f"Error updating verification methods registry: {e}")
652
-
653
- def get_verification_methods_registry(repo_id: str = None) -> Dict[str, Any]:
654
- """
655
- Retrieve the global verification methods registry.
656
-
657
- Args:
658
- repo_id (str, optional): Repository ID. If None, uses HF_REPO_ID from constants.
659
-
660
- Returns:
661
- Dict[str, Any]: Registry data containing all verification methods
662
- """
663
- if not repo_id:
664
- repo_id = HF_REPO_ID
665
-
666
- try:
667
- registry_path = "keys/global/verification_methods.json"
668
- registry_data = _get_json_from_repo(repo_id, registry_path, "dataset")
669
- return registry_data if registry_data else {"verification_methods": []}
670
- except Exception as e:
671
- logger.error(f"Error retrieving verification methods registry: {e}")
672
- return {"verification_methods": []}
673
-
674
- def list_issuer_ids(repo_id: str = None) -> List[str]:
675
- """
676
- List all issuer IDs that have stored keys in the repository.
677
-
678
- Args:
679
- repo_id (str, optional): Repository ID. If None, uses HF_REPO_ID from constants.
680
-
681
- Returns:
682
- List[str]: List of issuer IDs
683
- """
684
- if not repo_id:
685
- repo_id = HF_REPO_ID
686
-
687
- try:
688
- registry = get_verification_methods_registry(repo_id)
689
- return [method["issuer_id"] for method in registry.get("verification_methods", [])]
690
- except Exception as e:
691
- logger.error(f"Error listing issuer IDs: {e}")
692
- return []
693
-
694
- def _list_repo_folders(repo_id: str, path_prefix: str, repo_type: str = "dataset") -> List[str]:
695
- """
696
- List folder names under a given path in a HuggingFace repository.
697
-
698
- Args:
699
- repo_id: The repository ID on Hugging Face
700
- path_prefix: The path prefix to list folders under (e.g., "leaderboards/daily/2025-01-27")
701
- repo_type: Repository type ("dataset", "model", "space"). Default is "dataset".
702
-
703
- Returns:
704
- List of folder names (not full paths) found under the path_prefix.
705
- Returns empty list if path not found or on error.
706
- """
707
- try:
708
- login(token=HF_API_TOKEN)
709
- api = HfApi()
710
-
711
- # List all files in the repo under the prefix
712
- # The list_repo_files returns file paths, so we extract unique folder names
713
- all_files = api.list_repo_files(
714
- repo_id=repo_id,
715
- repo_type=repo_type,
716
- token=HF_API_TOKEN
717
- )
718
-
719
- # Ensure path_prefix ends with /
720
- if path_prefix and not path_prefix.endswith("/"):
721
- path_prefix = path_prefix + "/"
722
-
723
- folders = set()
724
- for file_path in all_files:
725
- if file_path.startswith(path_prefix):
726
- # Get the relative path after the prefix
727
- relative_path = file_path[len(path_prefix):]
728
- # Extract the first folder name (before any /)
729
- if "/" in relative_path:
730
- folder_name = relative_path.split("/")[0]
731
- folders.add(folder_name)
732
-
733
- return sorted(list(folders))
734
-
735
- except RepositoryNotFoundError:
736
- logger.warning(f"Repository {repo_id} not found.")
737
- return []
738
- except Exception as e:
739
- logger.error(f"Error listing folders in {repo_id}/{path_prefix}: {e}")
740
- return []
741
-
742
-
743
- def _list_repo_files_in_folder(repo_id: str, folder_path: str, repo_type: str = "dataset") -> List[str]:
744
- """
745
- List file names (not full paths) directly under a folder in a HuggingFace repository.
746
-
747
- Args:
748
- repo_id: The repository ID on Hugging Face
749
- folder_path: The folder path to list files under
750
- repo_type: Repository type. Default is "dataset".
751
-
752
- Returns:
753
- List of file names found directly in the folder.
754
- """
755
- try:
756
- login(token=HF_API_TOKEN)
757
- api = HfApi()
758
-
759
- all_files = api.list_repo_files(
760
- repo_id=repo_id,
761
- repo_type=repo_type,
762
- token=HF_API_TOKEN
763
- )
764
-
765
- # Ensure folder_path ends with /
766
- if folder_path and not folder_path.endswith("/"):
767
- folder_path = folder_path + "/"
768
-
769
- files = []
770
- for file_path in all_files:
771
- if file_path.startswith(folder_path):
772
- relative_path = file_path[len(folder_path):]
773
- # Only include files directly in this folder (no subdirectories)
774
- if "/" not in relative_path and relative_path:
775
- files.append(relative_path)
776
-
777
- return sorted(files)
778
-
779
- except RepositoryNotFoundError:
780
- logger.warning(f"Repository {repo_id} not found.")
781
- return []
782
- except Exception as e:
783
- logger.error(f"Error listing files in {repo_id}/{folder_path}: {e}")
784
- return []
785
-
786
- if __name__ == "__main__":
787
- issuer_id = "https://example.edu/issuers/565049"
788
- # Example usage
789
- public_key, private_key = get_issuer_keypair(issuer_id)
790
- print(f"Public Key: {public_key}")
791
- print(f"Private Key: {private_key}")
792
-
793
- # Example to store keys
794
- store_issuer_keypair(issuer_id, public_key, private_key)
795
-
796
- # Example to list issuer IDs
797
- issuer_ids = list_issuer_ids()
798
-
799
- print(f"Issuer IDs: {issuer_ids}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
battlewords/ui.py CHANGED
@@ -18,8 +18,8 @@ from .generator import generate_puzzle
18
  from .logic import build_letter_map, reveal_cell, guess_word, is_game_over, compute_tier, auto_mark_completed_words, hidden_word_display
19
  from .models import Coord, GameState, Puzzle
20
  from .word_loader import get_wordlist_files, load_word_list, compute_word_difficulties
21
- from .game_storage import load_game_from_sid, save_game_to_hf, get_shareable_url, add_user_result_to_game
22
- from .ui_helpers import inject_styles, fig_to_pil_rgba, ocean_background_css, inject_ocean_layers, pwa_service_worker, show_spinner, fade_out_spinner, start_root_fade_in, finish_root_fade_in
23
 
24
  # --- Spinner context manager for custom spinner ---
25
  class CustomSpinner:
@@ -77,10 +77,6 @@ def _init_session() -> None:
77
  spinner_placeholder = st.empty()
78
  with CustomSpinner(spinner_placeholder, "Initializing session..."):
79
  # --- Load most recent settings from settings/settings.json ---
80
- # --- Preserve music settings ---
81
-
82
- # Check if we're loading a shared game
83
- shared_settings = st.session_state.get("shared_game_settings")
84
 
85
  # Ensure a default selection exists before creating the puzzle
86
  files = get_wordlist_files()
@@ -89,34 +85,9 @@ def _init_session() -> None:
89
  if "game_mode" not in st.session_state:
90
  st.session_state.game_mode = "classic"
91
 
92
- # Generate puzzle with shared game settings if available
93
- if shared_settings:
94
- # Each user gets different random words from the same wordlist source
95
- wordlist_source = shared_settings.get("wordlist_source", "classic.txt")
96
- spacer = shared_settings["puzzle_options"].get("spacer", 1)
97
- may_overlap = shared_settings["puzzle_options"].get("may_overlap", False)
98
- game_mode = shared_settings.get("game_mode", "classic")
99
-
100
- # Override selected wordlist to match challenge
101
- st.session_state.selected_wordlist = wordlist_source
102
-
103
- # Generate puzzle with random words from the challenge's wordlist
104
- words = load_word_list(wordlist_source)
105
- puzzle = generate_puzzle(
106
- grid_size=12,
107
- words_by_len=words,
108
- spacer=spacer,
109
- may_overlap=may_overlap
110
- )
111
- st.session_state.game_mode = game_mode
112
- st.session_state.spacer = spacer
113
-
114
- # Users will see leaderboard showing all players' results
115
- # Each player has their own uid and word_list in the users array
116
- else:
117
- # Normal game generation
118
- words = load_word_list(st.session_state.get("selected_wordlist"))
119
- puzzle = generate_puzzle(grid_size=12, words_by_len=words)
120
 
121
  st.session_state.puzzle = puzzle
122
  st.session_state.grid_size = 12
@@ -141,10 +112,6 @@ def _init_session() -> None:
141
  if "show_incorrect_guesses" not in st.session_state:
142
  st.session_state.show_incorrect_guesses = True
143
 
144
- # NEW: Initialize Show Challenge Share Links (default OFF)
145
- if "show_challenge_share_links" not in st.session_state:
146
- st.session_state.show_challenge_share_links = False
147
-
148
  if "show_grid_ticks" not in st.session_state:
149
  st.session_state.show_grid_ticks = False
150
 
@@ -156,8 +123,6 @@ def _new_game() -> None:
156
  show_grid_ticks = st.session_state.get("show_grid_ticks", False)
157
  spacer = st.session_state.get("spacer",1)
158
  show_incorrect_guesses = st.session_state.get("show_incorrect_guesses", False)
159
- # NEW: Preserve Show Challenge Share Links
160
- show_challenge_share_links = st.session_state.get("show_challenge_share_links", False)
161
 
162
  st.session_state.clear()
163
  if selected:
@@ -167,8 +132,6 @@ def _new_game() -> None:
167
  st.session_state.show_grid_ticks = show_grid_ticks
168
  st.session_state.spacer = spacer
169
  st.session_state.show_incorrect_guesses = show_incorrect_guesses
170
- # NEW: Restore Show Challenge Share Links
171
- st.session_state.show_challenge_share_links = show_challenge_share_links
172
 
173
  st.session_state.radar_gif_path = None
174
  st.session_state.radar_gif_signature = None
@@ -207,106 +170,10 @@ def _sync_back(state: GameState) -> None:
207
 
208
 
209
  def _render_header():
210
- st.title(f"Battlewords v{version}", anchor="title")
211
 
212
  st.subheader("Click the cells to reveal the letters of words on the grid. The radar screen pulses show the LAST letter of the six words. Guess the words for extra points and a better score!", anchor="subtitle")
213
 
214
- # Only show Challenge Mode expander if in challenge mode and game_id is present
215
- params = st.query_params if hasattr(st, "query_params") else {}
216
- is_challenge_mode = "shared_game_settings" in st.session_state and "game_id" in params
217
-
218
- if is_challenge_mode:
219
- with st.expander("🎯 Challenge Mode (click to expand/collapse)", expanded=True):
220
- shared_settings = st.session_state.get("shared_game_settings")
221
- if shared_settings:
222
- users = shared_settings.get("users", [])
223
-
224
- if users:
225
- # Sort users by score (descending), then by time (ascending), then by difficulty (descending)
226
- def leaderboard_sort_key(u):
227
- # Use -score for descending, time for ascending, -difficulty for descending (default 0 if missing)
228
- diff = u.get("word_list_difficulty", 0)
229
- return (-u["score"], u["time"], -diff)
230
-
231
- sorted_users = sorted(users, key=leaderboard_sort_key)
232
- best_user = sorted_users[0]
233
- best_score = best_user["score"]
234
- best_time = best_user["time"]
235
- mins, secs = divmod(best_time, 60)
236
- best_time_str = f"{mins:02d}:{secs:02d}"
237
-
238
- # Build leaderboard HTML
239
- leaderboard_rows = []
240
- for i, user in enumerate(sorted_users[:5], 1): # Top 5
241
- u_mins, u_secs = divmod(user["time"], 60)
242
- u_time_str = f"{u_mins:02d}:{u_secs:02d}"
243
- medal = ["🥇", "🥈", "🥉"][i-1] if i <= 3 else f"{i}."
244
- # show optional difficulty if present
245
- diff_str = ""
246
- if "word_list_difficulty" in user:
247
- try:
248
- diff_str = f" • diff {float(user['word_list_difficulty']):.2f}"
249
- except Exception:
250
- diff_str = ""
251
- leaderboard_rows.append(
252
- f"<div style='padding:0.25rem; font-size:0.85rem;'>{medal} {user['username']}: {user['score']} pts in {u_time_str}{diff_str}</div>"
253
- )
254
- leaderboard_html = "".join(leaderboard_rows)
255
-
256
- # Get the challenge SID from session state
257
- sid = st.session_state.get("loaded_game_sid")
258
- share_html = ""
259
- # NEW: Only render share link when setting enabled
260
- if sid and st.session_state.get("show_challenge_share_links", False):
261
- share_url = get_shareable_url(sid)
262
- share_html = f"<div style='margin-top:1rem;margin-bottom:0.5rem;font-size: 0.9rem;'><a href='{share_url}' target='_blank' style='color:#FFF;text-decoration:underline;'><strong>🔗 Share this challenge</a></strong<br/><br/><span style='font-size:0.85em;color:#ddd;'>{share_url}</span>"
263
-
264
- st.markdown(
265
- f"""
266
- <div style="
267
- background: linear-gradient(90deg, #1d64c8 0%, #165ba8 100%);
268
- color: white;
269
- padding: 1rem;
270
- border-radius: 0.5rem;
271
- margin-bottom: 1rem;
272
- text-align: center;
273
- box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
274
- ">
275
- 🎯 <strong>CHALLENGE MODE</strong> 🎯<br/>
276
- <span style="font-size: 0.9rem;">
277
- Beat the best: <strong>{best_score} points</strong> in <strong>{best_time_str}</strong> by <strong>{best_user['username']}</strong>
278
- </span>
279
- <div style="margin-top:0.75rem; border-top: 1px solid rgba(255,255,255,0.3); padding-top:0.5rem;">
280
- <strong style="font-size:0.9rem;">🏆 Leaderboard</strong>
281
- {leaderboard_html}
282
- </div>
283
- {share_html}
284
- </div>
285
- """,
286
- unsafe_allow_html=True
287
- )
288
- else:
289
- st.markdown(
290
- """
291
- <div style="
292
- background: linear-gradient(90deg, #1d64c8 0%, #165ba8 100%);
293
- color: white;
294
- padding: 1rem;
295
- border-radius: 0.5rem;
296
- margin-bottom: 1rem;
297
- text-align: center;
298
- box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
299
- ">
300
- 🎯 <strong>CHALLENGE MODE</strong> 🎯<br/>
301
- <span style="font-size: 0.9rem;">
302
- Be the first to complete this challenge!
303
- </span>
304
- </div>
305
- """,
306
- unsafe_allow_html=True
307
- )
308
-
309
-
310
  inject_styles()
311
 
312
 
@@ -1132,8 +999,6 @@ def _game_over_content(state: GameState) -> None:
1132
  /*filter: invert(1);*/
1133
  }
1134
  .st-bb {background-color: rgba(29, 100, 200, 0.5);}
1135
- .st-key-generate_share_link div[data-testid="stButton"] button { aspect-ratio: auto;}
1136
- .st-key-generate_share_link div[data-testid="stButton"] button:hover { color: #1d64c8;}
1137
  </style>
1138
  """,
1139
  unsafe_allow_html=True,
@@ -1180,164 +1045,8 @@ def _game_over_content(state: GameState) -> None:
1180
  height=0,
1181
  )
1182
 
1183
- # Share Challenge Button
1184
- st.markdown("---")
1185
-
1186
- # Style the containing Streamlit block via CSS :has() using an anchor inside this container
1187
- with st.container(key="share-challenge"):
1188
- st.markdown(
1189
- """
1190
- <style>
1191
- .st-key-share-challenge{ display:none;}
1192
- /* Apply the dialog background to the Streamlit block that contains our anchor */
1193
- div[data-testid="stVerticalBlock"]:has(#bw-share-anchor) {
1194
- border-radius: 1rem;
1195
- box-shadow: 0 0 32px #1d64c8;
1196
- background: linear-gradient(-45deg, #1d64c8, #ffffff, #1d64c8, #666666);
1197
- color: #fff;
1198
- padding: 16px;
1199
- }
1200
- /* Improve inner text contrast inside the styled block */
1201
- div[data-testid="stVerticalBlock"]:has(#bw-share-anchor) h3,
1202
- div[data-testid="stVerticalBlock"]:has(#bw-share-anchor) label,
1203
- div[data-testid="stVerticalBlock"]:has(#bw-share-anchor) p {
1204
- color: #fff !important;
1205
- }
1206
- /* Ensure code block is readable */
1207
- div[data-testid="stVerticalBlock"]:has(#bw-share-anchor) pre,
1208
- div[data-testid="stVerticalBlock"]:has(#bw-share-anchor) code {
1209
- background: rgba(0,0,0,0.25) !important;
1210
- color: #fff !important;
1211
- }
1212
- /* Buttons hover contrast */
1213
- div[data-testid="stVerticalBlock"]:has(#bw-share-anchor) button:hover {
1214
- filter: brightness(1.1);
1215
- }
1216
- </style>
1217
- <div id="bw-share-anchor"></div>
1218
- """,
1219
- unsafe_allow_html=True,
1220
- )
1221
-
1222
- st.markdown("### 🎮 Share Your Challenge")
1223
-
1224
- # Check if this is a shared game being completed
1225
- is_shared_game = st.session_state.get("loaded_game_sid") is not None
1226
- existing_sid = st.session_state.get("loaded_game_sid")
1227
-
1228
- # Username input
1229
- if "player_username" not in st.session_state:
1230
- st.session_state["player_username"] = ""
1231
-
1232
- username = st.text_input(
1233
- "Enter your name (optional)",
1234
- value=st.session_state.get("player_username", ""),
1235
- key="username_input",
1236
- placeholder="Anonymous"
1237
- )
1238
- if username:
1239
- st.session_state["player_username"] = username
1240
- else:
1241
- username = "Anonymous"
1242
-
1243
- # Check if share URL already generated
1244
- if "share_url" not in st.session_state or st.session_state.get("share_url") is None:
1245
- button_text = "📊 Submit Your Result" if is_shared_game else "🔗 Generate Share Link"
1246
-
1247
- if st.button(button_text, key="generate_share_link", use_container_width=True):
1248
- try:
1249
- # Extract game data
1250
- word_list = [w.text for w in state.puzzle.words]
1251
- spacer = state.puzzle.spacer
1252
- may_overlap = state.puzzle.may_overlap
1253
- wordlist_source = st.session_state.get("selected_wordlist", "unknown")
1254
-
1255
- if is_shared_game and existing_sid:
1256
- # Add result to existing game
1257
- success = add_user_result_to_game(
1258
- sid=existing_sid,
1259
- username=username,
1260
- word_list=word_list, # Each user gets different words
1261
- score=state.score,
1262
- time_seconds=elapsed_seconds
1263
- )
1264
-
1265
- if success:
1266
- share_url = get_shareable_url(existing_sid)
1267
- st.session_state["share_url"] = share_url
1268
- st.session_state["share_sid"] = existing_sid
1269
- st.success(f"✅ Result submitted for {username}!")
1270
- st.rerun()
1271
- else:
1272
- st.error("Failed to submit result")
1273
- else:
1274
- # Create new game
1275
- challenge_id, full_url, sid = save_game_to_hf(
1276
- word_list=word_list,
1277
- username=username,
1278
- score=state.score,
1279
- time_seconds=elapsed_seconds,
1280
- game_mode=state.game_mode,
1281
- grid_size=state.grid_size,
1282
- spacer=spacer,
1283
- may_overlap=may_overlap,
1284
- wordlist_source=wordlist_source
1285
- )
1286
-
1287
- if sid:
1288
- share_url = get_shareable_url(sid)
1289
- st.session_state["share_url"] = share_url
1290
- st.session_state["share_sid"] = sid
1291
- st.rerun()
1292
- else:
1293
- st.error("Failed to generate short URL")
1294
-
1295
- except Exception as e:
1296
- st.error(f"Failed to save game: {e}")
1297
- else:
1298
- # Conditionally display the generated share URL
1299
- if st.session_state.get("show_challenge_share_links", False):
1300
- # Display generated share URL
1301
- share_url = st.session_state["share_url"]
1302
- st.success("✅ Share link generated!")
1303
- st.code(share_url, language=None)
1304
-
1305
- # More robust copy-to-clipboard implementation with fallbacks
1306
- import json as _json
1307
- import html as _html
1308
-
1309
- _share_url_json = _json.dumps(share_url) # safe for JS
1310
- _share_url_attr = _html.escape(share_url, quote=True) # safe for HTML attribute
1311
- _share_url_text = _html.escape(share_url)
1312
-
1313
- components.html(
1314
- f"""
1315
- <div id="bw-copy-container" style="
1316
- display:flex;
1317
- gap:8px;
1318
- width:100%;
1319
- align-items:center;
1320
- margin-top:6px;
1321
- justify-content:center;
1322
- ">
1323
-
1324
- <strong><a href="{_share_url_attr}"
1325
- target="_blank"
1326
- rel="noopener noreferrer"
1327
- style="text-decoration: underline; color: #fff; word-break: break-all; filter: drop-shadow(1px 1px 2px #003);">
1328
- {_share_url_text}
1329
- </a></strong>
1330
- </div>
1331
- """,
1332
- height=80
1333
- )
1334
- else:
1335
- # Do not display the share URL, but confirm it’s saved/submitted
1336
- st.success("✅ Your result has been saved.")
1337
-
1338
- st.markdown("---")
1339
-
1340
  # Dialog actions
 
1341
  if st.button("Close", key="close_game_over"):
1342
  st.session_state["show_gameover_overlay"] = False
1343
  st.rerun()
@@ -1366,56 +1075,8 @@ def _render_game_over(state: GameState):
1366
  if visible:
1367
  _game_over_dialog(state)
1368
 
1369
- def _on_game_option_change() -> None:
1370
- """
1371
- Unified callback for game option changes.
1372
- If currently in a loaded challenge, break the link by resetting challenge mode
1373
- and removing the game_id query param. Then start a new game with the updated options.
1374
- """
1375
- try:
1376
- # Remove challenge-specific query param if present
1377
- if hasattr(st, "query_params"):
1378
- qp = st.query_params
1379
- # st.query_params may be a Mapping; pop safely if supported
1380
- try:
1381
- if "game_id" in qp:
1382
- qp.pop("game_id")
1383
- except Exception:
1384
- # Fallback: clear all params if pop not supported
1385
- try:
1386
- st.query_params.clear()
1387
- except Exception:
1388
- pass
1389
- except Exception:
1390
- pass
1391
-
1392
- # Clear challenge session flags and links
1393
- if st.session_state.get("loaded_game_sid") is not None:
1394
- st.session_state.loaded_game_sid = None
1395
- # Remove loaded challenge settings so UI no longer treats session as challenge mode
1396
- st.session_state.pop("shared_game_settings", None)
1397
- # Ensure the loader won't auto-reload challenge on rerun within this session
1398
- st.session_state["shared_game_loaded"] = True
1399
-
1400
- # Clear any existing generated share link tied to the previous challenge
1401
- st.session_state.pop("share_url", None)
1402
- st.session_state.pop("share_sid", None)
1403
-
1404
- # Start a fresh game with updated options
1405
- _new_game()
1406
-
1407
  def run_app():
1408
  start_root_fade_in(0.0)
1409
- # Render PWA service worker registration (meta tags in <head> via Docker)
1410
- # Streamlit reruns the script frequently; inject this only once per session.
1411
- if not st.session_state.get("pwa_injected", False):
1412
- st.markdown(pwa_service_worker, unsafe_allow_html=True)
1413
- st.session_state["pwa_injected"] = True
1414
-
1415
- # try:
1416
- # st.set_page_config(initial_sidebar_state="collapsed")
1417
- # except Exception:
1418
- # pass
1419
 
1420
  # Handle query params using new API
1421
  try:
@@ -1438,42 +1099,9 @@ def run_app():
1438
  with CustomSpinner(spinner_placeholder, "Initial Load..."):
1439
  st.session_state["initial_page_loaded"] = True
1440
 
1441
- # Basic branch: only render the play experience
1442
- # Settings and leaderboard pages are disabled
1443
-
1444
- # Handle game_id for loading shared games
1445
- if "game_id" in params and "shared_game_loaded" not in st.session_state:
1446
- sid = params.get("game_id")
1447
- spinner_placeholder = st.empty()
1448
- with CustomSpinner(spinner_placeholder, "Loading challenge..."):
1449
- try:
1450
- settings = load_game_from_sid(sid)
1451
- if settings:
1452
- st.session_state["shared_game_settings"] = settings
1453
- st.session_state["loaded_game_sid"] = sid
1454
- st.session_state["shared_game_loaded"] = True
1455
-
1456
- # Get best score and time from users array
1457
- users = settings.get("users", [])
1458
- if users:
1459
- best_score = max(u["score"] for u in users)
1460
- best_time = min(u["time"] for u in users)
1461
- st.toast(
1462
- f"🎯 Loading shared challenge (Best: {best_score} pts in {best_time}s by {len(users)} player(s))",
1463
- icon="ℹ️",
1464
- )
1465
- else:
1466
- st.toast("🎯 Loading shared challenge", icon="ℹ️")
1467
- else:
1468
- st.warning(f"No shared game found for ID: {sid}. Starting a normal game.")
1469
- st.session_state["shared_game_loaded"] = True
1470
- except Exception as e:
1471
- st.error(f"❌ Error loading shared game: {e}")
1472
- st.session_state["shared_game_loaded"] = True
1473
-
1474
- # Show spinner during game initialization
1475
 
1476
- if st.session_state.get("needs_initialization", True):
1477
  spinner_placeholder = st.empty()
1478
  with CustomSpinner(spinner_placeholder, "Initializing Game..."):
1479
  st.session_state.needs_initialization = False
@@ -1491,6 +1119,14 @@ def run_app():
1491
  state = _to_state()
1492
  if is_game_over(state) and not st.session_state.get("hide_gameover_overlay", False):
1493
  _render_game_over(state)
 
 
 
 
 
 
 
 
1494
  finish_root_fade_in(2.0)
1495
 
1496
  def _render_game_tab():
 
18
  from .logic import build_letter_map, reveal_cell, guess_word, is_game_over, compute_tier, auto_mark_completed_words, hidden_word_display
19
  from .models import Coord, GameState, Puzzle
20
  from .word_loader import get_wordlist_files, load_word_list, compute_word_difficulties
21
+ from .ui_helpers import inject_styles, fig_to_pil_rgba, ocean_background_css, inject_ocean_layers, show_spinner, fade_out_spinner, start_root_fade_in, finish_root_fade_in
22
+ from .modules.version_info import versions_html
23
 
24
  # --- Spinner context manager for custom spinner ---
25
  class CustomSpinner:
 
77
  spinner_placeholder = st.empty()
78
  with CustomSpinner(spinner_placeholder, "Initializing session..."):
79
  # --- Load most recent settings from settings/settings.json ---
 
 
 
 
80
 
81
  # Ensure a default selection exists before creating the puzzle
82
  files = get_wordlist_files()
 
85
  if "game_mode" not in st.session_state:
86
  st.session_state.game_mode = "classic"
87
 
88
+ # Normal game generation
89
+ words = load_word_list(st.session_state.get("selected_wordlist"))
90
+ puzzle = generate_puzzle(grid_size=12, words_by_len=words)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
 
92
  st.session_state.puzzle = puzzle
93
  st.session_state.grid_size = 12
 
112
  if "show_incorrect_guesses" not in st.session_state:
113
  st.session_state.show_incorrect_guesses = True
114
 
 
 
 
 
115
  if "show_grid_ticks" not in st.session_state:
116
  st.session_state.show_grid_ticks = False
117
 
 
123
  show_grid_ticks = st.session_state.get("show_grid_ticks", False)
124
  spacer = st.session_state.get("spacer",1)
125
  show_incorrect_guesses = st.session_state.get("show_incorrect_guesses", False)
 
 
126
 
127
  st.session_state.clear()
128
  if selected:
 
132
  st.session_state.show_grid_ticks = show_grid_ticks
133
  st.session_state.spacer = spacer
134
  st.session_state.show_incorrect_guesses = show_incorrect_guesses
 
 
135
 
136
  st.session_state.radar_gif_path = None
137
  st.session_state.radar_gif_signature = None
 
170
 
171
 
172
  def _render_header():
173
+ st.title(f"Battlewords", anchor="title") # removed v{version}
174
 
175
  st.subheader("Click the cells to reveal the letters of words on the grid. The radar screen pulses show the LAST letter of the six words. Guess the words for extra points and a better score!", anchor="subtitle")
176
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
177
  inject_styles()
178
 
179
 
 
999
  /*filter: invert(1);*/
1000
  }
1001
  .st-bb {background-color: rgba(29, 100, 200, 0.5);}
 
 
1002
  </style>
1003
  """,
1004
  unsafe_allow_html=True,
 
1045
  height=0,
1046
  )
1047
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1048
  # Dialog actions
1049
+ st.markdown("---")
1050
  if st.button("Close", key="close_game_over"):
1051
  st.session_state["show_gameover_overlay"] = False
1052
  st.rerun()
 
1075
  if visible:
1076
  _game_over_dialog(state)
1077
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1078
  def run_app():
1079
  start_root_fade_in(0.0)
 
 
 
 
 
 
 
 
 
 
1080
 
1081
  # Handle query params using new API
1082
  try:
 
1099
  with CustomSpinner(spinner_placeholder, "Initial Load..."):
1100
  st.session_state["initial_page_loaded"] = True
1101
 
1102
+ # Basic branch: challenge mode and shared games are disabled
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1103
 
1104
+ if st.session_state.get("needs_initialization", True):
1105
  spinner_placeholder = st.empty()
1106
  with CustomSpinner(spinner_placeholder, "Initializing Game..."):
1107
  st.session_state.needs_initialization = False
 
1119
  state = _to_state()
1120
  if is_game_over(state) and not st.session_state.get("hide_gameover_overlay", False):
1121
  _render_game_over(state)
1122
+
1123
+ # Version info footer (hidden when ?nofooter is present)
1124
+ if not params.get("nofooter"):
1125
+ st.markdown(
1126
+ f'<div style="text-align:center;">{versions_html()}</div>',
1127
+ unsafe_allow_html=True,
1128
+ )
1129
+
1130
  finish_root_fade_in(2.0)
1131
 
1132
  def _render_game_tab():
battlewords/ui_helpers.py CHANGED
@@ -22,152 +22,19 @@ def fig_to_pil_rgba(fig):
22
  def get_effective_game_title() -> str:
23
  """
24
  Get the effective game title, prioritizing:
25
- 1. Challenge-specific game_title from shared_game_settings
26
- 2. Session state game_title (if set)
27
- 3. APP_SETTINGS default
28
- 4. Fallback to "Wrdler"
29
  Returns:
30
  str: The effective game title.
31
  """
32
- # First check shared game settings (challenge mode)
33
- shared_settings = st.session_state.get("shared_game_settings")
34
- if shared_settings and shared_settings.get("game_title"):
35
- return shared_settings["game_title"]
36
-
37
- # Then check session state
38
  if st.session_state.get("game_title"):
39
  return st.session_state["game_title"]
40
 
41
  # Fall back to APP_SETTINGS
42
  return APP_SETTINGS.get("game_title", "Battlewords")
43
 
44
- # PWA (Progressive Web App) Support
45
- # Enables installing BattleWords as a native-feeling mobile app
46
- # Note: PWA meta tags are injected into <head> via Docker build (inject-pwa-head.sh)
47
- # This ensures proper PWA detection by browsers
48
- pwa_service_worker = """
49
- <script>
50
- // Register service worker for offline functionality
51
- // Note: Using inline Blob URL to bypass Streamlit's text/plain content-type for .js files
52
- if ('serviceWorker' in navigator) {
53
- window.addEventListener('load', () => {
54
- // Service worker code as string (inline to avoid MIME type issues)
55
- const swCode = `
56
- const CACHE_NAME = 'battlewords-v0.2.29';
57
- const RUNTIME_CACHE = 'battlewords-runtime';
58
-
59
- const PRECACHE_URLS = [
60
- '/',
61
- '/app/static/manifest.json',
62
- '/app/static/icon-192.png',
63
- '/app/static/icon-512.png'
64
- ];
65
-
66
- self.addEventListener('install', event => {
67
- console.log('[ServiceWorker] Installing...');
68
- event.waitUntil(
69
- caches.open(CACHE_NAME)
70
- .then(cache => {
71
- console.log('[ServiceWorker] Precaching app shell');
72
- return cache.addAll(PRECACHE_URLS);
73
- })
74
- .then(() => self.skipWaiting())
75
- );
76
- });
77
-
78
- self.addEventListener('activate', event => {
79
- console.log('[ServiceWorker] Activating...');
80
- event.waitUntil(
81
- caches.keys().then(cacheNames => {
82
- return Promise.all(
83
- cacheNames.map(cacheName => {
84
- if (cacheName !== CACHE_NAME && cacheName !== RUNTIME_CACHE) {
85
- console.log('[ServiceWorker] Deleting old cache:', cacheName);
86
- return caches.delete(cacheName);
87
- }
88
- })
89
- );
90
- }).then(() => self.clients.claim())
91
- );
92
- });
93
-
94
- self.addEventListener('fetch', event => {
95
- if (event.request.method !== 'GET') return;
96
- if (!event.request.url.startsWith('http')) return;
97
-
98
- event.respondWith(
99
- caches.open(RUNTIME_CACHE).then(cache => {
100
- return fetch(event.request)
101
- .then(response => {
102
- if (response.status === 200) {
103
- cache.put(event.request, response.clone());
104
- }
105
- return response;
106
- })
107
- .catch(() => {
108
- return caches.match(event.request).then(cachedResponse => {
109
- if (cachedResponse) {
110
- console.log('[ServiceWorker] Serving from cache:', event.request.url);
111
- return cachedResponse;
112
- }
113
- return new Response('Offline - Please check your connection', {
114
- status: 503,
115
- statusText: 'Service Unavailable',
116
- headers: new Headers({'Content-Type': 'text/plain'})
117
- });
118
- });
119
- });
120
- })
121
- );
122
- });
123
-
124
- self.addEventListener('message', event => {
125
- if (event.data.action === 'skipWaiting') {
126
- self.skipWaiting();
127
- }
128
- });
129
- `;
130
-
131
- // Create Blob URL for service worker
132
- const blob = new Blob([swCode], { type: 'application/javascript' });
133
- const swUrl = URL.createObjectURL(blob);
134
-
135
- navigator.serviceWorker.register(swUrl)
136
- .then(registration => {
137
- console.log('[PWA] Service Worker registered successfully:', registration.scope);
138
-
139
- registration.addEventListener('updatefound', () => {
140
- const newWorker = registration.installing;
141
- newWorker.addEventListener('statechange', () => {
142
- if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
143
- console.log('[PWA] New version available! Refresh to update.');
144
- }
145
- });
146
- });
147
- })
148
- .catch(error => {
149
- console.log('[PWA] Service Worker registration failed:', error);
150
- });
151
- });
152
- }
153
-
154
- // Prompt user to install PWA (for browsers that support it)
155
- let deferredPrompt;
156
- window.addEventListener('beforeinstallprompt', (e) => {
157
- console.log('[PWA] Install prompt available');
158
- e.preventDefault();
159
- deferredPrompt = e;
160
- // Could show custom install button here if desired
161
- });
162
-
163
- // Track when user installs the app
164
- window.addEventListener('appinstalled', () => {
165
- console.log('[PWA] BattleWords installed successfully!');
166
- deferredPrompt = null;
167
- });
168
- </script>
169
- """
170
-
171
 
172
  ocean_background_css = """
173
  <style>
@@ -591,90 +458,6 @@ def inject_styles() -> None:
591
  unsafe_allow_html=True,
592
  )
593
 
594
- # --- Footer Navigation ---
595
- def render_footer(current_page: str = "play", params: dict = {}) -> None:
596
- """Render footer navigation.
597
-
598
- Args:
599
- current_page: Active page key ("play", "leaderboard", "settings").
600
- """
601
-
602
- # If nofooter=1 is present, do not render the footer
603
- if isinstance(params, dict) and params.get("nofooter") == "1":
604
- return
605
-
606
- play_active = "active" if current_page == "play" else ""
607
- leaderboard_active = "active" if current_page == "leaderboard" else ""
608
- settings_active = "active" if current_page == "settings" else ""
609
-
610
- game_id: Optional[str] = None
611
- if isinstance(params, dict):
612
- game_id = params.get("game_id")
613
- if not game_id:
614
- game_id = st.session_state.get("loaded_game_sid")
615
-
616
- def _url(page: str) -> str:
617
- base = f"?page={page}"
618
- if game_id:
619
- return f"{base}&game_id={game_id}"
620
- return base
621
-
622
- st.markdown(
623
- f"""
624
- <style>
625
- .bw-footer {{
626
- position: fixed;
627
- bottom: 0;
628
- left: 0;
629
- right: 0;
630
- background: linear-gradient(180deg, transparent 0%, rgba(11, 42, 74, 0.95) 30%, rgba(11, 42, 74, 0.98) 100%);
631
- padding: 0.75rem 1rem 0.5rem;
632
- z-index: 9998;
633
- text-align: center;
634
- }}
635
- .bw-footer-nav {{
636
- display: flex;
637
- justify-content: center;
638
- align-items: center;
639
- gap: 1rem;
640
- flex-wrap: wrap;
641
- }}
642
- .bw-footer-nav a {{
643
- color: #d7faff;
644
- text-decoration: none;
645
- font-weight: 600;
646
- font-size: 0.9rem;
647
- padding: 0.4rem 0.8rem;
648
- border-radius: 0.5rem;
649
- background: rgba(29, 100, 200, 0.3);
650
- border: 1px solid rgba(215, 250, 255, 0.3);
651
- transition: all 0.2s ease;
652
- }}
653
- .bw-footer-nav a:hover {{
654
- background: rgba(29, 100, 200, 0.6);
655
- border-color: rgba(215, 250, 255, 0.6);
656
- color: #ffffff;
657
- text-decoration: none;
658
- }}
659
- .bw-footer-nav a.active {{
660
- background: rgba(32, 212, 108, 0.3);
661
- border-color: rgba(32, 212, 108, 0.5);
662
- }}
663
- .stMainBlockContainer {{
664
- padding-bottom: 60px !important;
665
- }}
666
- </style>
667
- <div class="bw-footer">
668
- <nav class="bw-footer-nav">
669
- <a href="{_url('play')}" target="_self" class="{play_active}">Play</a>
670
- <a href="{_url('leaderboard')}" target="_self" class="{leaderboard_active} hide">Leaderboard</a>
671
- <a href="{_url('settings')}" target="_self" class="{settings_active} hide">Settings</a>
672
- </nav>
673
- </div>
674
- """,
675
- unsafe_allow_html=True,
676
- )
677
-
678
 
679
  # --- Spinner Overlay ---
680
  def show_spinner(message: str = "Loading..."):
 
22
  def get_effective_game_title() -> str:
23
  """
24
  Get the effective game title, prioritizing:
25
+ 1. Session state game_title (if set)
26
+ 2. APP_SETTINGS default
27
+ 3. Fallback to "Battlewords"
 
28
  Returns:
29
  str: The effective game title.
30
  """
31
+ # Check session state
 
 
 
 
 
32
  if st.session_state.get("game_title"):
33
  return st.session_state["game_title"]
34
 
35
  # Fall back to APP_SETTINGS
36
  return APP_SETTINGS.get("game_title", "Battlewords")
37
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
 
39
  ocean_background_css = """
40
  <style>
 
458
  unsafe_allow_html=True,
459
  )
460
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
461
 
462
  # --- Spinner Overlay ---
463
  def show_spinner(message: str = "Loading..."):
claude.md CHANGED
@@ -1,413 +1,67 @@
1
- # BattleWords - Project Context
2
 
3
  ## Project Overview
4
- BattleWords is a vocabulary learning game inspired by Battleship mechanics, built with Streamlit and Python 3.12. Players reveal cells on a 12x12 grid to discover hidden words and earn points for strategic guessing.
5
 
6
- **Current Version:** 0.2.37 (Stable - User Features, UI Enhancements)
7
- **Last Updated:** 2026-02-10
8
- **Next Version:** 0.3.0 (In Development - Wrdler improvements)
9
  **Repository:** https://github.com/Oncorporation/BattleWords.git
10
- **Live Demo:** https://huggingface.co/spaces/Surn/BattleWords
11
 
12
- ## Current Upgrades (Wrdler BattleWords)
13
-
14
- The detailed porting checklist lives in `specs/upgrade.mdx`.
15
-
16
- ## Recent Changes
17
- See `README.md` for the canonical release notes: [Recent Changes](README.md#recent-changes)
18
 
19
  ## Core Gameplay
20
- - 12x12 grid with 6 hidden words (2×4-letter, 2×5-letter, 2×6-letter)
21
- - Words placed horizontally or vertically, no overlaps
22
  - Players click cells to reveal letters or empty spaces
23
  - After revealing a letter, players can guess words
24
  - Scoring: word length + bonus for unrevealed letters
25
  - Game ends when all words are guessed or all word letters are revealed
26
- - Incorrect guess history with optional display (enabled by default)
27
- - 10 incorrect guess limit per game
28
- - **✅ IMPLEMENTED (v0.2.20+):** Challenge Mode with game sharing via short URLs
29
- - **✅ IMPLEMENTED (v0.2.20+):** Remote storage via Hugging Face datasets for challenges and leaderboards
30
- - **✅ IMPLEMENTED (v0.2.28):** PWA install support
31
- - **PLANNED (v0.3.0):** Local persistent storage for individual player results and high scores
32
 
33
  ### Scoring Tiers
34
- - **Legendary:** 46+ points (perfect game)
35
- - **Fantastic:** 42-45 points
36
- - **Great:** 38-41 points
37
- - **Good:** 34-37 points
38
- - **Keep practicing:** < 34 points
39
-
40
- ## Technical Architecture
41
-
42
- ### Technology Stack
43
- - **Framework:** Streamlit 1.52.1
44
- - **Language:** Python 3.12.8
45
- - **Visualization:** Matplotlib, NumPy
46
- - **Data Processing:** Pandas, Altair
47
- - **Storage:** JSON-based local persistence
48
- - **Testing:** Pytest
49
- - **Code Quality:** Flake8, MyPy
50
- - **Package Manager:** UV (modern Python package manager)
51
-
52
- ### Project Structure
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  ```
54
  battlewords/
55
  ├── app.py # Streamlit entry point
56
  ├── battlewords/ # Main package
57
- │ ├── __init__.py # Version: 0.2.33
58
- │ ├── models.py # Data models (Coord, Word, Puzzle, GameState)
59
- │ ├── generator.py # Puzzle generation with deterministic seeding
60
- │ ├── logic.py # Game mechanics (reveal, guess, scoring)
61
- │ ├── ui.py # Streamlit UI (~1800 lines)
62
- │ ├── word_loader.py # Word list management
63
- ── audio.py # Background music system
64
- │ ├── sounds.py # Sound effects management
65
- │ ├── generate_sounds.py # Sound generation utilities
66
- │ ├── game_storage.py # HF game storage wrapper (v0.1.0)
67
- │ ├── version_info.py # Version display
68
- │ ├── settings_page.py # Settings page renderer (query-param routing)
69
- │ ├── ui_helpers.py # Footer navigation + shared UI helpers
70
- │ ├── modules/ # Shared utility modules (from OpenBadge)
71
- │ │ ├── __init__.py # Module exports
72
- │ │ ├── storage.py # HuggingFace storage & URL shortener (v0.1.5)
73
- │ │ ├── storage.md # Storage module documentation
74
- │ │ ├── constants.py # Storage-related constants (trimmed)
75
- │ │ └── file_utils.py # File utility functions
76
- │ └── words/ # Word list files
77
- │ ├── classic.txt # Default word list
78
- │ ���── fourth_grade.txt # Elementary word list
79
- │ └── wordlist.txt # Full word list
80
- ├── tests/ # Unit tests
81
- ├── specs/ # Documentation
82
- │ ├── specs.md # Game specifications
83
- │ ├── requirements.md # Implementation requirements
84
- │ └── history.md # Game history
85
- ├── .env # Environment variables
86
- ├── pyproject.toml # Project metadata
87
- ├── requirements.txt # Dependencies
88
- ├── uv.lock # UV lock file
89
- ├── Dockerfile # Container deployment
90
- └── CLAUDE.md # This file - project context for Claude
91
- ```
92
-
93
- ## Key Features
94
-
95
- ### Game Modes
96
- 1. **Classic Mode:** Allows consecutive guessing after correct answers
97
- 2. **Too Easy Mode:** Single guess per reveal
98
-
99
- ### Audio & Visual Effects
100
- - **Background Music:** Toggleable ocean-themed background music with volume control
101
- - **Sound Effects:** Hit/miss/correct/incorrect guess sounds with volume control
102
- - **Animated Radar:** Pulsing rings showing word boundaries (last letter locations)
103
- - **Ocean Theme:** Gradient animated background with wave effects
104
- - **Incorrect Guess History:** Visual display of wrong guesses (toggleable in settings)
105
-
106
- ### ✅ Challenge Mode & Remote Storage (v0.2.20+)
107
- - **Game ID System:** Short URL-based challenge sharing
108
- - Format: `?game_id=<sid>` in URL (shortened URL reference)
109
- - Each player gets different random words from the same wordlist
110
- - Enables fair challenges between players
111
- - Stored in Hugging Face dataset repository
112
- - **Remote Storage via HuggingFace Hub:**
113
- - Per-game settings JSON in `games/{uid}/settings.json`
114
- - Shortened URL mapping in `shortener.json`
115
- - Multi-user leaderboards with score, time, and difficulty tracking
116
- - Results sorted by: highest score → fastest time → highest difficulty
117
- - **Challenge Features:**
118
- - Submit results to existing challenges
119
- - Create new challenges from any completed game
120
- - Top 5 leaderboard display in Challenge Mode banner
121
- - Optional player names (defaults to "Anonymous")
122
- - Word list difficulty calculation and display
123
- - "Show Challenge Share Links" toggle (default OFF) to control URL visibility
124
- - Each player gets different random words from the same wordlist
125
-
126
- ### PLANNED: Local Player Storage (v0.3.0)
127
- - **Local Storage:**
128
- - Location: `~/.battlewords/data/`
129
- - Files: `game_results.json`, `highscores.json`
130
- - Privacy-first: no cloud dependency, offline-capable
131
- - **Personal High Scores:**
132
- - Top 100 scores tracked automatically on local machine
133
- - Filterable by wordlist and game mode
134
- - High score sidebar expander display
135
- - **Player Statistics:**
136
- - Games played, average score, best score
137
- - Fastest completion time
138
- - Per-player history on local device
139
-
140
- ### Puzzle Generation
141
- - Deterministic seeding support for reproducible puzzles
142
- - Configurable word spacing (spacer: 0-2)
143
- - 0: Words may touch
144
- - 1: At least 1 blank cell between words (default)
145
- - 2: At least 2 blank cells between words
146
- - Validation ensures no overlaps, proper bounds, correct word distribution
147
-
148
- ### UI Components (Current)
149
- - **Radar Visualization:** Animated matplotlib GIF showing word boundaries
150
- - Displays pulsing rings at last letter of each word
151
- - Hides rings for guessed words
152
- - Three-layer composition: gradient background, scope image, animated rings
153
- - Cached per-puzzle with signature matching
154
- - **Game Grid:** Interactive 12x12 button grid with responsive layout
155
- - **Score Panel:** Compact score table (timer display removed)
156
- - **Routing/Navigation:** Query-param routing via `?page=play|settings|leaderboard`
157
- - **Footer Navigation:** Primary navigation is in the footer (links may be conditionally hidden)
158
- - **Settings:** Dedicated settings page renderer (not a sidebar panel)
159
- - **Theme System:** Ocean gradient background with CSS animations
160
- - **Game Over Dialog:** Final score display with tier ranking
161
- - **Incorrect Guess Display:** Shows history of wrong guesses with count
162
- - **✅ Challenge Mode UI (v0.2.20+):**
163
- - Challenge Mode banner with leaderboard (top 5 players)
164
- - Share challenge button in game over dialog
165
- - Submit result or create new challenge options
166
- - Word list difficulty display
167
- - Conditional share URL visibility toggle
168
- - **PLANNED (v0.3.0):** Local high scores expander in sidebar
169
- - **PLANNED (v0.3.0):** Personal statistics display
170
-
171
- ### Recent Changes & Branch Status
172
- **Branch:** hugs
173
-
174
- **Latest (v0.2.17):**
175
- - Documentation updates and corrections
176
- - Updated CLAUDE.md with accurate feature status
177
- - Clarified v0.3.0 planned features vs current implementation
178
- - Added comprehensive project structure details
179
- - Improved version tracking and roadmap clarity
180
-
181
- **Previously Fixed (v0.2.16):**
182
- - Replace question marks with underscores in score panel
183
- - Add toggle for incorrect guess history display (enabled by default)
184
- - Game over popup positioning improvements
185
- - Music playback after game end
186
- - Sound effect and music volume issues
187
- - Radar alignment inconsistencies
188
- - Added `fig.subplots_adjust(left=0, right=0.9, top=0.9, bottom=0)`
189
- - Set `fig.patch.set_alpha(0.0)` for transparent background
190
- - Maintains 2% margin for tick visibility while ensuring consistent layer alignment
191
-
192
- **Completed (v0.2.20-0.2.27 - Challenge Mode):**
193
- - ✅ Imported storage modules from OpenBadge project:
194
- - `battlewords/modules/storage.py` (v0.1.5) - HuggingFace storage & URL shortener
195
- - `battlewords/modules/constants.py` (trimmed) - Storage-related constants
196
- - `battlewords/modules/file_utils.py` - File utility functions
197
- - `battlewords/modules/storage.md` - Documentation
198
- - ✅ Created `battlewords/game_storage.py` (v0.1.0) - BattleWords storage wrapper:
199
- - `save_game_to_hf()` - Save game to HF repo and generate short URL
200
- - `load_game_from_sid()` - Load game from short ID
201
- - `generate_uid()` - Generate unique game identifiers
202
- - `serialize_game_settings()` - Convert game data to JSON
203
- - `get_shareable_url()` - Generate shareable URLs
204
- - `add_user_result_to_game()` - Append results to existing challenges
205
- - ✅ UI integration complete (`battlewords/ui.py`):
206
- - Query parameter parsing for `?game_id=<sid>` on app load
207
- - Load shared game settings into session state
208
- - Challenge Mode banner with leaderboard (top 5)
209
- - Share button in game over dialog with "Generate Share Link" or "Submit Result"
210
- - Conditional share URL display based on settings toggle
211
- - Automatic save to HuggingFace on game completion
212
- - Word list difficulty calculation and display
213
- - ✅ Generator updates (`battlewords/generator.py`):
214
- - Added `target_words` parameter for loading specific words
215
- - Added `may_overlap` parameter (for future crossword mode)
216
- - Support for shared game replay with randomized word positions
217
-
218
- **In Progress (v0.3.0 - Local Player History):**
219
- - ⏳ Local storage module (`battlewords/local_storage.py`)
220
- - ⏳ Personal high score tracking (local JSON files)
221
- - ⏳ High score sidebar UI display
222
- - ⏳ Player statistics tracking and display
223
-
224
- ## Data Models
225
-
226
- ### Core Classes
227
- ```python
228
- @dataclass
229
- class Coord:
230
- x: int # row, 0-based
231
- y: int # col, 0-based
232
-
233
- @dataclass
234
- class Word:
235
- text: str
236
- start: Coord
237
- direction: Direction # "H" or "V"
238
- cells: List[Coord]
239
-
240
- @dataclass
241
- class Puzzle:
242
- words: List[Word]
243
- radar: List[Coord]
244
- may_overlap: bool
245
- spacer: int
246
- uid: str # Unique identifier for caching
247
-
248
- @dataclass
249
- class GameState:
250
- grid_size: int
251
- puzzle: Puzzle
252
- revealed: Set[Coord]
253
- guessed: Set[str]
254
- score: int
255
- last_action: str
256
- can_guess: bool
257
- game_mode: str
258
- points_by_word: Dict[str, int]
259
- start_time: Optional[datetime]
260
- end_time: Optional[datetime]
261
- ```
262
-
263
- ## Development Workflow
264
-
265
- ### Running Locally
266
- ```bash
267
- # Install dependencies
268
- uv pip install -r requirements.txt --link-mode=copy
269
-
270
- # Run app
271
- uv run streamlit run app.py
272
- # or
273
- streamlit run app.py
274
- ```
275
-
276
- ### Docker Deployment
277
- ```bash
278
- docker build -t battlewords .
279
- docker run -p 8501:8501 battlewords
280
- ```
281
-
282
- ### Testing
283
- ```bash
284
- pytest tests/
285
- ```
286
-
287
- ### Environment Variables (for Challenge Mode)
288
- Challenge Mode requires HuggingFace Hub access for remote storage. Create a `.env` file in the project root:
289
-
290
- ```bash
291
- # Required for Challenge Mode
292
- HF_API_TOKEN=hf_xxxxxxxxxxxxxxxxxxxxx # or HF_TOKEN
293
- HF_REPO_ID=YourUsername/YourRepo # Target HF dataset repo
294
- SPACE_NAME=YourUsername/BattleWords # Your HF Space name
295
-
296
- # Optional
297
- CRYPTO_PK= # Reserved for future signing
298
  ```
299
-
300
- **How to get your HF_API_TOKEN:**
301
- 1. Go to https://huggingface.co/settings/tokens
302
- 2. Create a new token with `write` access
303
- 3. Add to `.env` file as `HF_API_TOKEN=hf_...`
304
-
305
- **HF_REPO_ID Structure:**
306
- The dataset repository will contain:
307
- - `shortener.json` - Short URL mappings
308
- - `games/{uid}/settings.json` - Per-game challenge data
309
- - `games/{uid}/result.json` - Optional detailed results
310
-
311
- **Note:** The app will work without these variables but Challenge Mode features (sharing, leaderboards) will be disabled.
312
-
313
- ## Git Configuration & Deployment
314
- **Current Branch:** cc-01
315
- **Purpose:** Storage and sharing features (v0.3.0 development)
316
- **Main Branch:** main (not specified in git config, but typical convention)
317
-
318
- ### Remotes
319
- - **ONCORP (origin):** https://github.com/Oncorporation/BattleWords.git (main repository)
320
- - **Hugging:** https://huggingface.co/spaces/Surn/BattleWords (live deployment)
321
-
322
- ## Known Issues
323
- - Word list loading bug: App may not select proper word lists in some environments
324
- - Investigation needed in `word_loader.get_wordlist_files()` and `load_word_list()`
325
- - Sidebar selection persistence needs verification
326
-
327
- ## v0.3.0 Development Status (cc-01 branch)
328
-
329
- ### Completed ✅
330
- - `battlewords/storage.py` module created with:
331
- - `GameStorage` class for JSON-based local storage
332
- - `GameResult` and `HighScoreEntry` dataclasses
333
- - Functions: `generate_game_id_from_words()`, `parse_game_id_from_url()`, `create_shareable_url()`
334
- - Storage location: `~/.battlewords/data/` (game_results.json, highscores.json)
335
- - Documentation updated in specs/ folder
336
-
337
- ### In Progress ⏳
338
- - Puzzle model integration for game_id field
339
- - Generator updates for `target_words` parameter (replay from game_id)
340
- - UI integration:
341
- - Storage calls on game completion
342
- - High score display in sidebar
343
- - Share button in game over dialog
344
- - Query parameter parsing for game_id
345
- - Player name input in sidebar
346
-
347
- ### Planned 📋
348
- - Unit tests for storage module
349
- - Integration tests for complete storage flow
350
- - Game replay from shared ID functionality
351
- - Player statistics display
352
- - Share results text generation
353
-
354
- ## Future Roadmap
355
-
356
- ### Phase 1.5 (v0.3.0) - Current Focus ⏳
357
- - ✅ Storage module with local JSON persistence (backend complete)
358
- - ✅ Game ID generation system (backend complete)
359
- - ⏳ High score tracking and display (backend complete, UI pending)
360
- - ⏳ Share challenge functionality (UI integration pending)
361
- - ⏳ Game replay from shared IDs (generator updates needed)
362
- - ⏳ Player name input and statistics
363
-
364
- ### Beta (v0.5.0)
365
- - Word overlaps on shared letters (crossword-style gameplay)
366
- - Enhanced responsive layout for mobile/tablet
367
- - Keyboard navigation and guessing
368
- - Deterministic seed UI for custom puzzles
369
- - Improved accessibility features
370
-
371
- ### Full (v1.0.0)
372
- - Optional cloud storage backend (FastAPI)
373
- - Daily puzzle mode with global leaderboards
374
- - Practice mode with hints
375
- - Enhanced UX features (animations, themes)
376
- - Multiple difficulty levels
377
- - Internationalization (i18n) support
378
-
379
- ## Deployment Targets
380
- - **Hugging Face Spaces:** Primary deployment platform
381
- - **Docker:** Containerized deployment for any platform
382
- - **Local:** Development and testing
383
-
384
- ### Privacy & Data
385
- - All storage is local (no telemetry)
386
- - Player names optional
387
- - No data leaves user's machine
388
- - Easy to delete: just remove `~/.battlewords/data/`
389
-
390
- ## Notes for Claude
391
- - Project uses modern Python features (3.12+)
392
- - Heavy use of Streamlit session state for game state management
393
- - Matplotlib figures are converted to PIL images and animated GIFs
394
- - Client-side JavaScript for timer updates without page refresh
395
- - CSS heavily customized for game aesthetics
396
- - All file paths should be absolute when working in WSL environment
397
- - Current working directory: `/mnt/d/Projects/Battlewords`
398
- - Storage features are backward-compatible (game works without storage)
399
- - Game IDs are deterministic for consistent sharing
400
- - JSON storage chosen for simplicity and privacy
401
-
402
- ### WSL Environment Python Versions
403
- The development environment is WSL (Windows Subsystem for Linux) with access to both native Linux and Windows Python installations:
404
-
405
- **Native WSL (Linux):**
406
- - `python3` → Python 3.10.12 (`/usr/bin/python3`)
407
- - `python3.10` → Python 3.10.12
408
-
409
- **Windows Python (accessible via WSL):**
410
- - `python311.exe` → Python 3.11.9 (`/mnt/c/Users/cfettinger/AppData/Local/Programs/Python/Python311/`)
411
- - `python3.13.exe` → Python 3.13.1 (`/mnt/c/ProgramData/chocolatey/bin/`)
412
-
413
- **Note:** Windows Python executables (`.exe`) can be invoked directly from WSL and are useful for testing compatibility across Python versions. The project targets Python 3.12+ but can run on 3.10+.
 
1
+ # BattleWords - Project Context (Basic Branch)
2
 
3
  ## Project Overview
4
+ BattleWords is a vocabulary learning game inspired by Battleship mechanics, built with Streamlit and Python. Players reveal cells on a 12×12 grid to discover hidden words.
5
 
6
+ **Current Version:** 0.2.37 BASIC
7
+ **Branch:** `basic`
8
+ **Last Updated:** 2026-02-11
9
  **Repository:** https://github.com/Oncorporation/BattleWords.git
 
10
 
11
+ This file describes the intended scope of the `basic` branch only.
 
 
 
 
 
12
 
13
  ## Core Gameplay
14
+ - 12×12 grid with 6 hidden words (2×4-letter, 2×5-letter, 2×6-letter)
15
+ - Words placed horizontally or vertically
16
  - Players click cells to reveal letters or empty spaces
17
  - After revealing a letter, players can guess words
18
  - Scoring: word length + bonus for unrevealed letters
19
  - Game ends when all words are guessed or all word letters are revealed
 
 
 
 
 
 
20
 
21
  ### Scoring Tiers
22
+ - Legendary: 46+
23
+ - Fantastic: 4245
24
+ - Great: 3841
25
+ - Good: 3437
26
+
27
+ ## UI / UX (Basic)
28
+ - Single play experience rendered from `app.py`
29
+ - No sidebar
30
+ - No footer navigation
31
+ - No query-param routing to non-game pages
32
+ - `CustomSpinner` is used for smooth transitions (initial load, New Game, Game Over)
33
+ - Radar visualization rendered via matplotlib and saved as GIF
34
+
35
+ ## Word Lists
36
+ - Loaded only from local files in `battlewords/words/`
37
+ - No AI-generated / model-backed word list generation
38
+
39
+ ## Tech Stack
40
+ - Python 3.10+
41
+ - Streamlit
42
+ - matplotlib
43
+ - numpy
44
+ - Pillow
45
+
46
+ ## Out of Scope (Basic)
47
+ - Challenge mode / remote storage / share links
48
+ - PWA support
49
+ - Audio (music and sound effects)
50
+ - Settings page
51
+ - Leaderboard
52
+ - Tests
53
+
54
+ ## Project Structure (Relevant)
55
  ```
56
  battlewords/
57
  ├── app.py # Streamlit entry point
58
  ├── battlewords/ # Main package
59
+ │ ├── __init__.py
60
+ │ ├── models.py # Data models
61
+ │ ├── generator.py # Puzzle generation
62
+ │ ├── logic.py # Reveal/guess/scoring
63
+ │ ├── ui.py # Streamlit UI
64
+ │ ├── ui_helpers.py # Styles + transitions
65
+ ── words/ # Local word lists
66
+ ── specs/ # Documentation
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  ```
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
generate_pwa_icons.py DELETED
@@ -1,98 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- Generate PWA icons for BattleWords.
4
- Creates 192x192 and 512x512 icons with ocean theme and 'BW' text.
5
- """
6
-
7
- from PIL import Image, ImageDraw, ImageFont
8
- import os
9
-
10
- def create_icon(size, filename):
11
- """Create a square icon with ocean gradient background and 'BW' text."""
12
-
13
- # Create image with ocean blue gradient
14
- img = Image.new('RGB', (size, size))
15
- draw = ImageDraw.Draw(img)
16
-
17
- # Draw vertical gradient (ocean theme)
18
- water_sky = (29, 100, 200) # #1d64c8
19
- water_deep = (11, 42, 74) # #0b2a4a
20
-
21
- for y in range(size):
22
- # Interpolate between sky and deep
23
- ratio = y / size
24
- r = int(water_sky[0] * (1 - ratio) + water_deep[0] * ratio)
25
- g = int(water_sky[1] * (1 - ratio) + water_deep[1] * ratio)
26
- b = int(water_sky[2] * (1 - ratio) + water_deep[2] * ratio)
27
- draw.rectangle([(0, y), (size, y + 1)], fill=(r, g, b))
28
-
29
- # Draw circular background for better icon appearance
30
- circle_margin = size // 10
31
- circle_bbox = [circle_margin, circle_margin, size - circle_margin, size - circle_margin]
32
-
33
- # Draw white circle with transparency
34
- overlay = Image.new('RGBA', (size, size), (255, 255, 255, 0))
35
- overlay_draw = ImageDraw.Draw(overlay)
36
- overlay_draw.ellipse(circle_bbox, fill=(255, 255, 255, 40))
37
-
38
- # Composite the overlay
39
- img = img.convert('RGBA')
40
- img = Image.alpha_composite(img, overlay)
41
-
42
- # Draw 'BW' text
43
- font_size = size // 3
44
- try:
45
- # Try to load a nice bold font
46
- font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", font_size)
47
- except Exception:
48
- try:
49
- # Fallback for Windows
50
- font = ImageFont.truetype("C:/Windows/Fonts/arialbd.ttf", font_size)
51
- except Exception:
52
- # Ultimate fallback
53
- font = ImageFont.load_default()
54
-
55
- draw = ImageDraw.Draw(img)
56
- text = "BW"
57
-
58
- # Get text bounding box for centering
59
- bbox = draw.textbbox((0, 0), text, font=font)
60
- text_width = bbox[2] - bbox[0]
61
- text_height = bbox[3] - bbox[1]
62
-
63
- # Center the text
64
- x = (size - text_width) // 2
65
- y = (size - text_height) // 2 - (bbox[1] // 2)
66
-
67
- # Draw text with shadow for depth
68
- shadow_offset = size // 50
69
- draw.text((x + shadow_offset, y + shadow_offset), text, fill=(0, 0, 0, 100), font=font)
70
- draw.text((x, y), text, fill='white', font=font)
71
-
72
- # Convert back to RGB for saving as PNG
73
- if img.mode == 'RGBA':
74
- background = Image.new('RGB', img.size, (11, 42, 74))
75
- background.paste(img, mask=img.split()[3]) # Use alpha channel as mask
76
- img = background
77
-
78
- # Save
79
- img.save(filename, 'PNG', optimize=True)
80
- print(f"[OK] Created {filename} ({size}x{size})")
81
-
82
- def main():
83
- """Generate both icon sizes."""
84
- script_dir = os.path.dirname(os.path.abspath(__file__))
85
- static_dir = os.path.join(script_dir, 'battlewords', 'static')
86
-
87
- # Ensure directory exists
88
- os.makedirs(static_dir, exist_ok=True)
89
-
90
- # Generate icons
91
- print("Generating PWA icons for BattleWords...")
92
- create_icon(192, os.path.join(static_dir, 'icon-192.png'))
93
- create_icon(512, os.path.join(static_dir, 'icon-512.png'))
94
- print("\n[SUCCESS] PWA icons generated successfully!")
95
- print(f" Location: {static_dir}")
96
-
97
- if __name__ == '__main__':
98
- main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
inject-pwa-head.sh DELETED
@@ -1,49 +0,0 @@
1
- #!/bin/bash
2
- # Inject PWA meta tags into Streamlit's index.html head section
3
- # This script modifies the Streamlit index.html during Docker build
4
-
5
- set -e
6
-
7
- echo "[PWA] Injecting PWA meta tags into Streamlit's index.html..."
8
-
9
- # Find Streamlit's index.html
10
- STREAMLIT_INDEX=$(python3 -c "import streamlit; import os; print(os.path.join(os.path.dirname(streamlit.__file__), 'static', 'index.html'))")
11
-
12
- if [ ! -f "$STREAMLIT_INDEX" ]; then
13
- echo "[PWA] ERROR: Streamlit index.html not found at: $STREAMLIT_INDEX"
14
- exit 1
15
- fi
16
-
17
- echo "[PWA] Found Streamlit index.html at: $STREAMLIT_INDEX"
18
-
19
- # Check if already injected (to make script idempotent)
20
- if grep -q "PWA (Progressive Web App) Meta Tags" "$STREAMLIT_INDEX"; then
21
- echo "[PWA] PWA tags already injected, skipping..."
22
- exit 0
23
- fi
24
-
25
- # Read the injection content
26
- INJECT_FILE="/app/pwa-head-inject.html"
27
- if [ ! -f "$INJECT_FILE" ]; then
28
- echo "[PWA] ERROR: Injection file not found at: $INJECT_FILE"
29
- exit 1
30
- fi
31
-
32
- # Create backup
33
- cp "$STREAMLIT_INDEX" "${STREAMLIT_INDEX}.backup"
34
-
35
- # Use awk to inject after <head> tag
36
- awk -v inject_file="$INJECT_FILE" '
37
- /<head>/ {
38
- print
39
- while ((getline line < inject_file) > 0) {
40
- print line
41
- }
42
- close(inject_file)
43
- next
44
- }
45
- { print }
46
- ' "${STREAMLIT_INDEX}.backup" > "$STREAMLIT_INDEX"
47
-
48
- echo "[PWA] PWA meta tags successfully injected!"
49
- echo "[PWA] Backup saved as: ${STREAMLIT_INDEX}.backup"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
pwa-head-inject.html DELETED
@@ -1,8 +0,0 @@
1
- <!-- PWA (Progressive Web App) Meta Tags -->
2
- <link rel="manifest" href="/app/static/manifest.json">
3
- <meta name="theme-color" content="#165ba8">
4
- <meta name="apple-mobile-web-app-capable" content="yes">
5
- <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
6
- <meta name="apple-mobile-web-app-title" content="BattleWords">
7
- <link rel="apple-touch-icon" href="/app/static/icon-192.png">
8
- <meta name="mobile-web-app-capable" content="yes">
 
 
 
 
 
 
 
 
 
pyproject.toml CHANGED
@@ -1,7 +1,7 @@
1
  [project]
2
  name = "battlewords"
3
  version = "0.2.37"
4
- description = "BattleWords vocabulary game with game sharing via shortened game_id URL referencing server-side JSON settings"
5
  readme = "README.md"
6
  requires-python = ">=3.12,<3.13"
7
  dependencies = [
 
1
  [project]
2
  name = "battlewords"
3
  version = "0.2.37"
4
+ description = "BattleWords vocabulary game (basic branch)"
5
  readme = "README.md"
6
  requires-python = ">=3.12,<3.13"
7
  dependencies = [
specs/basic.mdx CHANGED
@@ -90,21 +90,14 @@ The basic branch uses:
90
  - `component.html` is allowed for UI transitions/effects (spinners, scroll-to-anchor, overlays).
91
  - `component.html` must not be used for audio mounting in the basic branch.
92
 
93
- ## Possible features to remove
94
-
95
- The following features are currently included, but may be considered for removal in an even more minimal version:
96
- - Challenge mode / Share Links / Remote Storage (HF)
97
- - PWA support (service worker, manifest, installability)
98
-
99
-
100
  ## Implementation Checklist
101
 
102
  - App starts with no errors.
103
  - New Game transition remains smooth (uses `CustomSpinner`).
104
  - Core gameplay works end-to-end (reveal, guess, scoring, radar, game over).
105
  - Game Over / Congratulations popup behaves correctly and remains smooth.
106
- - Challenge mode works via `?game_id=...` (load, play, submit/share).
107
- - PWA support remains functional (service worker/manifest still injected/served).
108
  - No audio playback occurs (no background music, no sound effects).
109
  - No AI/HF-model-backed word list generation occurs (local wordlists only).
110
  - No settings/leaderboard pages are reachable (`?page=settings` / `?page=leaderboard` disabled).
 
90
  - `component.html` is allowed for UI transitions/effects (spinners, scroll-to-anchor, overlays).
91
  - `component.html` must not be used for audio mounting in the basic branch.
92
 
 
 
 
 
 
 
 
93
  ## Implementation Checklist
94
 
95
  - App starts with no errors.
96
  - New Game transition remains smooth (uses `CustomSpinner`).
97
  - Core gameplay works end-to-end (reveal, guess, scoring, radar, game over).
98
  - Game Over / Congratulations popup behaves correctly and remains smooth.
99
+ - Challenge mode / share links / remote storage are removed.
100
+ - PWA support is removed.
101
  - No audio playback occurs (no background music, no sound effects).
102
  - No AI/HF-model-backed word list generation occurs (local wordlists only).
103
  - No settings/leaderboard pages are reachable (`?page=settings` / `?page=leaderboard` disabled).
specs/history.mdx DELETED
@@ -1,25 +0,0 @@
1
- # Battlewords: History
2
-
3
- ## Foundation
4
- - Invented in the early 1980s as "Word Battle," a paper-and-pencil grid game inspired by Battleship.
5
- - Used in reading classes for international students at San Diego State University.
6
- - In 1992, developed for submission to game agents; accepted by Technical Game Services (TGS).
7
-
8
- ## Rename & Presentation
9
- - Renamed "Battlewords" in 1993.
10
- - Presented to Milton-Bradley at the New York trade show, but only as a videotaped paper prototype.
11
- - MB declined due to another European word game in development.
12
-
13
- ## Sabotage & Similar Games
14
- - Turkish students reported a similar game, "The Admiral Sank," in Turkey.
15
- - Suspicions arose that students adapted and sold the idea, which became "Battle Words" (two words) in Europe, licensed to Hasbro International.
16
- - MB ultimately rejected the original Battlewords after seeing the European version.
17
-
18
- ## Reboot & Digital Version
19
- - The European "Battle Words" faded into obscurity.
20
- - The original creator retained prototypes and correspondence.
21
- - Later collaborated with a Turkish programmer to create a Flash version of Battlewords for the website.
22
- - Plans to further develop the game.
23
-
24
- ## Copyright
25
- BattlewordsTM. All Rights Reserved. All content, trademarks and logos are copyrighted by the owner.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
specs/requirements.mdx CHANGED
@@ -1,316 +1,99 @@
1
- # Battlewords: Implementation Requirements
2
 
3
- **Current Version:** 0.2.37
4
- **Last Updated:** 2026-02-10
 
5
 
6
- ## Recent Changes (v0.2.37)
7
- See `README.md` for the canonical release notes: [Recent Changes](README.md#recent-changes)
8
 
9
- This document breaks down the tasks to build Battlewords using the game rules described in `specs.md`. It is organized in phases: a minimal Proof of Concept (POC), a Beta Version (0.5.0), and a Full Version (1.0.0).
10
 
11
- Assumptions
12
- - Tech stack: Python 3.10+, Streamlit for UI, matplotlib for radar, numpy for tick helpers, Pillow for animated GIFs.
13
- - Single-player, local state stored in Streamlit session state for POC.
14
- - Grid is always 12x12 with exactly six words: two 4-letter, two 5-letter, two 6-letter words; horizontal/vertical only; no shared letters or overlaps in POC; shared-letter overlaps allowed in Beta; no overlaps in Full.
 
 
15
  - Entry point is `app.py`.
16
 
17
- Streamlit key components (API usage plan)
18
- - State & caching
19
- - `st.session_state` for `puzzle`, `grid_size`, `revealed`, `guessed`, `score`, `last_action`, `can_guess`.
20
- - `st.session_state.points_by_word` for per-word score breakdown.
21
- - `st.session_state.letter_map` derived from puzzle.
22
- - `st.session_state.selected_wordlist` for sidebar picker.
23
- - `st.session_state.radar_gif_path` for session-persistent radar animation.
24
- - `st.session_state.show_incorrect_guesses` toggle.
25
- - `st.session_state.show_challenge_share_links` toggle (v0.2.27, default OFF) to control visibility of challenge share links in the header and Game Over dialog.
26
 
27
- - Layout & structure
28
- - `st.title`, `st.subheader`, `st.markdown` for headers/instructions.
29
- - `st.columns(12)` to render the 12×12 grid; `st.container` for grouping; `st.sidebar` for secondary controls/help.
30
- - `st.expander` for inline help/intel tips.
31
- - Query-param routing via `st.query_params` using `?page=play|settings|leaderboard`.
32
- - Footer navigation via `render_footer(current_page=...)`.
33
- - Widgets (interaction)
34
- - `st.button` for each grid cell (144 total) with unique `key` to handle reveals.
35
- - `st.form` + `st.text_input` + `st.form_submit_button("OK")` for controlled word guessing (disabled until guessing is allowed).
36
- - `st.button("New Game")` to reset state; sidebar `selectbox` for wordlist selection and `Sort Wordlist` button (length+alpha).
37
- - `st.metric` to show score.
38
- - Visualization
39
- - Animated radar using matplotlib `FuncAnimation` + `PillowWriter` saved to GIF.
40
- - Scope overlay image generated once and reused; metallic gradient background.
41
- - Radar plot uses inverted Y so (0,0) is top-left.
42
- - Control flow
43
- - App reruns on interaction; uses `st.rerun()` after state changes (reveal, guess); `st.stop()` after game over summary to freeze UI.
44
 
45
- Folder Structure
46
- - `app.py` Streamlit entry point
47
- - `battlewords/` – Python package
48
- - `__init__.py`
49
- - `models.py` data models and types
50
- - `word_loader.py` – load/validate/cached word lists (uses `battlewords/words/wordlist.txt` with fallback)
51
- - `generator.py` – word placement; imports from `word_loader`; avoids duplicate words
52
- - `logic.py` game mechanics (reveal, guess, scoring, tiers)
53
- - `ui.py` Streamlit UI composition; animated radar; immediate rerender on reveal/guess via `st.rerun()`; inverted radar Y
54
- - `settings_page.py` Settings page renderer (used by `ui.py` via `?page=settings`)
55
- - `ui_helpers.py` – Shared UI helpers (styles, footer navigation, PWA helpers)
56
- - `words/wordlist.txt` – candidate words
57
- - `specs/` – documentation (this file and `specs.md`)
58
- - `tests/` – unit tests
59
 
60
- Phase 1: Proof of Concept (0.1.0)
61
- Goal: A playable, single-session game demonstrating core rules, scoring, and radar without persistence or advanced UX.
 
62
 
63
- 1) Data Models
64
- - Define `Coord(x:int, y:int)`.
65
- - Define `Word(text:str, start:Coord, direction:str{"H","V"}, cells:list[Coord])`.
66
- - Define `Puzzle(words:list[Word], radar:list[Coord])` – radar holds last-letter coordinates.
67
- - Define `GameState(grid_size:int=12, puzzle:Puzzle, revealed:set[Coord], guessed:set[str], score:int, last_action:str, can_guess:bool)`.
68
 
69
- Acceptance: Types exist and are consumed by generator/logic; simple constructors and validators.
70
 
71
- 2) Word List
72
- - Add an English word list filtered to alphabetic uppercase, lengths in {4,5,6}.
73
- - Ensure words contain no special characters; maintain reasonable difficulty.
74
- - Streamlit: `st.cache_data` to memoize loading/filtering.
75
- - Loader is centralized in `word_loader.py` and used by generator and UI.
76
 
77
- Acceptance: Loading function returns lists by length with >= 25 words per length or fallback minimal lists.
 
 
78
 
79
- 3) Puzzle Generation (Placement)
80
  - Randomly place 2×4, 2×5, 2×6 letter words on a 12×12 grid.
81
- - Constraints (POC):
82
- - Horizontal (left→right) or Vertical (top→down) only.
83
- - No overlapping letters between different words (cells must be unique).
84
  - Compute radar pulses as the last cell of each word.
85
- - Retry strategy with max attempts; raise a controlled error if generation fails.
86
 
87
- Acceptance: Generator returns a valid `Puzzle` passing validation checks (no collisions, in-bounds, correct counts, no duplicates).
88
-
89
- 4) Game Mechanics
90
  - Reveal:
91
- - Click a covered cell to reveal; if the cell is part of a word, show the letter; else mark empty (CSS class `empty`).
92
- - After a reveal action, set `can_guess=True`.
93
  - Guess:
94
- - Accept a guess only if `can_guess` is True and input length ∈ {4,5,6}.
95
- - Match guess case-insensitively against unguessed words in puzzle.
96
- - If correct: add base points = word length; bonus points = count of unrevealed cells in that word at guess time; mark all cells of the word as revealed; add to `guessed`.
97
- - If incorrect: no points awarded.
98
- - After any guess, set `can_guess=False` and require another reveal before next guess.
99
- - Exception: in default mode a correct guess allows chaining (`can_guess=True`); other modes may disable chaining.
100
- - Streamlit: `with st.form("guess"):` + `st.text_input("Your guess")` + `st.form_submit_button("OK", disabled=not can_guess)`; after guess, call `st.rerun()`.
101
- - End of game when all 6 words are guessed or all word letters are revealed; display summary and tier, then `st.stop()`.
102
-
103
- Acceptance: Unit tests cover scoring, guess gating, and reveal behavior.
104
-
105
- 5) UI (Streamlit)
106
- - Layout:
107
- - Title and brief instructions via `st.title`, `st.subheader`, `st.markdown`.
108
- - Left: 12×12 grid using `st.columns(12)`.
109
- - Right: Animated radar, Correct/Try Again indicator, guess form, and score panel.
110
- - Navigation: Footer links and query-param routing (`?page=...`).
111
- - Settings: Dedicated settings page (`?page=settings`) rendered by `render_settings_page()`.
112
- - Visuals:
113
- - Covered vs revealed styles; revealed empty cells use CSS class `empty`.
114
- - Completed word cells styled with `bw-cell-complete`; cell tooltips show coordinates.
115
- - Score panel shows a compact total; timer display is removed.
116
-
117
- Acceptance: Users can play end-to-end; radar shows exactly 6 pulses; reveal and guess update via rerun; completed words are visually distinct.
118
-
119
- 6) Scoring Tiers
120
- - After game ends, compute tier:
121
- - Good: 3437
122
- - Great: 3841
123
- - Fantastic: 42+
124
-
125
- 7) Basic Tests
126
- - Placement validity (bounds, overlap, counts, no duplicate words).
127
- - Scoring logic and bonus calculation.
128
- - Guess gating (reveal required except chaining after correct guess when enabled).
129
-
130
- Current Deltas (0.1.3 → 0.1.10)
131
- - 0.1.3
132
- - Sidebar wordlist select; sorting persists length-then-alpha ordering; auto new-game after 5s notice.
133
- - Score panel improvements; per-word points; final score styling.
134
- - 0.1.4
135
- - Animated radar GIF with metallic gradient and scope overlay; session reuse via `radar_gif_path`.
136
- - Mobile layout improvements; tighter grid spacing and horizontal scroll per row.
137
- - 0.1.5
138
- - Hit/Miss indicator derived from `last_action`.
139
- - Completed word cells render as non-buttons with tooltips.
140
- - Helper functions for scope image and stable letter map rebuild.
141
- - 0.1.10
142
- - Game Mode selector (`standard`, `too easy`); chaining allowed only in `standard`.
143
- - Guess feedback indicator switched to Correct/Try Again.
144
- - Version footer shows commit/Python/Streamlit; ocean background effect.
145
- - Word list default/persistence fixes and sort action persists after delay.
146
- - 0.2.24
147
- - compress height
148
- - change incorrect guess tooltip location
149
- - update final screen layout
150
- - add word difficulty formula
151
- - update documentation
152
- - 0.2.28
153
- - Add Progressive Web App (PWA) support with `service worker` and `manifest.json`
154
- - Add INSTALL_GUIDE.md for PWA install instructions
155
- - No gameplay logic changes
156
-
157
- Known Issues / TODO
158
- - Word list selection bug: improper list fetched/propagated in some runs.
159
- - Verify `get_wordlist_files()` returns correct filenames and `selected_wordlist` persists across `_new_game()`.
160
- - Ensure `load_word_list(selected_wordlist)` loads the chosen file and matches `generate_puzzle(words_by_len=...)` expected shape.
161
- - Add tests for selection, sorting, and fallback behavior.
162
-
163
- Phase 1.5: Storage and Sharing (0.3.0) - NEW
164
- Goal: Add persistent storage, high scores, and game sharing capabilities.
165
-
166
- A) Storage Module
167
- - Create `battlewords/storage.py` with:
168
- - `GameStorage` class for saving/loading game results and high scores
169
- - `GameResult` and `HighScoreEntry` dataclasses
170
- - JSON-based local storage in `~/.battlewords/data/`
171
- - Update `models.py` to include `game_id` in Puzzle (based on word list)
172
- - Update `generator.py` to:
173
- - Generate game_id from sorted word list
174
- - Accept optional `target_words` parameter for replay
175
- - Integrate storage into game flow:
176
- - Save result on game completion
177
- - Display high scores in sidebar
178
- Acceptance:
179
- - Game results saved to local JSON files
180
- - High scores correctly filtered and sorted
181
- - Game IDs generated deterministically
182
-
183
- B) Game Sharing
184
- - Parse `game_id` from query params (`?game_id=ABC123`)
185
- - Generate puzzle from game_id (same words, different positions)
186
- - "Share Challenge" button creates shareable URL
187
- - Display game_id in UI to show shared challenges
188
- Acceptance:
189
- - URL with game_id loads same word set
190
- - Share button generates correct URL
191
- - Game ID visible to players
192
-
193
- C) High Score Display
194
- - Sidebar expander for high scores
195
- - Filter by: All-time, Current Wordlist, Current Mode
196
- - Top 10 entries with: Rank, Player, Score, Tier, Time
197
- - Player name input (optional, defaults to "Anonymous")
198
- Acceptance:
199
- - High scores display correctly
200
- - Filters work as expected
201
- - Player names saved with results
202
-
203
- D) Tests
204
- - Unit tests for storage operations
205
- - Test game ID generation consistency
206
- - Test save/load result flow
207
- - Test high score filtering and ranking
208
-
209
- Milestones and Estimates (High-level)
210
- - Phase 1 (POC): 2–4 days ✅ COMPLETE
211
- - Phase 1.5 (Local Storage & Sharing - planned): 2–3 days ⏳ PENDING (deferred to v0.3.0)
212
- - Phase 1.6 (Remote Storage & Challenge Mode): 3–4 days ✅ COMPLETE (v0.2.20-0.2.27)
213
- - Phase 1.7 (Local Player History): 2–3 days ⏳ IN PROGRESS (v0.3.0)
214
- - Beta (0.5.0): 3–5 days (overlaps, responsive UI, keyboard, deterministic seed)
215
- - Phase 2 (Full): 1–2 weeks depending on features selected
216
-
217
- Definitions of Done (per task)
218
- - Code merged with tests and docs updated.
219
- - No regressions in existing tests; coverage maintained or improved for core logic.
220
- - Manual playthrough validates rules: reveal/guess gating, scoring, radar pulses, end state and tiers.
221
-
222
- ## v0.2.20 Update: Game Sharing with Shortened game_id URL
223
-
224
- ### Game Sharing Feature
225
- - On game completion, save a JSON file to the storage server named by a unique `uid`.
226
- - The JSON file contains: word_list, score, time, game_mode, grid_size, and puzzle options.
227
- - Generate a shortened URL for this file; use it as the `game_id` in the shareable link.
228
- - When a user loads a game with a `game_id` query string, fetch the JSON file and apply all settings for the session.
229
-
230
- ### Implementation Notes
231
- - The game_id is a shortened URL referencing the JSON file.
232
- - The app applies all settings from the file for a true replay.
233
- - No direct encoding of game data in the query string; only the reference is shared.
234
-
235
- ## Phase 1.6: Remote Storage & Challenge Mode (v0.2.20-0.2.27) ✅ COMPLETE
236
-
237
- ### Goal
238
- Persist per-game settings and leaderboards on a storage server (Hugging Face Hub repo) with shortened URLs for challenge sharing.
239
-
240
- ### A) Storage Server Integration ✅
241
- - Imported modules from OpenBadge `modules/storage.py`:
242
- - `upload_files_to_repo(...)` to write JSON to `HF_REPO_ID`
243
- - `gen_full_url(...)` for shortener lookups/creation backed by `shortener.json`
244
- - Created `battlewords/game_storage.py` wrapper with functions:
245
- - `save_game_to_hf()` - Save challenge and get short URL
246
- - `load_game_from_sid()` - Load challenge from short ID
247
- - `add_user_result_to_game()` - Append user result to existing challenge
248
- - `get_shareable_url()` - Generate shareable URLs
249
- - Repository structure in HF dataset:
250
- - `shortener.json` - Short URL mappings
251
- - `games/{uid}/settings.json` - Per-game challenge data with users array
252
- - Required env vars (.env): `HF_API_TOKEN` (or `HF_TOKEN`), `HF_REPO_ID`, `SPACE_NAME`
253
-
254
- ### B) Sharing Link (game_id) ✅
255
- - Shortened URL flow: `gen_full_url(full_url=...)` returns short id (sid)
256
- - Shareable link format: `https://<SPACE_NAME>/?game_id=<sid>`
257
- - On app load with `game_id`: fetch JSON, apply settings, show Challenge Mode banner
258
-
259
- ### C) Challenge Mode Features ✅
260
- - Multi-user leaderboards with score, time, and difficulty tracking
261
- - Results sorted by: highest score → fastest time → highest difficulty
262
- - Challenge Mode UI banner showing top 5 players
263
- - Submit result to existing challenge or create new challenge
264
- - Word list difficulty calculation and display
265
- - "Show Challenge Share Links" toggle (default OFF) for URL visibility control
266
- - Share links are only rendered in the Challenge Mode banner and Game Over flow when the toggle is enabled.
267
- - Each player gets different random words from the same wordlist source
268
-
269
- ### D) Dependencies ✅
270
- - Added `huggingface_hub` and `python-dotenv` to requirements
271
- - Module imports in `ui.py:30`
272
-
273
- ### E) Acceptance Criteria ✅
274
- - ✅ Completed game produces working share link with `game_id` sid
275
- - ✅ Visiting link reconstructs challenge with leaderboard
276
- - ✅ Multiple users can submit results to same challenge
277
- - ✅ Leaderboard displays and sorts correctly
278
- - ✅ Documentation updated with env vars and flows
279
- - ✅ App works without HF credentials (Challenge Mode features disabled gracefully)
280
-
281
- ### F) Implementation Files
282
- - `battlewords/game_storage.py` - HF storage wrapper (v0.1.0)
283
- - `battlewords/modules/storage.py` - Generic HF storage (v0.1.5)
284
- - `battlewords/ui.py` - Challenge Mode UI integration (lines 508-601, 1588-1701)
285
- - `battlewords/generator.py` - Support for target_words parameter
286
-
287
- ## Phase 1.7: Local Player History (v0.3.0) ⏳ IN PROGRESS
288
-
289
- ### Goal
290
- Add local persistent storage for individual player game results and personal high scores (offline-capable, privacy-first).
291
-
292
- ### A) Local Storage Module
293
- - Create `battlewords/local_storage.py` with:
294
- - `GameResult` and `HighScoreEntry` dataclasses
295
- - JSON-based storage in `~/.battlewords/data/`
296
- - Functions: `save_game_result()`, `load_high_scores()`, `get_player_stats()`
297
- - Storage location: `~/.battlewords/data/` (game_results.json, highscores.json)
298
-
299
- ### B) High Score Display
300
- - Sidebar expander for personal high scores
301
- - Filter by: All-time, Current Wordlist, Current Mode
302
- - Top 10 entries with: Rank, Player, Score, Tier, Time
303
- - Player name input (optional, defaults to "Anonymous")
304
-
305
- ### C) Player Statistics
306
- - Games played, average score, best score
307
- - Fastest completion time
308
- - Per-player history tracking
309
-
310
- ### D) Acceptance Criteria
311
- - Local JSON files created and updated on game completion
312
- - High scores display correctly in sidebar
313
- - Filters work as expected
314
- - Player names saved with results
315
- - No cloud dependency required
316
- - Easy data deletion (remove ~/.battlewords/data/)
 
1
+ # Battlewords: Implementation Requirements
2
 
3
+ **Current Version:** 0.2.37 BASIC
4
+ **Branch:** `basic`
5
+ **Last Updated:** 2026-02-11
6
 
7
+ This document captures the implementation requirements for the **basic** branch only (single-player, default settings, one play experience).
 
8
 
9
+ ---
10
 
11
+ ## Assumptions
12
+ - Tech stack: Python 3.10+, Streamlit (UI), matplotlib (radar), numpy (tick helpers), Pillow (animated GIFs).
13
+ - Single-player only.
14
+ - State is local and stored in Streamlit session state.
15
+ - Grid is always 12×12 with exactly six words: two 4-letter, two 5-letter, two 6-letter words.
16
+ - Words placed horizontal/vertical only.
17
  - Entry point is `app.py`.
18
 
19
+ ## Streamlit Key Components
 
 
 
 
 
 
 
 
20
 
21
+ ### State & caching
22
+ - `st.session_state` holds: `puzzle`, `grid_size`, `revealed`, `guessed`, `score`, `last_action`, `can_guess`, `points_by_word`, `letter_map`.
23
+ - Word list loading uses `st.cache_data` where appropriate.
24
+ - Radar rendering caches per-session artifacts (e.g., `radar_gif_path`).
 
 
 
 
 
 
 
 
 
 
 
 
 
25
 
26
+ ### Layout & structure
27
+ - The app renders a single play experience.
28
+ - No sidebar.
29
+ - No footer navigation.
30
+ - No query-param routing to non-game pages.
31
+
32
+ ### Widgets
33
+ - Grid cell actions use `st.button` with unique keys.
34
+ - Guessing uses `st.form` with `st.text_input` and `st.form_submit_button`.
35
+ - A New Game action restarts gameplay.
 
 
 
 
36
 
37
+ ### Visualization
38
+ - Radar visualization uses matplotlib and renders as an animated GIF.
39
+ - Radar plot uses inverted Y so (0,0) is top-left.
40
 
41
+ ### UI transitions
42
+ - `CustomSpinner` is used for initial load, New Game transition, and Game Over/overlay transitions.
43
+ - `components.html` usage is allowed for UI transitions/effects.
 
 
44
 
45
+ ---
46
 
47
+ ## Core Requirements
 
 
 
 
48
 
49
+ ### Word list
50
+ - Word lists must be loaded from local files in `battlewords/words/`.
51
+ - No AI generation and no model-backed wordlist fetching.
52
 
53
+ ### Puzzle generation
54
  - Randomly place 2×4, 2×5, 2×6 letter words on a 12×12 grid.
55
+ - No overlapping letters between different words.
 
 
56
  - Compute radar pulses as the last cell of each word.
 
57
 
58
+ ### Game mechanics
 
 
59
  - Reveal:
60
+ - Clicking a covered cell reveals a letter (if in a word) or empty.
61
+ - After a reveal, set `can_guess=True`.
62
  - Guess:
63
+ - Only allowed if `can_guess=True`.
64
+ - Correct guess awards base points (= word length) plus bonus points (= unrevealed letters at guess time).
65
+ - On correct guess: mark all word cells as revealed and add word to `guessed`.
66
+ - On incorrect guess: no points.
67
+ - After any guess: set `can_guess=False`.
68
+ - Default behavior: chaining after a correct guess is allowed (no settings UI required).
69
+ - Game over:
70
+ - Triggered when all six words are guessed or when all word letters are revealed.
71
+ - Shows a summary with score and tier.
72
+
73
+ ### Scoring tiers
74
+ - Good: 34–37
75
+ - Great: 38–41
76
+ - Fantastic: 42–45
77
+ - Legendary: 46+
78
+
79
+ ---
80
+
81
+ ## Folder Structure (Basic)
82
+ - `app.py` Streamlit entry point
83
+ - `battlewords/`
84
+ - `models.py` data models and types
85
+ - `word_loader.py` – load/validate/cached word lists
86
+ - `generator.py` word placement
87
+ - `logic.py` – reveal/guess/scoring/tier rules
88
+ - `ui.py` – Streamlit UI composition
89
+ - `ui_helpers.py` shared UI helpers (styles/transitions)
90
+ - `words/` local word list files
91
+ - `specs/` documentation
92
+
93
+ ## Out of Scope (Basic)
94
+ - Settings page
95
+ - Leaderboards
96
+ - Challenge mode / remote storage (removed)
97
+ - PWA support (removed)
98
+ - Audio
99
+ - Tests
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
specs/specs.mdx CHANGED
@@ -1,209 +1,75 @@
1
- # Battlewords Game Requirements (specs.md)
2
 
3
- **Current Version:** 0.2.37
4
- **Last Updated:** 2026-02-10
 
5
 
6
  ## Overview
7
  Battlewords is inspired by the classic Battleship game, but uses words instead of ships. The objective is to discover hidden words on a grid, earning points for strategic guessing before all letters are revealed.
8
 
9
- ## Upgrade Checklist
10
- The detailed Wrdler  BattleWords porting checklist is tracked in `specs/upgrade.mdx`.
11
-
12
- ## Recent Changes (v0.2.37)
13
- See `README.md` for the canonical release notes: [Recent Changes](README.md#recent-changes)
14
-
15
  ---
16
 
17
  ## Game Board
18
- - 12 x 12 grid.
19
  - Six hidden words:
20
  - Two four-letter words
21
  - Two five-letter words
22
  - Two six-letter words
23
  - Words are placed horizontally (left-right) or vertically (top-down), not diagonally.
24
- - Words may touch edges or corners but do not overlap unless a future mode allows shared letters.
25
  - Entry point is `app.py`.
26
- - **Supports Dockerfile-based deployment for Hugging Face Spaces and other container platforms.**
27
 
28
- ## Gameplay (Common)
29
  - Players click grid squares to reveal letters or empty spaces.
30
  - Empty revealed squares are styled with CSS class `empty`.
31
- - After any reveal, the app immediately reruns (`st.rerun`) to show the change.
32
- - Use radar pulses to locate word boundaries (first and last letters).
33
- - After revealing a letter, players may guess a word by entering it in a text box.
34
- - Guess submission triggers an immediate rerun to reflect results.
35
  - Only one guess per letter reveal; must uncover another letter before guessing again.
36
- - In the default mode, a correct guess allows chaining an additional guess without another reveal.
37
- - **The game ends when all six words are guessed or all word letters are revealed.**
38
 
39
  ## Scoring
40
  - Each correct word guess awards points:
41
  - 1 point per letter in the word
42
- - Bonus points for each hidden letter at the time of guessing
43
  - Score tiers:
44
- - Good: 34-37
45
- - Great: 38-41
46
- - Fantastic: 42-45
47
- - Legendary: 46+ (perfect game)
48
- - **Game over is triggered by either all words being guessed or all word letters being revealed.**
49
-
50
- ## POC (0.1.0) Rules
51
- - No overlaps: words do not overlap or share letters.
52
- - UI: basic grid, radar, and guess form.
53
- - No keyboard interaction requirement.
54
- - Seed is optional and not standardized.
55
-
56
- ## Beta (0.5.0) Additions
57
- - Optional validation pass to avoid unintended adjacent partial words (content curation rule).
58
- - Cell rendering with consistent sizing and responsive layout (desktop/mobile).
59
- - Keyboard support for navigation and guessing (custom JS via `st.html` or a component).
60
- - Deterministic seed support to reproduce puzzles (e.g., daily seed derived from date).
61
-
62
- ## Full (1.0.0) Rules
63
- - No overlaps: words do not overlap or share letters.
64
- - Enhanced UX polish (animations, accessibility, themes).
65
- - Persistence, leaderboards, and additional modes as specified in requirements.
66
- - Deterministic daily mode and practice mode supported.
67
-
68
- ## New Features (v0.3.0)
69
- - **Game ID Sharing:** Each puzzle generates a deterministic game ID based on the word list. Players can share URLs with `?game_id=ABC123` to challenge others with the same words.
70
- - **Persistent Storage:** Game results and high scores are saved locally in `~/.battlewords/data/`.
71
- - **High Scores:** Top scores are tracked and displayed in the sidebar, filterable by wordlist and game mode.
72
- - **Player Name:** Optional player name is saved with results.
73
-
74
- ## New Features (v0.2.28)
75
- - **PWA Support:** App is installable as a Progressive Web App on desktop and mobile.
76
- - Added `service worker` and `manifest.json`.
77
- - Basic offline caching of static assets.
78
- - INSTALL_GUIDE.md added with platform-specific install steps.
79
- - No gameplay logic changes.
80
-
81
- ## New Features (v0.2.24)
82
- - **UI Improvements:** More compact layout, improved tooltip for incorrect guesses, and updated final score screen.
83
- - **Word Difficulty:** Added a word difficulty formula and display for each game/challenge, visible in the final score and leaderboard.
84
- - **Challenge Mode:** Enhanced leaderboard with difficulty display, improved result submission, and clearer challenge sharing.
85
- - **Documentation:** Updated to reflect new features and UI changes.
86
-
87
- ## Storage
88
- - Game results and high scores are stored in JSON files for privacy and offline access.
89
- - Game ID is generated from the sorted word list for replay/sharing.
90
 
91
  ## UI Elements
92
- - 12x12 grid
93
- - Radar screen (shows last letter locations); y-axis inverted so (0,0) is top-left
94
- - Text box for word guesses
95
- - Score display (shows word, base points, bonus points, total score)
96
- - Guess status indicator (Correct/Try Again)
97
- - Game ID display and share button in game over dialog.
98
- - High score expander in sidebar.
99
- - Player name input in sidebar.
100
- - Checkbox: "Show Challenge Share Links" (v0.2.27, default OFF)
101
- - When OFF:
102
- - Challenge Mode header hides the Share Challenge link
103
- - Game Over dialog still supports submitting/creating challenges, but does not display the generated share URL
104
- - Persisted in session state and preserved across "New Game"
105
-
106
- ## New Features (v0.2.27)
107
- - Added "Show Challenge Share Links" visibility toggle for Challenge Mode sharing UI
108
- - Purely a UI change; gameplay logic and storage behavior unchanged
109
-
110
- ## Word List
111
- - External list at `battlewords/words/wordlist.txt`.
112
- - Loaded by `battlewords.word_loader.load_word_list()` with caching.
113
- - Filtered to uppercase A�Z, lengths in {4,5,6}; falls back if < 25 per length.
114
 
115
  ## Generator
116
  - Centralized word loader.
117
  - No duplicate word texts are selected.
118
 
119
- ## Entry Point
120
- - The Streamlit entry point is `app.py`.
121
- - **A `Dockerfile` can be used for containerized deployment (recommended for Hugging Face Spaces).**
122
-
123
- ## Deployment Requirements
124
-
125
- ### Basic Deployment (Offline Mode)
126
- No special configuration needed. The app will run with all core gameplay features.
127
- Optional: Install as PWA from the browser menu (Add to Home Screen/Install app).
128
 
129
- ### Challenge Mode Deployment (Remote Storage)
130
- Requires HuggingFace Hub integration for challenge sharing and leaderboards.
131
-
132
- **Required Environment Variables:**
133
- ```bash
134
- HF_API_TOKEN=hf_xxxxxxxxxxxxxxxxxxxxx # or HF_TOKEN (write access required)
135
- HF_REPO_ID=YourUsername/YourRepo # Target HF dataset repository
136
- SPACE_NAME=YourUsername/BattleWords # Your HF Space name for URL generation
137
- ```
138
-
139
- **Optional Environment Variables:**
140
- ```bash
141
- CRYPTO_PK= # Reserved for future challenge signing
142
- ```
143
-
144
- **Setup Steps:**
145
- 1. Create a HuggingFace account at https://huggingface.co
146
- 2. Create a dataset repository (e.g., `YourUsername/BattleWordsStorage`)
147
- 3. Generate an access token with `write` permissions:
148
- - Go to https://huggingface.co/settings/tokens
149
- - Click "New token"
150
- - Select "Write" access
151
- - Copy the token (starts with `hf_`)
152
- 4. Create a `.env` file in project root with the variables above
153
- 5. For Hugging Face Spaces deployment, add these as Space secrets
154
-
155
- **Repository Structure (automatically created):**
156
- ```
157
- HF_REPO_ID/
158
- ├── shortener.json # Short URL mappings (sid -> full URL)
159
- └── games/
160
- └── {uid}/
161
- └── settings.json # Challenge data with users array
162
- ```
163
-
164
- **Data Privacy:**
165
- - Challenge Mode stores: word lists, scores, times, game modes, player names
166
- - No PII beyond optional player name (defaults to "Anonymous")
167
- - Players control URL visibility via "Show Challenge Share Links" setting
168
- - App functions fully offline when HF credentials not configured
169
-
170
- **Deployment Platforms:**
171
- - Local development: Run with `streamlit run app.py`
172
- - Docker: Use provided `Dockerfile`
173
- - Hugging Face Spaces: Dockerfile deployment (recommended)
174
- - Any Python 3.10+ hosting with Streamlit support
175
 
176
  ## Copyright
177
- BattlewordsTM. All Rights Reserved. All content, trademarks and logos are copyrighted by the owner.
178
-
179
- ## v0.2.20: Remote Storage and Shortened game_id URL
180
-
181
- Game Sharing
182
- - Each puzzle can be shared via a link containing a `game_id` querystring (short id / sid)
183
- - `game_id` resolves to a settings JSON on the storage server (HF repo)
184
- - JSON fields:
185
- - word_list (list of 6 uppercase words)
186
- - score (int), time (int seconds) [metadata only]
187
- - game_mode (e.g., classic, too easy)
188
- - grid_size (e.g., 12)
189
- - puzzle_options (e.g., { spacer, may_overlap })
190
- - On load with `game_id`, fetch and apply: word_list, game_mode, grid_size, puzzle_options
191
-
192
- High Scores
193
- - Repository maintains `highscores/highscores.json` for top scores
194
- - Local highscores remain supported for offline use
195
-
196
- UI/UX
197
- - Show the current `game_id` (sid) and a �Share Challenge� link
198
- - When loading with a `game_id`, indicate the puzzle is a shared challenge
199
-
200
- Security/Privacy
201
- - Only game configuration and scores are stored; no personal data is required
202
- - `game_id` is a short reference; full URL is stored in a repo JSON shortener index
203
-
204
- ## Challenge Mode & Leaderboard
205
-
206
- - When loading a shared challenge via `game_id`, the leaderboard displays all user results for that challenge.
207
- - **Sorting:** The leaderboard is sorted by highest score (descending), then by fastest time (ascending).
208
- - **Difficulty:** Each result now displays a computed word list difficulty value.
209
- - Results are stored remotely in a Hugging Face dataset repo and updated via the app.
 
1
+ # Battlewords Game Requirements
2
 
3
+ **Current Version:** 0.2.37 BASIC
4
+ **Branch:** `basic`
5
+ **Last Updated:** 2026-02-11
6
 
7
  ## Overview
8
  Battlewords is inspired by the classic Battleship game, but uses words instead of ships. The objective is to discover hidden words on a grid, earning points for strategic guessing before all letters are revealed.
9
 
 
 
 
 
 
 
10
  ---
11
 
12
  ## Game Board
13
+ - 12×12 grid.
14
  - Six hidden words:
15
  - Two four-letter words
16
  - Two five-letter words
17
  - Two six-letter words
18
  - Words are placed horizontally (left-right) or vertically (top-down), not diagonally.
19
+ - Words may touch edges or corners but do not overlap.
20
  - Entry point is `app.py`.
21
+ - Supports Dockerfile-based deployment for container platforms.
22
 
23
+ ## Gameplay
24
  - Players click grid squares to reveal letters or empty spaces.
25
  - Empty revealed squares are styled with CSS class `empty`.
26
+ - After any reveal, the app reruns (`st.rerun`) to show the change.
27
+ - Radar pulses indicate the last letter of each hidden word.
28
+ - After revealing a letter, players may guess a word via a text input.
29
+ - Guess submission reruns the app to reflect results.
30
  - Only one guess per letter reveal; must uncover another letter before guessing again.
31
+ - Correct guesses allow chaining an additional guess without another reveal.
32
+ - The game ends when all six words are guessed or all word letters are revealed.
33
 
34
  ## Scoring
35
  - Each correct word guess awards points:
36
  - 1 point per letter in the word
37
+ - Bonus points for each hidden (unrevealed) letter at the time of guessing
38
  - Score tiers:
39
+ - Good: 3437
40
+ - Great: 3841
41
+ - Fantastic: 4245
42
+ - Legendary: 46+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
 
44
  ## UI Elements
45
+ - 12×12 clickable grid
46
+ - Radar screen (GIF) showing last-letter pulse locations
47
+ - Guess input form
48
+ - Score panel (base + bonus breakdown)
49
+ - Game over summary dialog (final score + tier)
50
+ - New Game button
51
+ - Custom loading spinner/overlay for transitions
52
+ - No sidebar, no settings page, no leaderboards
53
+
54
+ ## Word Lists
55
+ - Local word list files under `battlewords/words/`.
56
+ - Loaded via `battlewords.word_loader.load_word_list()`.
57
+ - Filtered to uppercase A–Z and lengths {4, 5, 6}.
 
 
 
 
 
 
 
 
 
58
 
59
  ## Generator
60
  - Centralized word loader.
61
  - No duplicate word texts are selected.
62
 
63
+ ## Deployment
64
+ - No special configuration needed.
65
+ - Local: `streamlit run app.py`
66
+ - Docker: use the provided `Dockerfile`
 
 
 
 
 
67
 
68
+ ## Out of Scope (Basic)
69
+ - Challenge mode / share links / remote storage (removed)
70
+ - PWA support (removed)
71
+ - Audio
72
+ - Query-param routing to non-game pages
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
 
74
  ## Copyright
75
+ Battlewords. All Rights Reserved.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
specs/upgrade.mdx DELETED
@@ -1,136 +0,0 @@
1
- # Upgrade checklist: porting useful `wrdler` improvements into `battlewords`
2
-
3
- **Current Version:** 0.2.34
4
- **Last Updated:** 2025-01-20
5
-
6
- ## Recent Changes (v0.2.34)
7
- See `README.md` for the canonical release notes: [Recent Changes](../README.md#recent-changes)
8
-
9
- ## Context summary (for a fresh chat)
10
-
11
- - **Goal:** Bring over non-gameplay improvements from `wrdler` into `battlewords`—primarily **leaderboards**, **settings management**, **UI/navigation**, and **remote storage helpers**.
12
- - **Out of scope:** Anything that changes BattleWords core gameplay (grid rules, word placement logic, scoring mechanics, etc.).
13
- - **Key BattleWords file already in scope:** `battlewords/game_storage.py` (HF-backed challenge saving/loading, multi-user challenge results, and robust share URL generation).
14
- - **Important existing BattleWords capability:** `battlewords/game_storage.py:get_shareable_url()` supports embedded hosting via `iframe_host` query param, local dev URLs, and Space URLs.
15
- - **Wrdler sources used for comparison:** ( use MCP tool fss. relative file path is ../projects/wrdler/ )
16
- - `d:/projects/wrdler/pyproject.toml` (project metadata)
17
- - `d:/projects/wrdler/readme.md` (feature list; describes leaderboards/settings/navigation)
18
- - `d:/projects/wrdler/specs/settings.md` (settings page + local settings persistence design)
19
- - `d:/projects/wrdler/wrdler/ui.py` (query-param routing, footer nav, challenge UX, game-over submission flow)
20
- - `d:/projects/wrdler/wrdler/version_info.py` (runtime diagnostics HTML snippet)
21
- - `d:/projects/wrdler/wrdler/modules/*` (constants/settings loader, HF storage helpers, repo folder listing)
22
- - **BattleWords repo remotes (for reference):**
23
- - Hugging Face Space: `https://huggingface.co/spaces/Surn/BattleWords`
24
- - GitHub: `https://github.com/Oncorporation/BattleWords`
25
- - **Notable Wrdler improvements to port:**
26
- - Dedicated Settings page using `?page=settings` + footer navigation.
27
- - Local settings persistence (load latest settings on startup; save settings to local JSON files).
28
- - Daily/weekly leaderboards stored in HF with folder-based discovery (no global `index.json`) + settings-based separation.
29
- - Challenge mode UX polish (top-N preview, exit challenge mode, optional share-link display).
30
- - Game-over dialog integrates submission + share link generation + qualification feedback.
31
- - Runtime diagnostics footer: app version, git commit, python and streamlit versions.
32
- - **Implementation notes / constraints:**
33
- - Prefer minimal changes; preserve existing behavior.
34
- - Track work by checking items below when implemented.
35
- - **Porting reminder:** `wrdler/version_info.py` is now `battlewords/modules/version_info.py` (BattleWords expects this kind of shared utility under `modules`).
36
-
37
- Scope: only non-gameplay changes (leaderboards, settings, UI/UX, storage/utilities). Ignore gameplay differences.
38
-
39
- ---
40
-
41
- ## UI/UX Improvements (from Wrdler)
42
-
43
- - [x] Combine "Your Guess" and guess result into a single UI element, as in Wrdler.
44
- - [x] Shrink space in scoreboard for a more compact screen layout.
45
- - [x] Radar graphic size reduced to 240px to save processing and load time.
46
- - [x] Remove waves from background for cleaner look and better performance.
47
-
48
- ---
49
-
50
- ## Settings system improvements
51
-
52
- - [x] Create a dedicated Settings page (route like `?page=settings`) instead of relying on a sidebar.
53
- - [x] Implement local settings persistence (load latest settings on startup, save current settings).
54
- - [ ] Add multi-profile settings management (create/update/rename/delete settings configurations) (optional).
55
- - [x] Centralize defaults/overrides via a single settings loader (defaults + `settings.json` + env overrides).
56
-
57
- ---
58
-
59
- ## Navigation + routing
60
-
61
- - [x] Add footer navigation as primary nav (Play / Leaderboard / Settings) rather than sidebar links.
62
- - [x] Implement query-param routing (e.g., `?page=play|settings|today|daily|weekly|history`) for multi-page UI.
63
- - [ ] Ensure routing avoids unnecessary page reload loops and preserves UI state where possible.
64
-
65
- ---
66
-
67
- ## Challenge sharing UX
68
-
69
- - [x] Standardize share URL generation using `get_shareable_url()` (avoid hard-coded Space URLs).
70
- - [x] Ensure embedded deployments work via `iframe_host` override (BattleWords already supports this).
71
- - [x] Add a “Show share links” toggle so share URLs aren’t always displayed.
72
- - [ ] Add an “Exit challenge mode” action that clears `game_id` and resets challenge session flags.
73
-
74
- ---
75
-
76
- ## Challenge mode banner / presentation
77
-
78
- - [x] Add a Challenge Mode banner/expander that summarizes the challenge.
79
- - [x] Show “top N” (e.g., 5) results preview in the banner (sorted by score desc, time asc, difficulty desc).
80
- - [x] Display optional `word_list_difficulty` per user in the preview when present.
81
-
82
- ---
83
-
84
- ## Game Changes
85
-
86
- - [x] faster loading
87
- - [x] remove timer
88
- - [x] remove settings panel entirely
89
- - [x] plain blue background (no waves)
90
- - [x] no empty guesses
91
- - [x] guess box rearranged to be more compact, circle around guess and result, no OK button
92
- - [x] New Game button moved to be stable (not flash)
93
- - [x] faster time for growing glow when word is complete
94
- - [x] smaller radar
95
- - [x] No share challenge on results page
96
- - [x] No leaderboard on results page
97
- - [x] No settings
98
- - [x] Change subheader text
99
-
100
- ## Game-over flow improvements
101
-
102
- - [x] Integrate “submit score” + “generate share link” into the game-over dialog/popup.
103
- - [x] Display qualification/rank results after submission.
104
- - [ ] Provide links into leaderboard views using query params.
105
-
106
- ---
107
-
108
- ## Daily/weekly leaderboards (port selectively)
109
-
110
- - [ ] Decide minimal leaderboard scope for BattleWords (start with Daily only, add Weekly later).
111
- - [ ] Use settings-based leaderboard separation (players compete only with identical settings).
112
- - [ ] Implement top-N display (e.g., 25) with deterministic sorting: score desc → time asc → difficulty desc.
113
- - [ ] Store leaderboards in HF using folder-based discovery (avoid a global `index.json`).
114
- - [ ] Track challenge submissions into daily/weekly leaderboards via `source_challenge_id`.
115
- - [ ] Provide leaderboard browsing UI (tabs like Today / Daily / Weekly / History).
116
-
117
- ---
118
-
119
- ## Storage/utilities to unlock leaderboards
120
-
121
- - [x] Add HF repo folder listing helper (for folder-based discovery leaderboards).
122
- - [x] Add HF repo “list files in folder” helper.
123
-
124
- ---
125
-
126
- ## Version + runtime diagnostics (supportability)
127
-
128
- - [x] Port `wrdler/version_info.py` into `battlewords/modules/version_info.py` (note: BattleWords needs it under `modules`).
129
- - [x] Add a footer snippet that shows app version + git commit + python + streamlit versions.
130
-
131
- ---
132
-
133
- ## Dependency/workflow alignment (optional)
134
-
135
- - [x] Validate Streamlit version supports modern `st.query_params` API.
136
- - [ ] Consider aligning Python/Streamlit pins (as needed) to support routing/dialog APIs used by Wrdler.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
static/icon-192.png DELETED
Binary file (5.33 kB)
 
static/icon-512.png DELETED
Binary file (13.7 kB)
 
static/manifest.json DELETED
@@ -1,27 +0,0 @@
1
- {
2
- "name": "BattleWords",
3
- "short_name": "BattleWords",
4
- "description": "Vocabulary learning game inspired by Battleship mechanics. Discover hidden words on a 12x12 grid and earn points for strategic guessing.",
5
- "start_url": "/",
6
- "scope": "/",
7
- "display": "standalone",
8
- "orientation": "portrait",
9
- "background_color": "#0b2a4a",
10
- "theme_color": "#165ba8",
11
- "icons": [
12
- {
13
- "src": "/app/static/icon-192.png",
14
- "sizes": "192x192",
15
- "type": "image/png",
16
- "purpose": "any maskable"
17
- },
18
- {
19
- "src": "/app/static/icon-512.png",
20
- "sizes": "512x512",
21
- "type": "image/png",
22
- "purpose": "any maskable"
23
- }
24
- ],
25
- "categories": ["games", "education"],
26
- "screenshots": []
27
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
static/service-worker.js DELETED
@@ -1,99 +0,0 @@
1
- /**
2
- * BattleWords Service Worker
3
- * Enables PWA functionality: offline caching, install prompt, etc.
4
- *
5
- * Security Note: This file contains no secrets or sensitive data.
6
- * It only caches public assets for offline access.
7
- */
8
-
9
- const CACHE_NAME = 'battlewords-v0.2.29';
10
- const RUNTIME_CACHE = 'battlewords-runtime';
11
-
12
- // Assets to cache on install (minimal for faster install)
13
- const PRECACHE_URLS = [
14
- '/',
15
- '/app/static/manifest.json',
16
- '/app/static/icon-192.png',
17
- '/app/static/icon-512.png'
18
- ];
19
-
20
- // Install event - cache essential files
21
- self.addEventListener('install', event => {
22
- console.log('[ServiceWorker] Installing...');
23
- event.waitUntil(
24
- caches.open(CACHE_NAME)
25
- .then(cache => {
26
- console.log('[ServiceWorker] Precaching app shell');
27
- return cache.addAll(PRECACHE_URLS);
28
- })
29
- .then(() => self.skipWaiting()) // Activate immediately
30
- );
31
- });
32
-
33
- // Activate event - clean up old caches
34
- self.addEventListener('activate', event => {
35
- console.log('[ServiceWorker] Activating...');
36
- event.waitUntil(
37
- caches.keys().then(cacheNames => {
38
- return Promise.all(
39
- cacheNames.map(cacheName => {
40
- if (cacheName !== CACHE_NAME && cacheName !== RUNTIME_CACHE) {
41
- console.log('[ServiceWorker] Deleting old cache:', cacheName);
42
- return caches.delete(cacheName);
43
- }
44
- })
45
- );
46
- }).then(() => self.clients.claim()) // Take control immediately
47
- );
48
- });
49
-
50
- // Fetch event - network first, fall back to cache
51
- self.addEventListener('fetch', event => {
52
- // Skip non-GET requests
53
- if (event.request.method !== 'GET') {
54
- return;
55
- }
56
-
57
- // Skip chrome-extension and other non-http requests
58
- if (!event.request.url.startsWith('http')) {
59
- return;
60
- }
61
-
62
- event.respondWith(
63
- caches.open(RUNTIME_CACHE).then(cache => {
64
- return fetch(event.request)
65
- .then(response => {
66
- // Cache successful responses for future offline access
67
- if (response.status === 200) {
68
- cache.put(event.request, response.clone());
69
- }
70
- return response;
71
- })
72
- .catch(() => {
73
- // Network failed, try cache
74
- return caches.match(event.request).then(cachedResponse => {
75
- if (cachedResponse) {
76
- console.log('[ServiceWorker] Serving from cache:', event.request.url);
77
- return cachedResponse;
78
- }
79
-
80
- // No cache available, return offline page or error
81
- return new Response('Offline - Please check your connection', {
82
- status: 503,
83
- statusText: 'Service Unavailable',
84
- headers: new Headers({
85
- 'Content-Type': 'text/plain'
86
- })
87
- });
88
- });
89
- });
90
- })
91
- );
92
- });
93
-
94
- // Message event - handle commands from the app
95
- self.addEventListener('message', event => {
96
- if (event.data.action === 'skipWaiting') {
97
- self.skipWaiting();
98
- }
99
- });