v0.2.8 Settings Page fixed
Browse filesAdd dedicated Settings page and local settings persistence
Introduced a dedicated Settings page (`?page=settings`) to replace
the sidebar settings, accessible via footer navigation. The page
includes controls for game mode, wordlist selection, grid options,
and audio settings. Implemented local JSON-based settings
persistence, saving configurations in `wrdler/settings/` and
auto-loading the latest settings on startup.
Enhanced the audio system with configurable volume controls for
background music and sound effects. Added new settings options
(`enable_free_letters`, `show_grid_ticks`, `enable_sound_effects`)
and updated the UI with improved navigation and design.
Refactored settings persistence logic, ensuring compatibility with
leaderboard settings and adding safeguards for file I/O. Updated
documentation, test files, and examples to reflect these changes.
Fixed minor issues, normalized music paths, and improved code
consistency.
- CLAUDE.md +15 -8
- README.md +15 -9
- pyproject.toml +1 -1
- specs/leaderboard_spec.md +2 -8
- specs/requirements.md +9 -11
- specs/settings.md +47 -0
- specs/specs.md +9 -161
- wrdler/__init__.py +1 -1
- wrdler/local_storage.py +175 -1
- wrdler/settings/classic-classic-0.json +21 -0
- wrdler/settings/classic-classic-1.json +21 -0
- wrdler/settings/classic-classic-2.json +21 -0
- wrdler/settings/settings.json +21 -0
- wrdler/settings_page.py +173 -2
- wrdler/ui.py +33 -6
|
@@ -1,6 +1,6 @@
|
|
| 1 |
# CLAUDE
|
| 2 |
|
| 3 |
-
Wrdler v0.2.
|
| 4 |
|
| 5 |
# Wrdler - Project Context
|
| 6 |
|
|
@@ -12,11 +12,11 @@ Wrdler is a simplified vocabulary puzzle game based on BattleWords:
|
|
| 12 |
- **2 free letter guesses at game start** (all instances revealed)
|
| 13 |
- **Word composition:** 2 four-letter, 2 five-letter, 2 six-letter words per puzzle
|
| 14 |
|
| 15 |
-
**Current Version:** 0.2.
|
| 16 |
**Repository:** https://github.com/Oncorporation/Wrdler.git
|
| 17 |
**Branch:** AI (working branch)
|
| 18 |
|
| 19 |
-
## Current Features (v0.2.
|
| 20 |
|
| 21 |
### Core Gameplay
|
| 22 |
- 8x6 grid with 6 hidden words (one per row, horizontal only)
|
|
@@ -38,6 +38,13 @@ Wrdler is a simplified vocabulary puzzle game based on BattleWords:
|
|
| 38 |
- **Good:** 35-38 points
|
| 39 |
- **Keep practicing:** < 35 points
|
| 40 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
### Word List Management
|
| 42 |
- Sidebar controls for sorting and filtering word lists
|
| 43 |
- Filter capability using `assets/filter.txt` blocklist to remove unwanted words
|
|
@@ -81,8 +88,8 @@ Wrdler is a simplified vocabulary puzzle game based on BattleWords:
|
|
| 81 |
|
| 82 |
### Audio & Visuals
|
| 83 |
- Ocean-themed gradient background with wave animations
|
| 84 |
-
- Toggleable background music with volume control
|
| 85 |
-
- Sound effects (hit/miss/correct/incorrect) with volume control
|
| 86 |
|
| 87 |
### PWA Support
|
| 88 |
- Installable as Progressive Web App on desktop and mobile
|
|
@@ -104,13 +111,13 @@ Wrdler is a simplified vocabulary puzzle game based on BattleWords:
|
|
| 104 |
wrdler/
|
| 105 |
├── app.py # Streamlit entry point
|
| 106 |
├── wrdler/ # Main package
|
| 107 |
-
│ ├── __init__.py # Version: 0.2.
|
| 108 |
│ ├── models.py # Data models (Coord, Word, Puzzle, GameState)
|
| 109 |
│ ├── generator.py # Puzzle generation with deterministic seeding
|
| 110 |
│ ├── logic.py # Game mechanics (reveal, guess, scoring)
|
| 111 |
│ ├── ui.py # Streamlit UI with query param routing
|
| 112 |
│ ├── oauth.py # HuggingFace OAuth utilities (NEW)
|
| 113 |
-
│ ├── settings_page.py # Settings page UI (
|
| 114 |
│ ├── leaderboard.py # Leaderboard system (daily/weekly)
|
| 115 |
│ ├── leaderboard_page.py # Leaderboard UI page
|
| 116 |
│ ├── word_loader.py # Word list management
|
|
@@ -149,7 +156,7 @@ wrdler/
|
|
| 149 |
### Page Navigation System
|
| 150 |
Uses **query parameter-based routing** (NOT Streamlit multi-page):
|
| 151 |
- `?page=today|daily|weekly|history` → Leaderboard pages
|
| 152 |
-
- `?page=settings` → Settings page (
|
| 153 |
- `?game_id=<sid>` → Challenge mode
|
| 154 |
- No query params → Main game page
|
| 155 |
|
|
|
|
| 1 |
# CLAUDE
|
| 2 |
|
| 3 |
+
Wrdler v0.2.8
|
| 4 |
|
| 5 |
# Wrdler - Project Context
|
| 6 |
|
|
|
|
| 12 |
- **2 free letter guesses at game start** (all instances revealed)
|
| 13 |
- **Word composition:** 2 four-letter, 2 five-letter, 2 six-letter words per puzzle
|
| 14 |
|
| 15 |
+
**Current Version:** 0.2.8
|
| 16 |
**Repository:** https://github.com/Oncorporation/Wrdler.git
|
| 17 |
**Branch:** AI (working branch)
|
| 18 |
|
| 19 |
+
## Current Features (v0.2.7)
|
| 20 |
|
| 21 |
### Core Gameplay
|
| 22 |
- 8x6 grid with 6 hidden words (one per row, horizontal only)
|
|
|
|
| 38 |
- **Good:** 35-38 points
|
| 39 |
- **Keep practicing:** < 35 points
|
| 40 |
|
| 41 |
+
### Settings Page
|
| 42 |
+
- All game settings moved from sidebar to a dedicated Settings page (`?page=settings`)
|
| 43 |
+
- Accessible via footer navigation (`⚙️ Settings` link)
|
| 44 |
+
- Local JSON-based settings persistence in `wrdler/settings/`
|
| 45 |
+
- Latest settings auto-loaded on startup
|
| 46 |
+
- Sidebar now focused on lightweight controls (if any) and navigation
|
| 47 |
+
|
| 48 |
### Word List Management
|
| 49 |
- Sidebar controls for sorting and filtering word lists
|
| 50 |
- Filter capability using `assets/filter.txt` blocklist to remove unwanted words
|
|
|
|
| 88 |
|
| 89 |
### Audio & Visuals
|
| 90 |
- Ocean-themed gradient background with wave animations
|
| 91 |
+
- Toggleable background music with volume control (configured via Settings page, played globally)
|
| 92 |
+
- Sound effects (hit/miss/correct/incorrect) with volume control (configured via Settings page)
|
| 93 |
|
| 94 |
### PWA Support
|
| 95 |
- Installable as Progressive Web App on desktop and mobile
|
|
|
|
| 111 |
wrdler/
|
| 112 |
├── app.py # Streamlit entry point
|
| 113 |
├── wrdler/ # Main package
|
| 114 |
+
│ ├── __init__.py # Version: 0.2.7
|
| 115 |
│ ├── models.py # Data models (Coord, Word, Puzzle, GameState)
|
| 116 |
│ ├── generator.py # Puzzle generation with deterministic seeding
|
| 117 |
│ ├── logic.py # Game mechanics (reveal, guess, scoring)
|
| 118 |
│ ├── ui.py # Streamlit UI with query param routing
|
| 119 |
│ ├── oauth.py # HuggingFace OAuth utilities (NEW)
|
| 120 |
+
│ ├── settings_page.py # Settings page UI (implemented, query-param route ?page=settings)
|
| 121 |
│ ├── leaderboard.py # Leaderboard system (daily/weekly)
|
| 122 |
│ ├── leaderboard_page.py # Leaderboard UI page
|
| 123 |
│ ├── word_loader.py # Word list management
|
|
|
|
| 156 |
### Page Navigation System
|
| 157 |
Uses **query parameter-based routing** (NOT Streamlit multi-page):
|
| 158 |
- `?page=today|daily|weekly|history` → Leaderboard pages
|
| 159 |
+
- `?page=settings` → Settings page (no OAuth gating yet, local settings JSON persistence)
|
| 160 |
- `?game_id=<sid>` → Challenge mode
|
| 161 |
- No query params → Main game page
|
| 162 |
|
|
@@ -21,7 +21,7 @@ thumbnail: >-
|
|
| 21 |
|
| 22 |
# Wrdler
|
| 23 |
|
| 24 |
-
Version 0.2.
|
| 25 |
|
| 26 |
Wrdler is a vocabulary puzzle game with daily and weekly leaderboards, 8x6 grid, and two free letter guesses at the start. See CHANGELOG or specs for details on the latest updates.
|
| 27 |
|
|
@@ -29,7 +29,7 @@ Wrdler is a vocabulary puzzle game with daily and weekly leaderboards, 8x6 grid,
|
|
| 29 |
|
| 30 |
Wrdler is a vocabulary learning game with a simplified grid and strategic letter guessing. The objective is to discover hidden words on a grid by making smart guesses before all letters are revealed.
|
| 31 |
|
| 32 |
-
**Current Version:** v0.2.
|
| 33 |
|
| 34 |
## Key Differences from BattleWords
|
| 35 |
|
|
@@ -73,7 +73,7 @@ Wrdler is a vocabulary learning game with a simplified grid and strategic letter
|
|
| 73 |
|
| 74 |
### Customization
|
| 75 |
- Multiple word lists (classic, fourth_grade, wordlist)
|
| 76 |
-
-
|
| 77 |
- Audio volume controls (music and effects separate)
|
| 78 |
|
| 79 |
### ✅ Challenge Mode
|
|
@@ -131,7 +131,6 @@ Wrdler is a vocabulary learning game with a simplified grid and strategic letter
|
|
| 131 |
- See `INSTALL_GUIDE.md` for platform-specific steps
|
| 132 |
|
| 133 |
### Planned/Upcoming
|
| 134 |
-
- Move all game settings from sidebar to a dedicated settings page (`?page=settings`) requiring a logged in user (OAuth)
|
| 135 |
- Local persistent storage for personal game history (future)
|
| 136 |
- Personal high scores sidebar (future)
|
| 137 |
- Player statistics tracking (future)
|
|
@@ -217,9 +216,9 @@ MAX_DISPLAY_ENTRIES=25 # Max leaderboard entries to display (def
|
|
| 217 |
- `word_loader_ai.py` – AI word generation with retry logic
|
| 218 |
- `generator.py` – word placement logic (8x6, horizontal only)
|
| 219 |
- `logic.py` – game mechanics (reveal, guess, scoring, free letters)
|
| 220 |
-
- `ui.py` – Streamlit UI composition
|
| 221 |
- `oauth.py` – HuggingFace OAuth utilities (NEW)
|
| 222 |
-
- `settings_page.py` – Settings page UI (
|
| 223 |
- `leaderboard.py` – Daily/weekly leaderboard system (v0.2.1)
|
| 224 |
- `leaderboard_page.py` – Leaderboard UI page (v0.2.1)
|
| 225 |
- `game_storage.py` – Hugging Face remote storage integration and challenge sharing
|
|
@@ -241,11 +240,18 @@ All test files must be placed in the `/tests` folder. This ensures a clean proje
|
|
| 241 |
4. Earn points for correct guesses and bonus points for unrevealed letters.
|
| 242 |
5. **The game ends when all six words are found or all word letters are revealed. Your score tier is displayed.**
|
| 243 |
6. **To play a shared challenge, use a link with `?game_id=<sid>`. Your result will be added to the challenge leaderboard.**
|
| 244 |
-
7. **Access leaderboards anytime using the 'Leaderboard' link in the footer navigation.**
|
| 245 |
|
| 246 |
## Changelog
|
| 247 |
|
| 248 |
-
### v0.2.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
**Word List Filtering**
|
| 250 |
- ✅ Added "Filter Wordlist" button to sidebar
|
| 251 |
- ✅ Filters words against `assets/filter.txt` blocklist
|
|
@@ -435,4 +441,4 @@ Ensure you have the necessary permissions and API access (if required) to use th
|
|
| 435 |
|
| 436 |
For any issues or enhancements, please refer to the project documentation or contact the project maintainer.
|
| 437 |
|
| 438 |
-
Happy gaming and sound designing!Happy gaming and sound designing!
|
|
|
|
| 21 |
|
| 22 |
# Wrdler
|
| 23 |
|
| 24 |
+
Version 0.2.8
|
| 25 |
|
| 26 |
Wrdler is a vocabulary puzzle game with daily and weekly leaderboards, 8x6 grid, and two free letter guesses at the start. See CHANGELOG or specs for details on the latest updates.
|
| 27 |
|
|
|
|
| 29 |
|
| 30 |
Wrdler is a vocabulary learning game with a simplified grid and strategic letter guessing. The objective is to discover hidden words on a grid by making smart guesses before all letters are revealed.
|
| 31 |
|
| 32 |
+
**Current Version:** v0.2.8
|
| 33 |
|
| 34 |
## Key Differences from BattleWords
|
| 35 |
|
|
|
|
| 73 |
|
| 74 |
### Customization
|
| 75 |
- Multiple word lists (classic, fourth_grade, wordlist)
|
| 76 |
+
- Dedicated Settings page (`⚙️ Settings` in footer) for wordlist selection, game mode, grid options, and audio
|
| 77 |
- Audio volume controls (music and effects separate)
|
| 78 |
|
| 79 |
### ✅ Challenge Mode
|
|
|
|
| 131 |
- See `INSTALL_GUIDE.md` for platform-specific steps
|
| 132 |
|
| 133 |
### Planned/Upcoming
|
|
|
|
| 134 |
- Local persistent storage for personal game history (future)
|
| 135 |
- Personal high scores sidebar (future)
|
| 136 |
- Player statistics tracking (future)
|
|
|
|
| 216 |
- `word_loader_ai.py` – AI word generation with retry logic
|
| 217 |
- `generator.py` – word placement logic (8x6, horizontal only)
|
| 218 |
- `logic.py` – game mechanics (reveal, guess, scoring, free letters)
|
| 219 |
+
- `ui.py` – Streamlit UI composition (with query-param routing and Settings/Leaderboard pages)
|
| 220 |
- `oauth.py` – HuggingFace OAuth utilities (NEW)
|
| 221 |
+
- `settings_page.py` – Settings page UI (implemented)
|
| 222 |
- `leaderboard.py` – Daily/weekly leaderboard system (v0.2.1)
|
| 223 |
- `leaderboard_page.py` – Leaderboard UI page (v0.2.1)
|
| 224 |
- `game_storage.py` – Hugging Face remote storage integration and challenge sharing
|
|
|
|
| 240 |
4. Earn points for correct guesses and bonus points for unrevealed letters.
|
| 241 |
5. **The game ends when all six words are found or all word letters are revealed. Your score tier is displayed.**
|
| 242 |
6. **To play a shared challenge, use a link with `?game_id=<sid>`. Your result will be added to the challenge leaderboard.**
|
| 243 |
+
7. **Access leaderboards anytime using the 'Leaderboard' link and change game/audio options via the 'Settings' link in the footer navigation.**
|
| 244 |
|
| 245 |
## Changelog
|
| 246 |
|
| 247 |
+
### v0.2.8 (Current) ✅
|
| 248 |
+
**Settings Page & Local Settings Persistence (Settings Update)**
|
| 249 |
+
- ✅ All game settings moved from sidebar to dedicated Settings page (`?page=settings`)
|
| 250 |
+
- ✅ Settings accessible from footer navigation (`⚙️ Settings` link)
|
| 251 |
+
- ✅ Local JSON-based settings persistence in `wrdler/settings/`
|
| 252 |
+
- ✅ Latest settings automatically loaded on startup
|
| 253 |
+
|
| 254 |
+
### v0.2.7 ✅
|
| 255 |
**Word List Filtering**
|
| 256 |
- ✅ Added "Filter Wordlist" button to sidebar
|
| 257 |
- ✅ Filters words against `assets/filter.txt` blocklist
|
|
|
|
| 441 |
|
| 442 |
For any issues or enhancements, please refer to the project documentation or contact the project maintainer.
|
| 443 |
|
| 444 |
+
Happy gaming and sound designing!Happy gaming and sound designing!Happy gaming and sound designing!Happy gaming and sound designing!
|
|
@@ -1,6 +1,6 @@
|
|
| 1 |
[project]
|
| 2 |
name = "wrdler"
|
| 3 |
-
version = "0.2.
|
| 4 |
description = "Wrdler vocabulary puzzle game - simplified version based on BattleWords with 8x6 grid, horizontal words only, no scope, 2 free letter guesses, and a settings-based daily/weekly leaderboard system. Features leaderboard UI, challenge sharing, and AI word lists."
|
| 5 |
readme = "README.md"
|
| 6 |
requires-python = ">=3.12,<3.13"
|
|
|
|
| 1 |
[project]
|
| 2 |
name = "wrdler"
|
| 3 |
+
version = "0.2.8"
|
| 4 |
description = "Wrdler vocabulary puzzle game - simplified version based on BattleWords with 8x6 grid, horizontal words only, no scope, 2 free letter guesses, and a settings-based daily/weekly leaderboard system. Features leaderboard UI, challenge sharing, and AI word lists."
|
| 5 |
readme = "README.md"
|
| 6 |
requires-python = ">=3.12,<3.13"
|
|
@@ -1,7 +1,7 @@
|
|
| 1 |
# Wrdler Leaderboard System Specification
|
| 2 |
|
| 3 |
**Document Version:** 1.4.1
|
| 4 |
-
**Project Version:** 0.2.
|
| 5 |
**Author:** GitHub Copilot
|
| 6 |
**Last Updated:** 2025-12-08
|
| 7 |
**Status:** ✅ Implemented and Documented
|
|
@@ -883,10 +883,4 @@ HF_REPO_ID/games/
|
|
| 883 |
"YYYY-MM-DD HH:MM:SS PST to YYYY-MM-DD HH:MM:SS PST"
|
| 884 |
(PST is UTC-8; adjust for daylight saving as needed)
|
| 885 |
For example, a UTC file date of `2025-12-08` covers `2025-12-08 00:00:00 UTC` to `2025-12-08 23:59:59 UTC`, which is displayed as `2025-12-07 16:00:00 PST` to `2025-12-08 15:59:59 PST`.
|
| 886 |
-
The leaderboard expander label should show: `Mon, Dec 08, 2025 4:00 PM PST – Tue, Dec 09, 2025 3:59:59 PM PST [settings badge]`
|
| 887 |
-
|
| 888 |
-
**Leaderboard Page UI:**
|
| 889 |
-
- **Today Tab:** Current daily and weekly leaderboards
|
| 890 |
-
- **Daily Tab:** Last 7 days of daily leaderboards
|
| 891 |
-
- **Weekly Tab:** Last 5 weeks displayed as individual expanders (current week or `week=YYYY-Www` query opens by default)
|
| 892 |
-
- **History Tab:** Historical leaderboard browser
|
|
|
|
| 1 |
# Wrdler Leaderboard System Specification
|
| 2 |
|
| 3 |
**Document Version:** 1.4.1
|
| 4 |
+
**Project Version:** 0.2.8
|
| 5 |
**Author:** GitHub Copilot
|
| 6 |
**Last Updated:** 2025-12-08
|
| 7 |
**Status:** ✅ Implemented and Documented
|
|
|
|
| 883 |
"YYYY-MM-DD HH:MM:SS PST to YYYY-MM-DD HH:MM:SS PST"
|
| 884 |
(PST is UTC-8; adjust for daylight saving as needed)
|
| 885 |
For example, a UTC file date of `2025-12-08` covers `2025-12-08 00:00:00 UTC` to `2025-12-08 23:59:59 UTC`, which is displayed as `2025-12-07 16:00:00 PST` to `2025-12-08 15:59:59 PST`.
|
| 886 |
+
The leaderboard expander label should show: `Mon, Dec 08, 2025 4:00 PM PST – Tue, Dec 09, 2025 3:59:59 PM PST [settings badge]`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,12 +1,12 @@
|
|
| 1 |
# Wrdler Requirements
|
| 2 |
|
| 3 |
-
**Version:** 0.2.
|
| 4 |
**Status:** Production Ready - Leaderboards Implemented
|
| 5 |
-
**Last Updated:** 2025-12-
|
| 6 |
|
| 7 |
This document breaks down the implementation tasks for Wrdler using the game rules described in `specs.md`. Wrdler is a Python/Streamlit project based on BattleWords but with a simplified 8x6 grid, horizontal-only words, and free letter guesses at the start.
|
| 8 |
|
| 9 |
-
**Current Status:** ✅ All Phase 1 requirements complete, 100% tested (25/25 tests passing), AI word generation enhanced in v0.1.1, Daily/Weekly leaderboards implemented in v0.2.1
|
| 10 |
|
| 11 |
## Key Differences from BattleWords
|
| 12 |
- Python project (Streamlit, Python 3.12.8)
|
|
@@ -39,16 +39,14 @@ This document breaks down the implementation tasks for Wrdler using the game rul
|
|
| 39 |
- Layout & structure ✅
|
| 40 |
- `st.title`, `st.subheader`, `st.markdown` for headers
|
| 41 |
- `st.columns(8)` to render the 8×6 grid (6 rows)
|
| 42 |
-
-
|
| 43 |
-
- `st.expander` for high scores and stats
|
| 44 |
|
| 45 |
- Widgets (interaction) ✅
|
| 46 |
- `st.button` for each grid cell (48 total) with unique `key`
|
| 47 |
- Circular green gradient free letter choice buttons (2 at game start)
|
| 48 |
- `st.form` + `st.text_input` + `st.form_submit_button("OK")` for word guessing
|
| 49 |
- `st.button("New Game")` to reset state
|
| 50 |
-
-
|
| 51 |
-
- Sidebar buttons for "Sort Wordlist" and "Filter Wordlist"
|
| 52 |
|
| 53 |
- Visualization ✅
|
| 54 |
- Ocean-themed gradient background
|
|
@@ -63,7 +61,7 @@ This document breaks down the implementation tasks for Wrdler using the game rul
|
|
| 63 |
## Folder Structure (Implemented)
|
| 64 |
- `app.py` – Streamlit entry point ✅
|
| 65 |
- `wrdler/` – Python package ✅
|
| 66 |
-
- `__init__.py` (version 0.2.
|
| 67 |
- `models.py` – data models and types (rectangular grid support)
|
| 68 |
- `word_loader.py` – load/validate/cached word lists
|
| 69 |
- `word_loader_ai.py` – AI word generation with retry logic
|
|
@@ -76,7 +74,7 @@ This document breaks down the implementation tasks for Wrdler using the game rul
|
|
| 76 |
- `sounds.py` – sound effects management
|
| 77 |
- `game_storage.py` – HF storage wrapper for Challenge Mode
|
| 78 |
- `oauth.py` – HuggingFace OAuth utilities (NEW)
|
| 79 |
-
- `settings_page.py` – Settings page UI (
|
| 80 |
- `modules/` – shared utilities (storage with folder listing, constants, file_utils)
|
| 81 |
- `words/` – word list files (classic.txt, fourth_grade.txt, wordlist.txt)
|
| 82 |
- `specs/` – documentation (specs.md, requirements.md, sprint reports)
|
|
@@ -214,8 +212,8 @@ This document breaks down the implementation tasks for Wrdler using the game rul
|
|
| 214 |
- `wrdler/leaderboard_page.py` - Leaderboard UI page (v0.2.1)
|
| 215 |
- `wrdler/modules/storage.py` - Enhanced with folder listing functions
|
| 216 |
- `wrdler/oauth.py` - HuggingFace OAuth utilities (NEW)
|
| 217 |
-
- `wrdler/settings_page.py` - Settings page UI (
|
| 218 |
-
|
| 219 |
**Data Models:**
|
| 220 |
- `GameSettings` - Settings that define a unique leaderboard
|
| 221 |
- `UserEntry` - Individual score entry with uid, username, score, time, difficulty
|
|
|
|
| 1 |
# Wrdler Requirements
|
| 2 |
|
| 3 |
+
**Version:** 0.2.8
|
| 4 |
**Status:** Production Ready - Leaderboards Implemented
|
| 5 |
+
**Last Updated:** 2025-12-09
|
| 6 |
|
| 7 |
This document breaks down the implementation tasks for Wrdler using the game rules described in `specs.md`. Wrdler is a Python/Streamlit project based on BattleWords but with a simplified 8x6 grid, horizontal-only words, and free letter guesses at the start.
|
| 8 |
|
| 9 |
+
**Current Status:** ✅ All Phase 1 requirements complete, 100% tested (25/25 tests passing), AI word generation enhanced in v0.1.1, Daily/Weekly leaderboards implemented in v0.2.1, dedicated Settings page and local settings persistence implemented in v0.2.8
|
| 10 |
|
| 11 |
## Key Differences from BattleWords
|
| 12 |
- Python project (Streamlit, Python 3.12.8)
|
|
|
|
| 39 |
- Layout & structure ✅
|
| 40 |
- `st.title`, `st.subheader`, `st.markdown` for headers
|
| 41 |
- `st.columns(8)` to render the 8×6 grid (6 rows)
|
| 42 |
+
- Footer navigation for Play, Leaderboard, and Settings pages
|
|
|
|
| 43 |
|
| 44 |
- Widgets (interaction) ✅
|
| 45 |
- `st.button` for each grid cell (48 total) with unique `key`
|
| 46 |
- Circular green gradient free letter choice buttons (2 at game start)
|
| 47 |
- `st.form` + `st.text_input` + `st.form_submit_button("OK")` for word guessing
|
| 48 |
- `st.button("New Game")` to reset state
|
| 49 |
+
- Settings page controls for wordlist selection, game mode, grid options, and audio
|
|
|
|
| 50 |
|
| 51 |
- Visualization ✅
|
| 52 |
- Ocean-themed gradient background
|
|
|
|
| 61 |
## Folder Structure (Implemented)
|
| 62 |
- `app.py` – Streamlit entry point ✅
|
| 63 |
- `wrdler/` – Python package ✅
|
| 64 |
+
- `__init__.py` (version 0.2.7)
|
| 65 |
- `models.py` – data models and types (rectangular grid support)
|
| 66 |
- `word_loader.py` – load/validate/cached word lists
|
| 67 |
- `word_loader_ai.py` – AI word generation with retry logic
|
|
|
|
| 74 |
- `sounds.py` – sound effects management
|
| 75 |
- `game_storage.py` – HF storage wrapper for Challenge Mode
|
| 76 |
- `oauth.py` – HuggingFace OAuth utilities (NEW)
|
| 77 |
+
- `settings_page.py` – Settings page UI (complete; replaces sidebar settings)
|
| 78 |
- `modules/` – shared utilities (storage with folder listing, constants, file_utils)
|
| 79 |
- `words/` – word list files (classic.txt, fourth_grade.txt, wordlist.txt)
|
| 80 |
- `specs/` – documentation (specs.md, requirements.md, sprint reports)
|
|
|
|
| 212 |
- `wrdler/leaderboard_page.py` - Leaderboard UI page (v0.2.1)
|
| 213 |
- `wrdler/modules/storage.py` - Enhanced with folder listing functions
|
| 214 |
- `wrdler/oauth.py` - HuggingFace OAuth utilities (NEW)
|
| 215 |
+
- `wrdler/settings_page.py` - Settings page UI (implemented; no OAuth gating yet)
|
| 216 |
+
|
| 217 |
**Data Models:**
|
| 218 |
- `GameSettings` - Settings that define a unique leaderboard
|
| 219 |
- `UserEntry` - Individual score entry with uid, username, score, time, difficulty
|
|
@@ -63,3 +63,50 @@ This file will encapsulate the rendering logic for the settings.
|
|
| 63 |
- **Audio**: The audio *controls* (volume, track) will be in the Settings page, but the audio *player* (hidden HTML/JS) must be mounted on every page load via `_handle_audio()` in `run_app` to ensure continuous playback or proper state.
|
| 64 |
- **Callbacks**: `_on_game_option_change` in `ui.py` calls `_new_game`. This callback will be passed to `render_settings_page` so that changing settings triggers the necessary state resets.
|
| 65 |
- **Navigation**: The footer will serve as the primary navigation between "Play", "Leaderboard", and "Settings".
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
- **Audio**: The audio *controls* (volume, track) will be in the Settings page, but the audio *player* (hidden HTML/JS) must be mounted on every page load via `_handle_audio()` in `run_app` to ensure continuous playback or proper state.
|
| 64 |
- **Callbacks**: `_on_game_option_change` in `ui.py` calls `_new_game`. This callback will be passed to `render_settings_page` so that changing settings triggers the necessary state resets.
|
| 65 |
- **Navigation**: The footer will serve as the primary navigation between "Play", "Leaderboard", and "Settings".
|
| 66 |
+
|
| 67 |
+
---
|
| 68 |
+
|
| 69 |
+
## Settings Persistence Guidance (UPDATED)
|
| 70 |
+
|
| 71 |
+
- **Each settings configuration is saved as a separate JSON file in `wrdler/settings/`, not as a single large file.**
|
| 72 |
+
- **File naming convention:** Use a unique, human-readable key based on the main settings (e.g., `classic-classic-0.json`).
|
| 73 |
+
- Example: `wrdler/settings/classic-classic-0.json`
|
| 74 |
+
- This mirrors the leaderboard's convention (e.g., `weekly/2025-W51/classic-classic-0/settings.json`), but is local and not required to match challenge/leaderboard config structure.
|
| 75 |
+
- **Settings files should use the same layout as the current settings.json, but each file only contains one configuration.**
|
| 76 |
+
- **No need to match leaderboard or challenge settings.json structure exactly.**
|
| 77 |
+
- **When saving settings, only the relevant configuration for that file is written.**
|
| 78 |
+
- **When loading, look up the file by its unique key.**
|
| 79 |
+
- **This approach supports local wordlists and ensures settings are unique per instance.**
|
| 80 |
+
|
| 81 |
+
---
|
| 82 |
+
|
| 83 |
+
## Plan: Local Settings File Storage and Loading (Implemented in v0.2.8)
|
| 84 |
+
|
| 85 |
+
1. **Settings File Naming**
|
| 86 |
+
- For each unique settings configuration, generate a filename like `classic-classic-0.json` based on the main settings (e.g., game mode, wordlist, spacer).
|
| 87 |
+
- Store these files in `wrdler/settings/`.
|
| 88 |
+
|
| 89 |
+
2. **Saving Settings**
|
| 90 |
+
- When the user clicks "Save Settings" in the settings page:
|
| 91 |
+
- Gather all relevant settings from `st.session_state`.
|
| 92 |
+
- Generate the unique filename for the current configuration.
|
| 93 |
+
- Save the settings as a JSON file in `wrdler/settings/` using the generated filename.
|
| 94 |
+
- Use the same JSON structure as the current settings.json, but only for this configuration.
|
| 95 |
+
|
| 96 |
+
3. **Loading Settings in `wrdler/ui.py`**
|
| 97 |
+
- On app startup (in `_init_session()` or before applying defaults):
|
| 98 |
+
- Determine the intended settings file (e.g., from defaults or user selection).
|
| 99 |
+
- If the file exists in `wrdler/settings/`, load it and update `st.session_state` with its values (only for keys not already set).
|
| 100 |
+
- If not, proceed with defaults.
|
| 101 |
+
|
| 102 |
+
4. **Settings Page Integration**
|
| 103 |
+
- The settings page should allow users to select, save, and load settings configurations by their unique keys.
|
| 104 |
+
- Optionally, provide a dropdown or list of available settings files for quick switching.
|
| 105 |
+
|
| 106 |
+
5. **Directory Management**
|
| 107 |
+
- Ensure the `wrdler/settings/` directory is created if it does not exist.
|
| 108 |
+
- Handle file I/O errors gracefully and inform the user if saving/loading fails.
|
| 109 |
+
|
| 110 |
+
6. **Extensibility**
|
| 111 |
+
- When new settings are added, include them in the filename generation and JSON structure as needed.
|
| 112 |
+
- This approach allows for easy expansion as more settings or wordlists are introduced.
|
|
@@ -1,9 +1,9 @@
|
|
| 1 |
# Wrdler Specifications
|
| 2 |
|
| 3 |
-
**Version:** 0.2.
|
| 4 |
|
| 5 |
-
**Status:** Production Ready - Leaderboards Implemented
|
| 6 |
-
**Last Updated:** 2025-12-
|
| 7 |
|
| 8 |
## Overview
|
| 9 |
Wrdler is a Python/Streamlit vocabulary puzzle game based on BattleWords, but with key differences. The objective is to discover hidden words on a grid by making strategic guesses and using free letter reveals at the game start.
|
|
@@ -113,7 +113,7 @@ Wrdler features a comprehensive daily and weekly leaderboard system:
|
|
| 113 |
HF_REPO_ID/games/
|
| 114 |
├── leaderboards/
|
| 115 |
│ ├── daily/{YYYY-MM-DD}/{file_id}/settings.json
|
| 116 |
-
│ └── weekly/{YYYY-
|
| 117 |
└── {challenge_id}/settings.json
|
| 118 |
```
|
| 119 |
|
|
@@ -163,160 +163,8 @@ HF_REPO_ID/games/
|
|
| 163 |
- INSTALL_GUIDE.md added with platform-specific install steps
|
| 164 |
- No gameplay logic changes
|
| 165 |
|
| 166 |
-
### Settings Page (
|
| 167 |
-
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
### Current (v0.2.1)
|
| 172 |
-
- ✅ Challenge Mode uses remote storage via Hugging Face datasets
|
| 173 |
-
- ✅ Game ID is generated from the word list for replay/sharing
|
| 174 |
-
|
| 175 |
-
### Planned (Future)
|
| 176 |
-
- Local persistent storage for game results and high scores (JSON files)
|
| 177 |
-
- Local storage location: `~/.wrdler/data/`
|
| 178 |
-
- Privacy-first offline access
|
| 179 |
-
|
| 180 |
-
## UI Elements (v0.2.1 - Implemented)
|
| 181 |
-
- ✅ 8x6 grid (48 cells total)
|
| 182 |
-
- ✅ Free letter guess buttons (2 at game start) - circular green gradient design
|
| 183 |
-
- ✅ Text box for word guesses
|
| 184 |
-
- ✅ Score display (shows word, base points, bonus points, total score)
|
| 185 |
-
- ✅ Guess status indicator (Correct/Try Again)
|
| 186 |
-
- ✅ Incorrect guess history display (toggleable)
|
| 187 |
-
- ✅ Game ID display and share button in game over dialog
|
| 188 |
-
- ✅ Challenge Mode banner with leaderboard (top 5)
|
| 189 |
-
- ✅ High score expander in sidebar
|
| 190 |
-
- ✅ Player name input in sidebar
|
| 191 |
-
- ✅ Checkbox: "Show Challenge Share Links" (default OFF)
|
| 192 |
-
- When OFF:
|
| 193 |
-
- Challenge Mode header hides the Share Challenge link
|
| 194 |
-
- Game Over dialog still supports submitting/creating challenges, but does not display the generated share URL
|
| 195 |
-
- Persisted in session state and preserved across "New Game"
|
| 196 |
-
|
| 197 |
-
## Word List
|
| 198 |
-
- External list at `wrdler/words/wordlist.txt`
|
| 199 |
-
- Loaded by `wrdler.word_loader.load_word_list()` with caching
|
| 200 |
-
- Filtered to uppercase A-Z, lengths in {4,5,6}; falls back if < 25 per length
|
| 201 |
-
|
| 202 |
-
## Generator
|
| 203 |
-
- Centralized word loader
|
| 204 |
-
- No duplicate word texts are selected
|
| 205 |
-
- Horizontal-only word placement
|
| 206 |
-
- One word per row in 8x6 grid
|
| 207 |
-
- **Word length distribution:** Each puzzle must contain exactly 2 four-letter words, 2 five-letter words, and 2 six-letter words
|
| 208 |
-
- No word spacing configuration (fixed one word per row)
|
| 209 |
-
|
| 210 |
-
## Entry Point
|
| 211 |
-
- The Streamlit entry point is `app.py`
|
| 212 |
-
- **A `Dockerfile` can be used for containerized deployment (recommended for Hugging Face Spaces)**
|
| 213 |
-
|
| 214 |
-
## Deployment Requirements
|
| 215 |
-
|
| 216 |
-
### Basic Deployment (Offline Mode)
|
| 217 |
-
No special configuration needed. The app will run with all core gameplay features.
|
| 218 |
-
Optional: Install as PWA from the browser menu (Add to Home Screen/Install app).
|
| 219 |
-
|
| 220 |
-
### Challenge Mode Deployment (Remote Storage)
|
| 221 |
-
Requires HuggingFace Hub integration for challenge sharing and leaderboards.
|
| 222 |
-
|
| 223 |
-
**Required Environment Variables:**
|
| 224 |
-
```bash
|
| 225 |
-
HF_API_TOKEN=hf_xxxxxxxxxxxxxxxxxxxxx # or HF_TOKEN (write access required)
|
| 226 |
-
HF_REPO_ID=YourUsername/YourRepo # Target HF dataset repository
|
| 227 |
-
SPACE_NAME=YourUsername/Wrdler # Your HF Space name for URL generation
|
| 228 |
-
```
|
| 229 |
-
|
| 230 |
-
**Optional Environment Variables:**
|
| 231 |
-
```bash
|
| 232 |
-
CRYPTO_PK= # Reserved for future challenge signing
|
| 233 |
-
```
|
| 234 |
-
|
| 235 |
-
**Setup Steps:**
|
| 236 |
-
1. Create a HuggingFace account at https://huggingface.co
|
| 237 |
-
2. Create a dataset repository (e.g., `YourUsername/WrdlerStorage`)
|
| 238 |
-
3. Generate an access token with `write` permissions:
|
| 239 |
-
- Go to https://huggingface.co/settings/tokens
|
| 240 |
-
- Click "New token"
|
| 241 |
-
- Select "Write" access
|
| 242 |
-
- Copy the token (starts with `hf_`)
|
| 243 |
-
4. Create a `.env` file in project root with the variables above
|
| 244 |
-
5. For Hugging Face Spaces deployment, add these as Space secrets
|
| 245 |
-
|
| 246 |
-
**Repository Structure (automatically created):**
|
| 247 |
-
```
|
| 248 |
-
HF_REPO_ID/
|
| 249 |
-
├── shortener.json # Short URL mappings (sid -> full URL)
|
| 250 |
-
└── games/
|
| 251 |
-
├── leaderboards/
|
| 252 |
-
│ ├── daily/
|
| 253 |
-
│ │ └── {YYYY-MM-DD}/ # Daily period folders
|
| 254 |
-
│ │ └── {file_id}/ # Settings-specific leaderboard
|
| 255 |
-
│ │ └── settings.json # entry_type: "daily"
|
| 256 |
-
│ └── weekly/
|
| 257 |
-
│ └── {YYYY-Www}/ # Weekly period folders (ISO week)
|
| 258 |
-
│ └── {file_id}/ # Settings-specific leaderboard
|
| 259 |
-
│ └── settings.json # entry_type: "weekly"
|
| 260 |
-
└── {challenge_id}/
|
| 261 |
-
└── settings.json # Challenge data (entry_type: "challenge")
|
| 262 |
-
```
|
| 263 |
-
|
| 264 |
-
**Data Privacy:**
|
| 265 |
-
- Challenge Mode stores: word lists, scores, times, game modes, player names
|
| 266 |
-
- No PII beyond optional player name (defaults to "Anonymous")
|
| 267 |
-
- Players control URL visibility via "Show Challenge Share Links" setting
|
| 268 |
-
- App functions fully offline when HF credentials not configured
|
| 269 |
-
|
| 270 |
-
**Deployment Platforms:**
|
| 271 |
-
- Local development: Run with `streamlit run app.py`
|
| 272 |
-
- Docker: Use provided `Dockerfile`
|
| 273 |
-
- Hugging Face Spaces: Dockerfile deployment (recommended)
|
| 274 |
-
- Any Python 3.10+ hosting with Streamlit support
|
| 275 |
-
|
| 276 |
-
## Development Status
|
| 277 |
-
|
| 278 |
-
**Current Version:** 0.2.4 (Production Ready - Leaderboards Implemented)
|
| 279 |
-
|
| 280 |
-
### Completed ✅
|
| 281 |
-
- **v0.2.4:** Word List Filtering
|
| 282 |
-
- Added "Filter Wordlist" button to sidebar
|
| 283 |
-
- Implemented blocklist filtering via `assets/filter.txt`
|
| 284 |
-
- Added results dialog showing removed words
|
| 285 |
-
|
| 286 |
-
- **v0.2.1:** Daily and Weekly Leaderboards
|
| 287 |
-
- Settings-based leaderboard separation
|
| 288 |
-
- Folder-based discovery (no index.json)
|
| 289 |
-
- Top 20 displayed entries per leaderboard
|
| 290 |
-
- Four-tab leaderboard page (Today, Daily, Weekly, History)
|
| 291 |
-
- Automatic score qualification and submission
|
| 292 |
-
- Integration with challenge mode
|
| 293 |
-
- Query parameter filtering for direct links
|
| 294 |
-
- Settings page planned (move from sidebar, OAuth login required)
|
| 295 |
-
|
| 296 |
-
- **v0.1.1:** Enhanced AI word generation
|
| 297 |
-
- Intelligent word saving with duplicate prevention
|
| 298 |
-
- Automatic retry mechanism (up to 3 attempts)
|
| 299 |
-
- 1000-word file size limit
|
| 300 |
-
- Improved HF Space API integration
|
| 301 |
-
- Enhanced logging and error handling
|
| 302 |
-
|
| 303 |
-
- **v0.1.0:** AI word generation foundation
|
| 304 |
-
- Topic-based word list creation
|
| 305 |
-
- Dual generation modes (HF Space + local)
|
| 306 |
-
- Utility modules integration
|
| 307 |
-
|
| 308 |
-
- **v0.0.2:** All 7 sprints complete
|
| 309 |
-
- ✅ 100% test coverage (25/25 tests)
|
| 310 |
-
- 📊 Development time: ~12.75 hours (sprints 1-7)
|
| 311 |
-
- 📚 Complete documentation
|
| 312 |
-
|
| 313 |
-
### Future Roadmap
|
| 314 |
-
- Local persistent storage, high score tracking, player statistics, leaderboard caching (future)
|
| 315 |
-
- Enhanced UI animations, retry logic, rate limiting, archival script (future)
|
| 316 |
-
- AI difficulty tuning, multi-language word generation, i18n (future)
|
| 317 |
-
|
| 318 |
-
## Copyright
|
| 319 |
-
Wrdler is based on BattleWords. BattlewordsTM. All Rights Reserved. All content, trademarks and logos are copyrighted by the owner.
|
| 320 |
-
|
| 321 |
-
## Test File Location
|
| 322 |
-
All test files must be placed in the `/tests` folder. This ensures a clean project structure and makes it easy to discover and run all tests.
|
|
|
|
| 1 |
# Wrdler Specifications
|
| 2 |
|
| 3 |
+
**Version:** 0.2.8
|
| 4 |
|
| 5 |
+
**Status:** Production Ready - Leaderboards & Settings Page Implemented
|
| 6 |
+
**Last Updated:** 2025-12-09
|
| 7 |
|
| 8 |
## Overview
|
| 9 |
Wrdler is a Python/Streamlit vocabulary puzzle game based on BattleWords, but with key differences. The objective is to discover hidden words on a grid by making strategic guesses and using free letter reveals at the game start.
|
|
|
|
| 113 |
HF_REPO_ID/games/
|
| 114 |
├── leaderboards/
|
| 115 |
│ ├── daily/{YYYY-MM-DD}/{file_id}/settings.json
|
| 116 |
+
│ └── weekly/{YYYY-Wwww}/{file_id}/settings.json
|
| 117 |
└── {challenge_id}/settings.json
|
| 118 |
```
|
| 119 |
|
|
|
|
| 163 |
- INSTALL_GUIDE.md added with platform-specific install steps
|
| 164 |
- No gameplay logic changes
|
| 165 |
|
| 166 |
+
### Settings Page (v0.2.8)
|
| 167 |
+
- All game settings moved from sidebar to a dedicated settings page (`?page=settings`)
|
| 168 |
+
- Accessible via the footer navigation (`⚙️ Settings` link)
|
| 169 |
+
- Controls game mode, word list selection, grid options (spacer, grid ticks), and audio (music and sound effects)
|
| 170 |
+
- Settings are persisted to JSON files in `wrdler/settings/` and the latest settings are loaded on app startup
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -9,5 +9,5 @@ Key differences from BattleWords:
|
|
| 9 |
- Daily and weekly leaderboards
|
| 10 |
"""
|
| 11 |
|
| 12 |
-
__version__ = "0.2.
|
| 13 |
__all__ = ["models", "generator", "logic", "ui", "word_loader", "leaderboard", "leaderboard_page"]
|
|
|
|
| 9 |
- Daily and weekly leaderboards
|
| 10 |
"""
|
| 11 |
|
| 12 |
+
__version__ = "0.2.8"
|
| 13 |
__all__ = ["models", "generator", "logic", "ui", "word_loader", "leaderboard", "leaderboard_page"]
|
|
@@ -14,6 +14,7 @@ from typing import List, Dict, Optional, Any
|
|
| 14 |
from datetime import datetime
|
| 15 |
import json
|
| 16 |
import os
|
|
|
|
| 17 |
from pathlib import Path
|
| 18 |
|
| 19 |
@dataclass
|
|
@@ -161,6 +162,160 @@ class GameStorage:
|
|
| 161 |
"fastest_time": min(r.elapsed_seconds for r in player_results)
|
| 162 |
}
|
| 163 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
def save_json_to_file(data: dict, directory: str, filename: str = "settings.json") -> str:
|
| 165 |
"""
|
| 166 |
Save a dictionary as a JSON file with a specified filename in the given directory.
|
|
@@ -190,4 +345,23 @@ def parse_game_id_from_url() -> Optional[str]:
|
|
| 190 |
def create_shareable_url(game_id: str, base_url: Optional[str] = None) -> str:
|
| 191 |
if base_url is None:
|
| 192 |
base_url = "https://huggingface.co/spaces/Surn/Wrdler"
|
| 193 |
-
return f"{base_url}?game_id={game_id}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
from datetime import datetime
|
| 15 |
import json
|
| 16 |
import os
|
| 17 |
+
import glob
|
| 18 |
from pathlib import Path
|
| 19 |
|
| 20 |
@dataclass
|
|
|
|
| 162 |
"fastest_time": min(r.elapsed_seconds for r in player_results)
|
| 163 |
}
|
| 164 |
|
| 165 |
+
# --- Settings Persistence ---
|
| 166 |
+
|
| 167 |
+
SETTINGS_DIR = os.path.join(os.path.dirname(__file__), "settings")
|
| 168 |
+
MUSIC_DIR = os.path.join(os.path.dirname(__file__), "assets", "audio", "music")
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def ensure_settings_dir():
|
| 172 |
+
os.makedirs(SETTINGS_DIR, exist_ok=True)
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def _to_relative_music_path(path: Optional[str]) -> Optional[str]:
|
| 176 |
+
if not path:
|
| 177 |
+
return path
|
| 178 |
+
try:
|
| 179 |
+
norm_base = os.path.normpath(MUSIC_DIR)
|
| 180 |
+
norm_path = os.path.normpath(path)
|
| 181 |
+
if os.path.isabs(norm_path) and norm_path.startswith(norm_base):
|
| 182 |
+
rel_path = os.path.relpath(norm_path, norm_base)
|
| 183 |
+
return rel_path.replace("\\", "/")
|
| 184 |
+
return path
|
| 185 |
+
except Exception:
|
| 186 |
+
return path
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def _to_absolute_music_path(path: Optional[str]) -> Optional[str]:
|
| 190 |
+
if not path:
|
| 191 |
+
return path
|
| 192 |
+
if os.path.isabs(path):
|
| 193 |
+
return os.path.normpath(path)
|
| 194 |
+
return os.path.normpath(os.path.join(MUSIC_DIR, path))
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def generate_settings_filename(settings: dict) -> str:
|
| 198 |
+
"""
|
| 199 |
+
Generates a unique filename based on the main settings.
|
| 200 |
+
Format: {game_mode}-{wordlist_stem}-{spacer}.json
|
| 201 |
+
"""
|
| 202 |
+
mode = settings.get("game_mode", "classic")
|
| 203 |
+
|
| 204 |
+
# Handle wordlist name (remove extension)
|
| 205 |
+
if settings.get("use_ai_wordlist"):
|
| 206 |
+
topic = settings.get("ai_topic", "English").strip().replace(" ", "_")
|
| 207 |
+
# sanitize topic
|
| 208 |
+
topic = "".join(c for c in topic if c.isalnum() or c in ('-', '_'))
|
| 209 |
+
wordlist_part = f"ai-{topic}"
|
| 210 |
+
else:
|
| 211 |
+
w_file = settings.get("selected_wordlist", "classic.txt")
|
| 212 |
+
wordlist_part = os.path.splitext(os.path.basename(w_file))[0]
|
| 213 |
+
|
| 214 |
+
spacer = settings.get("spacer", 1)
|
| 215 |
+
|
| 216 |
+
# Sanitize filename parts
|
| 217 |
+
safe_mode = "".join(c for c in mode if c.isalnum() or c in ('-', '_'))
|
| 218 |
+
safe_wordlist = "".join(c for c in wordlist_part if c.isalnum() or c in ('-', '_'))
|
| 219 |
+
|
| 220 |
+
return f"{safe_mode}-{safe_wordlist}-{spacer}.json"
|
| 221 |
+
|
| 222 |
+
def save_settings_configuration(settings: dict) -> str:
|
| 223 |
+
"""
|
| 224 |
+
Saves the provided settings dictionary to a JSON file in wrdler/settings/.
|
| 225 |
+
Ensures compatibility with leaderboard settings format.
|
| 226 |
+
Also writes a copy to settings/settings.json as the canonical latest settings.
|
| 227 |
+
Returns the filename used.
|
| 228 |
+
"""
|
| 229 |
+
ensure_settings_dir()
|
| 230 |
+
filename = generate_settings_filename(settings)
|
| 231 |
+
filepath = os.path.join(SETTINGS_DIR, filename)
|
| 232 |
+
|
| 233 |
+
# Transform to compatible format
|
| 234 |
+
compatible_settings = settings.copy()
|
| 235 |
+
|
| 236 |
+
# Normalize music track path to be relative for portability
|
| 237 |
+
if "music_track_path" in compatible_settings and compatible_settings["music_track_path"]:
|
| 238 |
+
compatible_settings["music_track_path"] = _to_relative_music_path(compatible_settings["music_track_path"])
|
| 239 |
+
|
| 240 |
+
# Map selected_wordlist to wordlist_source
|
| 241 |
+
if "selected_wordlist" in settings:
|
| 242 |
+
compatible_settings["wordlist_source"] = settings["selected_wordlist"]
|
| 243 |
+
|
| 244 |
+
# Map spacer to puzzle_options
|
| 245 |
+
if "spacer" in settings:
|
| 246 |
+
compatible_settings["puzzle_options"] = {
|
| 247 |
+
"spacer": settings["spacer"],
|
| 248 |
+
"may_overlap": False # Default
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
# Ensure other required fields for leaderboard compatibility are present or defaulted
|
| 252 |
+
if "game_mode" not in compatible_settings:
|
| 253 |
+
compatible_settings["game_mode"] = "classic"
|
| 254 |
+
if "show_incorrect_guesses" not in compatible_settings:
|
| 255 |
+
compatible_settings["show_incorrect_guesses"] = True
|
| 256 |
+
if "enable_free_letters" not in compatible_settings:
|
| 257 |
+
compatible_settings["enable_free_letters"] = False
|
| 258 |
+
|
| 259 |
+
# Normalize music track paths
|
| 260 |
+
if "background_music" in compatible_settings:
|
| 261 |
+
compatible_settings["background_music"] = _to_relative_music_path(compatible_settings["background_music"])
|
| 262 |
+
if "victory_sound" in compatible_settings:
|
| 263 |
+
compatible_settings["victory_sound"] = _to_relative_music_path(compatible_settings["victory_sound"])
|
| 264 |
+
if "defeat_sound" in compatible_settings:
|
| 265 |
+
compatible_settings["defeat_sound"] = _to_relative_music_path(compatible_settings["defeat_sound"])
|
| 266 |
+
|
| 267 |
+
with open(filepath, "w", encoding="utf-8") as f:
|
| 268 |
+
json.dump(compatible_settings, f, indent=2, ensure_ascii=False)
|
| 269 |
+
|
| 270 |
+
# --- Write to canonical settings.json ---
|
| 271 |
+
canonical_path = os.path.join(SETTINGS_DIR, "settings.json")
|
| 272 |
+
with open(canonical_path, "w", encoding="utf-8") as f:
|
| 273 |
+
json.dump(compatible_settings, f, indent=2, ensure_ascii=False)
|
| 274 |
+
|
| 275 |
+
return filename
|
| 276 |
+
|
| 277 |
+
def load_settings_configuration(filename: str) -> dict:
|
| 278 |
+
"""
|
| 279 |
+
Loads settings from a specific file in wrdler/settings/.
|
| 280 |
+
Maps compatible format back to local session state keys.
|
| 281 |
+
"""
|
| 282 |
+
ensure_settings_dir()
|
| 283 |
+
filepath = os.path.join(SETTINGS_DIR, filename)
|
| 284 |
+
if not os.path.exists(filepath):
|
| 285 |
+
return {}
|
| 286 |
+
|
| 287 |
+
try:
|
| 288 |
+
with open(filepath, "r", encoding="utf-8") as f:
|
| 289 |
+
loaded_settings = json.load(f)
|
| 290 |
+
|
| 291 |
+
# Transform back to local format
|
| 292 |
+
local_settings = loaded_settings.copy()
|
| 293 |
+
|
| 294 |
+
# Map wordlist_source to selected_wordlist
|
| 295 |
+
if "wordlist_source" in loaded_settings:
|
| 296 |
+
local_settings["selected_wordlist"] = loaded_settings["wordlist_source"]
|
| 297 |
+
|
| 298 |
+
# Map puzzle_options to spacer
|
| 299 |
+
if "puzzle_options" in loaded_settings and isinstance(loaded_settings["puzzle_options"], dict):
|
| 300 |
+
local_settings["spacer"] = loaded_settings["puzzle_options"].get("spacer", 1)
|
| 301 |
+
|
| 302 |
+
# Resolve music path for runtime use
|
| 303 |
+
if "music_track_path" in local_settings and local_settings["music_track_path"]:
|
| 304 |
+
local_settings["music_track_path"] = _to_absolute_music_path(local_settings["music_track_path"])
|
| 305 |
+
|
| 306 |
+
return local_settings
|
| 307 |
+
except Exception as e:
|
| 308 |
+
print(f"Error loading settings from {filename}: {e}")
|
| 309 |
+
return {}
|
| 310 |
+
|
| 311 |
+
def list_settings_configurations() -> List[str]:
|
| 312 |
+
"""
|
| 313 |
+
Returns a list of available settings filenames.
|
| 314 |
+
"""
|
| 315 |
+
ensure_settings_dir()
|
| 316 |
+
files = glob.glob(os.path.join(SETTINGS_DIR, "*.json"))
|
| 317 |
+
return sorted([os.path.basename(f) for f in files])
|
| 318 |
+
|
| 319 |
def save_json_to_file(data: dict, directory: str, filename: str = "settings.json") -> str:
|
| 320 |
"""
|
| 321 |
Save a dictionary as a JSON file with a specified filename in the given directory.
|
|
|
|
| 345 |
def create_shareable_url(game_id: str, base_url: Optional[str] = None) -> str:
|
| 346 |
if base_url is None:
|
| 347 |
base_url = "https://huggingface.co/spaces/Surn/Wrdler"
|
| 348 |
+
return f"{base_url}?game_id={game_id}"
|
| 349 |
+
|
| 350 |
+
def load_latest_settings() -> dict:
|
| 351 |
+
"""
|
| 352 |
+
Loads the most recently saved settings from settings/settings.json.
|
| 353 |
+
Returns an empty dict if not found.
|
| 354 |
+
"""
|
| 355 |
+
ensure_settings_dir()
|
| 356 |
+
canonical_path = os.path.join(SETTINGS_DIR, "settings.json")
|
| 357 |
+
if not os.path.exists(canonical_path):
|
| 358 |
+
return {}
|
| 359 |
+
try:
|
| 360 |
+
with open(canonical_path, "r", encoding="utf-8") as f:
|
| 361 |
+
latest = json.load(f)
|
| 362 |
+
if "music_track_path" in latest and latest["music_track_path"]:
|
| 363 |
+
latest["music_track_path"] = _to_absolute_music_path(latest["music_track_path"])
|
| 364 |
+
return latest
|
| 365 |
+
except Exception as e:
|
| 366 |
+
print(f"Error loading latest settings: {e}")
|
| 367 |
+
return {}
|
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"game_mode": "classic",
|
| 3 |
+
"use_ai_wordlist": false,
|
| 4 |
+
"ai_topic": "English",
|
| 5 |
+
"selected_wordlist": "classic.txt",
|
| 6 |
+
"show_grid_ticks": false,
|
| 7 |
+
"spacer": 1,
|
| 8 |
+
"show_incorrect_guesses": true,
|
| 9 |
+
"show_challenge_share_links": true,
|
| 10 |
+
"enable_free_letters": false,
|
| 11 |
+
"music_enabled": false,
|
| 12 |
+
"music_volume": 15,
|
| 13 |
+
"effects_volume": 25,
|
| 14 |
+
"enable_sound_effects": false,
|
| 15 |
+
"music_track_path": "background.mp3",
|
| 16 |
+
"wordlist_source": "classic.txt",
|
| 17 |
+
"puzzle_options": {
|
| 18 |
+
"spacer": 1,
|
| 19 |
+
"may_overlap": false
|
| 20 |
+
}
|
| 21 |
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"game_mode": "classic",
|
| 3 |
+
"use_ai_wordlist": false,
|
| 4 |
+
"ai_topic": "English",
|
| 5 |
+
"selected_wordlist": "classic.txt",
|
| 6 |
+
"show_grid_ticks": false,
|
| 7 |
+
"spacer": 1,
|
| 8 |
+
"show_incorrect_guesses": true,
|
| 9 |
+
"show_challenge_share_links": true,
|
| 10 |
+
"enable_free_letters": false,
|
| 11 |
+
"music_enabled": false,
|
| 12 |
+
"music_volume": 15,
|
| 13 |
+
"effects_volume": 25,
|
| 14 |
+
"enable_sound_effects": false,
|
| 15 |
+
"music_track_path": "background.mp3",
|
| 16 |
+
"wordlist_source": "classic.txt",
|
| 17 |
+
"puzzle_options": {
|
| 18 |
+
"spacer": 1,
|
| 19 |
+
"may_overlap": false
|
| 20 |
+
}
|
| 21 |
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"game_mode": "classic",
|
| 3 |
+
"use_ai_wordlist": false,
|
| 4 |
+
"ai_topic": "English",
|
| 5 |
+
"selected_wordlist": "classic.txt",
|
| 6 |
+
"show_grid_ticks": false,
|
| 7 |
+
"spacer": 1,
|
| 8 |
+
"show_incorrect_guesses": true,
|
| 9 |
+
"show_challenge_share_links": true,
|
| 10 |
+
"enable_free_letters": true,
|
| 11 |
+
"music_enabled": true,
|
| 12 |
+
"music_volume": 15,
|
| 13 |
+
"effects_volume": 25,
|
| 14 |
+
"enable_sound_effects": true,
|
| 15 |
+
"music_track_path": "background.mp3",
|
| 16 |
+
"wordlist_source": "classic.txt",
|
| 17 |
+
"puzzle_options": {
|
| 18 |
+
"spacer": 1,
|
| 19 |
+
"may_overlap": false
|
| 20 |
+
}
|
| 21 |
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"game_mode": "classic",
|
| 3 |
+
"use_ai_wordlist": false,
|
| 4 |
+
"ai_topic": "English",
|
| 5 |
+
"selected_wordlist": "classic.txt",
|
| 6 |
+
"show_grid_ticks": false,
|
| 7 |
+
"spacer": 1,
|
| 8 |
+
"show_incorrect_guesses": true,
|
| 9 |
+
"show_challenge_share_links": true,
|
| 10 |
+
"enable_free_letters": false,
|
| 11 |
+
"music_enabled": false,
|
| 12 |
+
"music_volume": 15,
|
| 13 |
+
"effects_volume": 25,
|
| 14 |
+
"enable_sound_effects": false,
|
| 15 |
+
"music_track_path": "background.mp3",
|
| 16 |
+
"wordlist_source": "classic.txt",
|
| 17 |
+
"puzzle_options": {
|
| 18 |
+
"spacer": 1,
|
| 19 |
+
"may_overlap": false
|
| 20 |
+
}
|
| 21 |
+
}
|
|
@@ -1,10 +1,63 @@
|
|
| 1 |
-
import streamlit as st
|
| 2 |
import os
|
| 3 |
import time
|
| 4 |
from .word_loader import get_wordlist_files, get_wordlist_info
|
| 5 |
from .generator import sort_word_file, filter_word_file
|
| 6 |
from .audio import get_audio_tracks, _inject_audio_control_sync
|
| 7 |
from .version_info import versions_html
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
def _sort_wordlist(filename, new_game_callback):
|
| 10 |
import os
|
|
@@ -64,7 +117,72 @@ def _filter_wordlist(filename):
|
|
| 64 |
st.info(f"No words removed from {filename}.")
|
| 65 |
|
| 66 |
def render_settings_page(new_game_callback):
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
|
| 69 |
st.header("Game Mode")
|
| 70 |
game_modes = ["classic", "easy", "too easy"]
|
|
@@ -343,5 +461,58 @@ def render_settings_page(new_game_callback):
|
|
| 343 |
else:
|
| 344 |
st.caption("Place .mp3 files in wrdler/assets/audio/music to enable music.")
|
| 345 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 346 |
_inject_audio_control_sync()
|
| 347 |
st.markdown(versions_html(), unsafe_allow_html=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
import os
|
| 3 |
import time
|
| 4 |
from .word_loader import get_wordlist_files, get_wordlist_info
|
| 5 |
from .generator import sort_word_file, filter_word_file
|
| 6 |
from .audio import get_audio_tracks, _inject_audio_control_sync
|
| 7 |
from .version_info import versions_html
|
| 8 |
+
from .local_storage import save_settings_configuration, load_settings_configuration, list_settings_configurations, load_latest_settings
|
| 9 |
+
|
| 10 |
+
# Keys that should persist across sessions when saving settings
|
| 11 |
+
_PERSISTED_SETTING_KEYS = (
|
| 12 |
+
"game_mode",
|
| 13 |
+
"use_ai_wordlist",
|
| 14 |
+
"ai_topic",
|
| 15 |
+
"selected_wordlist",
|
| 16 |
+
"show_grid_ticks",
|
| 17 |
+
"spacer",
|
| 18 |
+
"show_incorrect_guesses",
|
| 19 |
+
"show_challenge_share_links",
|
| 20 |
+
"enable_free_letters",
|
| 21 |
+
"music_enabled",
|
| 22 |
+
"music_volume",
|
| 23 |
+
"effects_volume",
|
| 24 |
+
"enable_sound_effects",
|
| 25 |
+
"music_track_path",
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
SETTINGS_DIR = os.path.join(os.path.dirname(__file__), "settings")
|
| 29 |
+
|
| 30 |
+
# Helper to check if a settings file with the same settings already exists
|
| 31 |
+
# Compares only the persisted keys
|
| 32 |
+
import json
|
| 33 |
+
def _settings_file_exists(settings_snapshot):
|
| 34 |
+
files = [f for f in os.listdir(SETTINGS_DIR) if f.endswith('.json') and f != 'settings.json']
|
| 35 |
+
for fname in files:
|
| 36 |
+
fpath = os.path.join(SETTINGS_DIR, fname)
|
| 37 |
+
try:
|
| 38 |
+
with open(fpath, "r", encoding="utf-8") as f:
|
| 39 |
+
data = json.load(f)
|
| 40 |
+
# Compare only the persisted keys
|
| 41 |
+
if all(data.get(k) == settings_snapshot.get(k) for k in _PERSISTED_SETTING_KEYS):
|
| 42 |
+
return fname
|
| 43 |
+
except Exception:
|
| 44 |
+
continue
|
| 45 |
+
return None
|
| 46 |
+
|
| 47 |
+
def _get_next_settings_filename(base_name):
|
| 48 |
+
# base_name like 'classic-classic'
|
| 49 |
+
existing = [f for f in os.listdir(SETTINGS_DIR) if f.startswith(base_name) and f.endswith('.json') and f != 'settings.json']
|
| 50 |
+
nums = []
|
| 51 |
+
for f in existing:
|
| 52 |
+
try:
|
| 53 |
+
n = int(f[len(base_name)+1:-5])
|
| 54 |
+
nums.append(n)
|
| 55 |
+
except Exception:
|
| 56 |
+
continue
|
| 57 |
+
next_num = 0
|
| 58 |
+
while next_num in nums:
|
| 59 |
+
next_num += 1
|
| 60 |
+
return f"{base_name}-{next_num}.json"
|
| 61 |
|
| 62 |
def _sort_wordlist(filename, new_game_callback):
|
| 63 |
import os
|
|
|
|
| 117 |
st.info(f"No words removed from {filename}.")
|
| 118 |
|
| 119 |
def render_settings_page(new_game_callback):
|
| 120 |
+
# --- Always load settings.json on entry and apply to session state ---
|
| 121 |
+
latest_settings = load_latest_settings()
|
| 122 |
+
rerun_needed = False
|
| 123 |
+
if latest_settings:
|
| 124 |
+
for key, value in latest_settings.items():
|
| 125 |
+
if st.session_state.get(key) != value:
|
| 126 |
+
st.session_state[key] = value
|
| 127 |
+
rerun_needed = True
|
| 128 |
+
if rerun_needed:
|
| 129 |
+
st.rerun()
|
| 130 |
+
|
| 131 |
+
st.header("SETTINGS")
|
| 132 |
+
|
| 133 |
+
# Check if we are in a "just saved" state
|
| 134 |
+
apply_pending = st.session_state.get("_settings_apply_pending", False)
|
| 135 |
+
|
| 136 |
+
# Restore previously saved settings
|
| 137 |
+
saved_settings = st.session_state.get("_settings_snapshot")
|
| 138 |
+
if saved_settings:
|
| 139 |
+
for key, value in saved_settings.items():
|
| 140 |
+
# Restore if missing, OR if we just saved (enforce snapshot)
|
| 141 |
+
if key not in st.session_state or apply_pending:
|
| 142 |
+
st.session_state[key] = value
|
| 143 |
+
|
| 144 |
+
# Apply saved settings before rendering widgets to avoid session_state conflicts
|
| 145 |
+
if st.session_state.pop("_settings_apply_pending", False):
|
| 146 |
+
try:
|
| 147 |
+
new_game_callback()
|
| 148 |
+
except Exception as exc:
|
| 149 |
+
st.session_state["_settings_save_error"] = f"Failed to apply settings: {exc}"
|
| 150 |
+
else:
|
| 151 |
+
pass
|
| 152 |
+
# Force a rerun to ensure the UI reflects the applied settings and clears the pending state
|
| 153 |
+
st.rerun()
|
| 154 |
+
|
| 155 |
+
# Show confirmation/error if last save completed
|
| 156 |
+
notice = st.session_state.pop("_settings_saved_notice", None)
|
| 157 |
+
if notice:
|
| 158 |
+
st.success(notice)
|
| 159 |
+
error_msg = st.session_state.pop("_settings_save_error", None)
|
| 160 |
+
if error_msg:
|
| 161 |
+
st.error(error_msg)
|
| 162 |
+
|
| 163 |
+
# --- Load Configuration Section ---
|
| 164 |
+
st.subheader("Load Configuration")
|
| 165 |
+
config_files = list_settings_configurations()
|
| 166 |
+
if config_files:
|
| 167 |
+
# Use a form or just columns to make it look nice
|
| 168 |
+
col_load, col_btn = st.columns([3, 1], vertical_alignment="bottom")
|
| 169 |
+
with col_load:
|
| 170 |
+
selected_config = st.selectbox("Select saved configuration", ["Select..."] + config_files, key="config_loader")
|
| 171 |
+
with col_btn:
|
| 172 |
+
if st.button("Load", disabled=(selected_config == "Select..."), key="load_config_btn"):
|
| 173 |
+
loaded_settings = load_settings_configuration(selected_config)
|
| 174 |
+
if loaded_settings:
|
| 175 |
+
for key, value in loaded_settings.items():
|
| 176 |
+
st.session_state[key] = value
|
| 177 |
+
st.session_state["_settings_snapshot"] = loaded_settings
|
| 178 |
+
st.session_state["_settings_apply_pending"] = True
|
| 179 |
+
st.session_state["_settings_saved_notice"] = f"Loaded configuration: {selected_config}"
|
| 180 |
+
# Overwrite settings.json with the loaded configuration
|
| 181 |
+
save_settings_configuration(loaded_settings)
|
| 182 |
+
st.rerun()
|
| 183 |
+
else:
|
| 184 |
+
st.caption("No saved configurations found.")
|
| 185 |
+
st.markdown("---")
|
| 186 |
|
| 187 |
st.header("Game Mode")
|
| 188 |
game_modes = ["classic", "easy", "too easy"]
|
|
|
|
| 461 |
else:
|
| 462 |
st.caption("Place .mp3 files in wrdler/assets/audio/music to enable music.")
|
| 463 |
|
| 464 |
+
# --- Save Settings button (must be outside any form or column context) ---
|
| 465 |
+
settings_snapshot = {key: st.session_state.get(key) for key in _PERSISTED_SETTING_KEYS}
|
| 466 |
+
if st.button("Save Settings", key="save_settings_btn", help="Apply settings and start a new game with them"):
|
| 467 |
+
# Check if settings file already exists
|
| 468 |
+
existing_file = _settings_file_exists(settings_snapshot)
|
| 469 |
+
if not existing_file:
|
| 470 |
+
# Generate base name (e.g., classic-classic)
|
| 471 |
+
mode = settings_snapshot.get("game_mode", "classic")
|
| 472 |
+
if settings_snapshot.get("use_ai_wordlist"):
|
| 473 |
+
topic = settings_snapshot.get("ai_topic", "English").strip().replace(" ", "_")
|
| 474 |
+
topic = "".join(c for c in topic if c.isalnum() or c in ('-', '_'))
|
| 475 |
+
wordlist_part = f"ai-{topic}"
|
| 476 |
+
else:
|
| 477 |
+
w_file = settings_snapshot.get("selected_wordlist", "classic.txt")
|
| 478 |
+
wordlist_part = os.path.splitext(os.path.basename(w_file))[0]
|
| 479 |
+
base_name = f"{mode}-{wordlist_part}"
|
| 480 |
+
filename = _get_next_settings_filename(base_name)
|
| 481 |
+
# Save to new file and always update settings.json
|
| 482 |
+
save_settings_configuration(settings_snapshot)
|
| 483 |
+
# Rename the just-written file to the new unique filename
|
| 484 |
+
os.rename(os.path.join(SETTINGS_DIR, save_settings_configuration(settings_snapshot)), os.path.join(SETTINGS_DIR, filename))
|
| 485 |
+
st.session_state["_settings_saved_notice"] = f"Settings saved to {filename}!"
|
| 486 |
+
else:
|
| 487 |
+
# Always update settings.json
|
| 488 |
+
save_settings_configuration(settings_snapshot)
|
| 489 |
+
st.session_state["_settings_saved_notice"] = f"Settings already exists as {existing_file}. settings.json updated."
|
| 490 |
+
st.session_state["_settings_snapshot"] = settings_snapshot
|
| 491 |
+
st.session_state["_settings_apply_pending"] = True
|
| 492 |
+
st.session_state.pop("_settings_save_error", None)
|
| 493 |
+
st.rerun()
|
| 494 |
+
|
| 495 |
_inject_audio_control_sync()
|
| 496 |
st.markdown(versions_html(), unsafe_allow_html=True)
|
| 497 |
+
|
| 498 |
+
def generate_settings_filename(settings: dict) -> str:
|
| 499 |
+
"""
|
| 500 |
+
Generates a unique filename based on the main settings.
|
| 501 |
+
If settings match the default/classic config, returns 'classic-classic-0.json'.
|
| 502 |
+
"""
|
| 503 |
+
mode = settings.get("game_mode", "classic")
|
| 504 |
+
wordlist = settings.get("selected_wordlist", "classic.txt")
|
| 505 |
+
spacer = settings.get("spacer", 1)
|
| 506 |
+
# Add other default checks as needed
|
| 507 |
+
|
| 508 |
+
# Check for default/classic config
|
| 509 |
+
if (
|
| 510 |
+
mode == "classic"
|
| 511 |
+
and (wordlist == "classic.txt" or wordlist == "classic")
|
| 512 |
+
and spacer == 1
|
| 513 |
+
# Add other checks if needed
|
| 514 |
+
):
|
| 515 |
+
return "classic-classic-0.json"
|
| 516 |
+
|
| 517 |
+
# Otherwise, use the existing logic
|
| 518 |
+
# ... (existing filename generation code)
|
|
@@ -31,6 +31,7 @@ from .game_storage import load_game_from_sid, save_game_to_hf, get_shareable_url
|
|
| 31 |
from .modules.constants import APP_SETTINGS
|
| 32 |
from .leaderboard import submit_score_to_all_leaderboards
|
| 33 |
from .settings_page import render_settings_page
|
|
|
|
| 34 |
|
| 35 |
st.set_page_config(initial_sidebar_state="collapsed")
|
| 36 |
|
|
@@ -537,7 +538,7 @@ border-radius: 50% !important;
|
|
| 537 |
.bw-grid-row-anchor + div[data-testid="stHorizontalBlock"] { flex-wrap: nowrap !important; overflow-x: auto !important; margin: 2px 0 !important; }
|
| 538 |
.bw-grid-row-anchor + div[data-testid="stHorizontalBlock"] > div[data-testid="column"] { flex: 0 0 auto !important; }
|
| 539 |
.bw-grid-row-anchor { height: 0; margin: 0; padding: 0; }
|
| 540 |
-
.st-emotion-cache-1n6tfoc { background: linear-gradient(-45deg, #
|
| 541 |
.st-emotion-cache-1n6tfoc::before { content: ''; position: absolute; top: 0; left: 0; right: 0; bottom: 0; border-radius: 10px; margin: 5px;}
|
| 542 |
.st-key-guess_input, .st-key-guess_submit { flex-direction: row; display: flex; flex-wrap: wrap; justify-content: flex-start; align-items: flex-end; }
|
| 543 |
.st-key-guess_input [id^="text_input"] {max-width: 80px;}
|
|
@@ -589,7 +590,7 @@ border-radius: 50% !important;
|
|
| 589 |
|
| 590 |
.bold-text { font-weight: 700; }
|
| 591 |
.blue-background { background:#1d64c8; opacity:0.9; }
|
| 592 |
-
.metal-border { position: relative; padding: 20px; background: #333; color: white; border: 4px solid; border-image: linear-gradient(45deg, #a1a1a1, #ffffff, #
|
| 593 |
.shiny-border { position: relative; padding: 12px; background: #333; color: white; border-radius: 1.25rem; overflow: hidden; }
|
| 594 |
.shiny-border::before { content: ''; position: absolute; top: 0; left: -100%; width: 100%; height: 100%; background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4), transparent); transition: left 0.5s; }
|
| 595 |
.bw-score-panel-container { height: 100%; overflow: hidden; text-align:center;}
|
|
@@ -647,6 +648,17 @@ border-radius: 50% !important;
|
|
| 647 |
def _init_session() -> None:
|
| 648 |
if "initialized" in st.session_state and st.session_state.initialized:
|
| 649 |
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 650 |
# --- Preserve music settings ---
|
| 651 |
|
| 652 |
# Check if we're loading a shared game
|
|
@@ -733,6 +745,10 @@ def _init_session() -> None:
|
|
| 733 |
st.session_state.free_letters = set()
|
| 734 |
if "free_letters_used" not in st.session_state:
|
| 735 |
st.session_state.free_letters_used = 0
|
|
|
|
|
|
|
|
|
|
|
|
|
| 736 |
|
| 737 |
# --- Add enable sound effects ---
|
| 738 |
if "enable_sound_effects" not in st.session_state:
|
|
@@ -750,9 +766,12 @@ def _new_game() -> None:
|
|
| 750 |
ai_topic = st.session_state.get("ai_topic", "English")
|
| 751 |
spacer = st.session_state.get("spacer", 1)
|
| 752 |
mode = st.session_state.get("game_mode", "classic")
|
|
|
|
| 753 |
show_grid_ticks = st.session_state.get("show_grid_ticks", False)
|
| 754 |
show_incorrect = st.session_state.get("show_incorrect_guesses", True)
|
| 755 |
-
show_challenge_share_links = st.session_state.get("show_challenge_share_links", True)
|
|
|
|
|
|
|
| 756 |
|
| 757 |
shared_settings = st.session_state.get("shared_game_settings")
|
| 758 |
|
|
@@ -780,11 +799,13 @@ def _new_game() -> None:
|
|
| 780 |
st.session_state.revealed = set()
|
| 781 |
st.session_state.guessed = set()
|
| 782 |
st.session_state.score = 0
|
|
|
|
| 783 |
# Update welcome message based on free letters setting
|
| 784 |
if st.session_state.get("enable_free_letters", False):
|
| 785 |
st.session_state.last_action = "Welcome to Wrdler! Choose 2 free letters to start."
|
| 786 |
else:
|
| 787 |
st.session_state.last_action = "Welcome to Wrdler! Reveal cells and guess the words on each line!"
|
|
|
|
| 788 |
st.session_state.can_guess = False
|
| 789 |
st.session_state.points_by_word = {}
|
| 790 |
st.session_state.letter_map = build_letter_map(puzzle)
|
|
@@ -802,9 +823,12 @@ def _new_game() -> None:
|
|
| 802 |
st.session_state.pop("hide_gameover_overlay", None)
|
| 803 |
|
| 804 |
# Preserve preferences - but do NOT set widget-bound keys like game_mode
|
| 805 |
-
# game_mode is managed by the selectbox widget in _render_sidebar()
|
| 806 |
st.session_state.show_grid_ticks = show_grid_ticks
|
| 807 |
st.session_state.show_incorrect_guesses = show_incorrect
|
|
|
|
|
|
|
|
|
|
|
|
|
| 808 |
st.session_state.initialized = True # Prevent _init_session from overwriting
|
| 809 |
|
| 810 |
# No st.rerun() needed - Streamlit automatically reruns after callback
|
|
@@ -1014,6 +1038,8 @@ def _handle_audio():
|
|
| 1014 |
else:
|
| 1015 |
_mount_background_audio(False, None, 0.0)
|
| 1016 |
|
|
|
|
|
|
|
| 1017 |
# NOTE: Radar/scope visualization functions removed for Wrdler (Sprint 3)
|
| 1018 |
# - get_scope_image() removed
|
| 1019 |
# - _create_radar_scope() removed
|
|
@@ -1541,7 +1567,7 @@ def _render_score_panel(state: GameState):
|
|
| 1541 |
/* Hide empty table by default (until JS updates tbody) */
|
| 1542 |
/* table tr {{ display: none; }} */
|
| 1543 |
</style>
|
| 1544 |
-
<table class='shiny-border' style="background: linear-gradient(-45deg, #a1a1a1, #ffffff, #
|
| 1545 |
{table_inner}
|
| 1546 |
</table>
|
| 1547 |
<script>
|
|
@@ -2283,6 +2309,7 @@ def run_app():
|
|
| 2283 |
|
| 2284 |
# Handle audio globally
|
| 2285 |
_handle_audio()
|
|
|
|
| 2286 |
|
| 2287 |
# Handle page navigation via query params
|
| 2288 |
page = params.get("page", "")
|
|
@@ -2327,7 +2354,7 @@ def run_app():
|
|
| 2327 |
icon="ℹ️"
|
| 2328 |
)
|
| 2329 |
else:
|
| 2330 |
-
st.toast("🎯 Loading shared challenge", icon="
|
| 2331 |
else:
|
| 2332 |
st.warning(f"No shared game found for ID: {sid}. Starting a normal game.")
|
| 2333 |
st.session_state["shared_game_loaded"] = True # Prevent repeated attempts
|
|
|
|
| 31 |
from .modules.constants import APP_SETTINGS
|
| 32 |
from .leaderboard import submit_score_to_all_leaderboards
|
| 33 |
from .settings_page import render_settings_page
|
| 34 |
+
from .local_storage import load_latest_settings
|
| 35 |
|
| 36 |
st.set_page_config(initial_sidebar_state="collapsed")
|
| 37 |
|
|
|
|
| 538 |
.bw-grid-row-anchor + div[data-testid="stHorizontalBlock"] { flex-wrap: nowrap !important; overflow-x: auto !important; margin: 2px 0 !important; }
|
| 539 |
.bw-grid-row-anchor + div[data-testid="stHorizontalBlock"] > div[data-testid="column"] { flex: 0 0 auto !important; }
|
| 540 |
.bw-grid-row-anchor { height: 0; margin: 0; padding: 0; }
|
| 541 |
+
.st-emotion-cache-1n6tfoc { background: linear-gradient(-45deg, #a1a1a1, #c0c0c0, #a1a1a1, #666666) !important; gap: 0.1rem !important; color: white; border-radius:15px; padding: 10px 10px 10px 10px; }
|
| 542 |
.st-emotion-cache-1n6tfoc::before { content: ''; position: absolute; top: 0; left: 0; right: 0; bottom: 0; border-radius: 10px; margin: 5px;}
|
| 543 |
.st-key-guess_input, .st-key-guess_submit { flex-direction: row; display: flex; flex-wrap: wrap; justify-content: flex-start; align-items: flex-end; }
|
| 544 |
.st-key-guess_input [id^="text_input"] {max-width: 80px;}
|
|
|
|
| 590 |
|
| 591 |
.bold-text { font-weight: 700; }
|
| 592 |
.blue-background { background:#1d64c8; opacity:0.9; }
|
| 593 |
+
.metal-border { position: relative; padding: 20px; background: #333; color: white; border: 4px solid; border-image: linear-gradient(45deg, #a1a1a1, #ffffff, #a1a1c1, #666666) 1; border-radius: 8px; }
|
| 594 |
.shiny-border { position: relative; padding: 12px; background: #333; color: white; border-radius: 1.25rem; overflow: hidden; }
|
| 595 |
.shiny-border::before { content: ''; position: absolute; top: 0; left: -100%; width: 100%; height: 100%; background: linear-gradient(90deg, transparent, rgba(255,255,255,0.4), transparent); transition: left 0.5s; }
|
| 596 |
.bw-score-panel-container { height: 100%; overflow: hidden; text-align:center;}
|
|
|
|
| 648 |
def _init_session() -> None:
|
| 649 |
if "initialized" in st.session_state and st.session_state.initialized:
|
| 650 |
return
|
| 651 |
+
|
| 652 |
+
# --- Load most recent settings from settings/settings.json ---
|
| 653 |
+
latest_settings = load_latest_settings()
|
| 654 |
+
if latest_settings:
|
| 655 |
+
# Apply all keys from settings file into session_state before any defaults
|
| 656 |
+
for key, value in latest_settings.items():
|
| 657 |
+
# Avoid clobbering an already-initialized puzzle or game progress
|
| 658 |
+
if key in {"puzzle", "revealed", "guessed", "score", "points_by_word"}:
|
| 659 |
+
continue
|
| 660 |
+
st.session_state[key] = value
|
| 661 |
+
|
| 662 |
# --- Preserve music settings ---
|
| 663 |
|
| 664 |
# Check if we're loading a shared game
|
|
|
|
| 745 |
st.session_state.free_letters = set()
|
| 746 |
if "free_letters_used" not in st.session_state:
|
| 747 |
st.session_state.free_letters_used = 0
|
| 748 |
+
if "enable_free_letters" not in st.session_state:
|
| 749 |
+
st.session_state.enable_free_letters = False
|
| 750 |
+
if "show_grid_ticks" not in st.session_state:
|
| 751 |
+
st.session_state.show_grid_ticks = False
|
| 752 |
|
| 753 |
# --- Add enable sound effects ---
|
| 754 |
if "enable_sound_effects" not in st.session_state:
|
|
|
|
| 766 |
ai_topic = st.session_state.get("ai_topic", "English")
|
| 767 |
spacer = st.session_state.get("spacer", 1)
|
| 768 |
mode = st.session_state.get("game_mode", "classic")
|
| 769 |
+
|
| 770 |
show_grid_ticks = st.session_state.get("show_grid_ticks", False)
|
| 771 |
show_incorrect = st.session_state.get("show_incorrect_guesses", True)
|
| 772 |
+
show_challenge_share_links = st.session_state.get("show_challenge_share_links", True)
|
| 773 |
+
enable_free_letters = st.session_state.get("enable_free_letters", False)
|
| 774 |
+
enable_sound_effects = st.session_state.get("enable_sound_effects", False)
|
| 775 |
|
| 776 |
shared_settings = st.session_state.get("shared_game_settings")
|
| 777 |
|
|
|
|
| 799 |
st.session_state.revealed = set()
|
| 800 |
st.session_state.guessed = set()
|
| 801 |
st.session_state.score = 0
|
| 802 |
+
|
| 803 |
# Update welcome message based on free letters setting
|
| 804 |
if st.session_state.get("enable_free_letters", False):
|
| 805 |
st.session_state.last_action = "Welcome to Wrdler! Choose 2 free letters to start."
|
| 806 |
else:
|
| 807 |
st.session_state.last_action = "Welcome to Wrdler! Reveal cells and guess the words on each line!"
|
| 808 |
+
|
| 809 |
st.session_state.can_guess = False
|
| 810 |
st.session_state.points_by_word = {}
|
| 811 |
st.session_state.letter_map = build_letter_map(puzzle)
|
|
|
|
| 823 |
st.session_state.pop("hide_gameover_overlay", None)
|
| 824 |
|
| 825 |
# Preserve preferences - but do NOT set widget-bound keys like game_mode
|
|
|
|
| 826 |
st.session_state.show_grid_ticks = show_grid_ticks
|
| 827 |
st.session_state.show_incorrect_guesses = show_incorrect
|
| 828 |
+
st.session_state.show_challenge_share_links = show_challenge_share_links
|
| 829 |
+
st.session_state.enable_free_letters = enable_free_letters
|
| 830 |
+
st.session_state.enable_sound_effects = enable_sound_effects
|
| 831 |
+
|
| 832 |
st.session_state.initialized = True # Prevent _init_session from overwriting
|
| 833 |
|
| 834 |
# No st.rerun() needed - Streamlit automatically reruns after callback
|
|
|
|
| 1038 |
else:
|
| 1039 |
_mount_background_audio(False, None, 0.0)
|
| 1040 |
|
| 1041 |
+
_inject_audio_control_sync()
|
| 1042 |
+
|
| 1043 |
# NOTE: Radar/scope visualization functions removed for Wrdler (Sprint 3)
|
| 1044 |
# - get_scope_image() removed
|
| 1045 |
# - _create_radar_scope() removed
|
|
|
|
| 1567 |
/* Hide empty table by default (until JS updates tbody) */
|
| 1568 |
/* table tr {{ display: none; }} */
|
| 1569 |
</style>
|
| 1570 |
+
<table class='shiny-border' style="background: linear-gradient(-45deg, #a1a1a1, #ffffff, #a1a1c1, #666666);">
|
| 1571 |
{table_inner}
|
| 1572 |
</table>
|
| 1573 |
<script>
|
|
|
|
| 2309 |
|
| 2310 |
# Handle audio globally
|
| 2311 |
_handle_audio()
|
| 2312 |
+
_inject_audio_control_sync()
|
| 2313 |
|
| 2314 |
# Handle page navigation via query params
|
| 2315 |
page = params.get("page", "")
|
|
|
|
| 2354 |
icon="ℹ️"
|
| 2355 |
)
|
| 2356 |
else:
|
| 2357 |
+
st.toast("🎯 Loading shared challenge", icon="℉")
|
| 2358 |
else:
|
| 2359 |
st.warning(f"No shared game found for ID: {sid}. Starting a normal game.")
|
| 2360 |
st.session_state["shared_game_loaded"] = True # Prevent repeated attempts
|