Spaces:
Running
Running
Basic Only version step 2
Browse files- LOCALHOST_PWA_README.md +0 -267
- PWA_INSTALL_GUIDE.mdx +0 -208
- README.md +21 -417
- battlewords/__init__.py +2 -2
- battlewords/game_storage.py +0 -581
- battlewords/modules/__init__.py +0 -35
- battlewords/modules/constants.py +6 -18
- battlewords/modules/storage.md +0 -268
- battlewords/modules/storage.py +0 -799
- battlewords/ui.py +17 -381
- battlewords/ui_helpers.py +4 -221
- claude.md +49 -395
- generate_pwa_icons.py +0 -98
- inject-pwa-head.sh +0 -49
- pwa-head-inject.html +0 -8
- pyproject.toml +1 -1
- specs/basic.mdx +2 -9
- specs/history.mdx +0 -25
- specs/requirements.mdx +80 -297
- specs/specs.mdx +42 -176
- specs/upgrade.mdx +0 -136
- static/icon-192.png +0 -0
- static/icon-512.png +0 -0
- static/manifest.json +0 -27
- static/service-worker.js +0 -99
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 |
-
**
|
|
|
|
| 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 |
-
-
|
| 30 |
-
-
|
| 31 |
-
-
|
| 32 |
-
|
| 33 |
-
-
|
| 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 |
-
###
|
| 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 |
-
###
|
| 60 |
- Multiple word lists (classic, fourth_grade, wordlist)
|
| 61 |
-
-
|
| 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 |
-
-
|
| 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
|
| 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.
|
| 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.
|
| 209 |
|
| 210 |
## Changelog
|
| 211 |
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 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", "
|
|
|
|
| 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 |
-
|
| 4 |
-
|
|
|
|
|
|
|
| 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":
|
| 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 .
|
| 22 |
-
from .
|
| 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 |
-
#
|
| 93 |
-
|
| 94 |
-
|
| 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
|
| 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:
|
| 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.
|
| 26 |
-
2.
|
| 27 |
-
3.
|
| 28 |
-
4. Fallback to "Wrdler"
|
| 29 |
Returns:
|
| 30 |
str: The effective game title.
|
| 31 |
"""
|
| 32 |
-
#
|
| 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
|
| 5 |
|
| 6 |
-
**Current Version:** 0.2.37
|
| 7 |
-
**
|
| 8 |
-
**
|
| 9 |
**Repository:** https://github.com/Oncorporation/BattleWords.git
|
| 10 |
-
**Live Demo:** https://huggingface.co/spaces/Surn/BattleWords
|
| 11 |
|
| 12 |
-
|
| 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 |
-
-
|
| 21 |
-
- Words placed horizontally or vertically
|
| 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 |
-
-
|
| 35 |
-
-
|
| 36 |
-
-
|
| 37 |
-
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
-
|
| 44 |
-
-
|
| 45 |
-
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
-
|
| 49 |
-
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
```
|
| 54 |
battlewords/
|
| 55 |
├── app.py # Streamlit entry point
|
| 56 |
├── battlewords/ # Main package
|
| 57 |
-
│ ├── __init__.py
|
| 58 |
-
│ ├── models.py
|
| 59 |
-
│ ├── generator.py
|
| 60 |
-
│ ├── logic.py
|
| 61 |
-
│ ├── ui.py
|
| 62 |
-
│ ├──
|
| 63 |
-
│
|
| 64 |
-
|
| 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: 42–45
|
| 24 |
+
- Great: 38–41
|
| 25 |
+
- Good: 34–37
|
| 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
|
| 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
|
| 107 |
-
- PWA support
|
| 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 |
-
|
| 2 |
|
| 3 |
-
**Current Version:** 0.2.37
|
| 4 |
-
**
|
|
|
|
| 5 |
|
| 6 |
-
|
| 7 |
-
See `README.md` for the canonical release notes: [Recent Changes](README.md#recent-changes)
|
| 8 |
|
| 9 |
-
|
| 10 |
|
| 11 |
-
Assumptions
|
| 12 |
-
- Tech stack: Python 3.10+, Streamlit
|
| 13 |
-
- Single-player
|
| 14 |
-
-
|
|
|
|
|
|
|
| 15 |
- Entry point is `app.py`.
|
| 16 |
|
| 17 |
-
Streamlit
|
| 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 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 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 |
-
|
| 46 |
-
-
|
| 47 |
-
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 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 |
-
|
| 61 |
-
|
|
|
|
| 62 |
|
| 63 |
-
|
| 64 |
-
-
|
| 65 |
-
-
|
| 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 |
-
|
| 70 |
|
| 71 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 78 |
|
| 79 |
-
|
| 80 |
- Randomly place 2×4, 2×5, 2×6 letter words on a 12×12 grid.
|
| 81 |
-
-
|
| 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 |
-
|
| 88 |
-
|
| 89 |
-
4) Game Mechanics
|
| 90 |
- Reveal:
|
| 91 |
-
-
|
| 92 |
-
- After a reveal
|
| 93 |
- Guess:
|
| 94 |
-
-
|
| 95 |
-
-
|
| 96 |
-
-
|
| 97 |
-
-
|
| 98 |
-
- After any guess
|
| 99 |
-
-
|
| 100 |
-
|
| 101 |
-
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
-
|
| 121 |
-
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
-
|
| 127 |
-
-
|
| 128 |
-
-
|
| 129 |
-
|
| 130 |
-
|
| 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
|
| 2 |
|
| 3 |
-
**Current Version:** 0.2.37
|
| 4 |
-
**
|
|
|
|
| 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
|
| 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
|
| 25 |
- Entry point is `app.py`.
|
| 26 |
-
-
|
| 27 |
|
| 28 |
-
## Gameplay
|
| 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
|
| 32 |
-
-
|
| 33 |
-
- After revealing a letter, players may guess a word
|
| 34 |
-
- Guess submission
|
| 35 |
- Only one guess per letter reveal; must uncover another letter before guessing again.
|
| 36 |
-
-
|
| 37 |
-
-
|
| 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
|
| 45 |
-
- Great: 38
|
| 46 |
-
- Fantastic: 42
|
| 47 |
-
- Legendary: 46+
|
| 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 |
-
-
|
| 93 |
-
- Radar screen (
|
| 94 |
-
-
|
| 95 |
-
- Score
|
| 96 |
-
-
|
| 97 |
-
- Game
|
| 98 |
-
-
|
| 99 |
-
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 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 |
-
##
|
| 120 |
-
-
|
| 121 |
-
-
|
| 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 |
-
##
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 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 |
-
|
| 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: 34–37
|
| 40 |
+
- Great: 38–41
|
| 41 |
+
- Fantastic: 42–45
|
| 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 |
-
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|