Surn commited on
Commit
f0df64e
·
1 Parent(s): a16932a

v0.2.8 Settings Page fixed

Browse files

Add 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 CHANGED
@@ -1,6 +1,6 @@
1
  # CLAUDE
2
 
3
- Wrdler v0.2.7
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.6
16
  **Repository:** https://github.com/Oncorporation/Wrdler.git
17
  **Branch:** AI (working branch)
18
 
19
- ## Current Features (v0.2.6)
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.0
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 (IN PROGRESS)
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 (IN PROGRESS - OAuth protected)
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
 
README.md CHANGED
@@ -21,7 +21,7 @@ thumbnail: >-
21
 
22
  # Wrdler
23
 
24
- Version 0.2.7
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.6
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
- - Wordlist sidebar controls (picker + one-click sort + filter)
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 (IN PROGRESS)
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.6 (Current) ✅
 
 
 
 
 
 
 
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!
pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
  [project]
2
  name = "wrdler"
3
- version = "0.2.7"
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"
specs/leaderboard_spec.md CHANGED
@@ -1,7 +1,7 @@
1
  # Wrdler Leaderboard System Specification
2
 
3
  **Document Version:** 1.4.1
4
- **Project Version:** 0.2.7
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]`
 
 
 
 
 
 
specs/requirements.md CHANGED
@@ -1,12 +1,12 @@
1
  # Wrdler Requirements
2
 
3
- **Version:** 0.2.7
4
  **Status:** Production Ready - Leaderboards Implemented
5
- **Last Updated:** 2025-12-08
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
- - `st.sidebar` for secondary controls
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
- - Sidebar `selectbox` for wordlist selection (classic, fourth_grade, wordlist)
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.1)
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 (IN PROGRESS)
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 (IN PROGRESS)
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
specs/settings.md CHANGED
@@ -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.
specs/specs.md CHANGED
@@ -1,9 +1,9 @@
1
  # Wrdler Specifications
2
 
3
- **Version:** 0.2.7
4
 
5
- **Status:** Production Ready - Leaderboards Implemented
6
- **Last Updated:** 2025-12-08
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-Www}/{file_id}/settings.json
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 (Planned/Upcoming)
167
- - Move all game settings from sidebar to a dedicated settings page (`?page=settings`) requiring a logged in user (OAuth)
168
-
169
- ## Storage
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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
wrdler/__init__.py CHANGED
@@ -9,5 +9,5 @@ Key differences from BattleWords:
9
  - Daily and weekly leaderboards
10
  """
11
 
12
- __version__ = "0.2.7"
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"]
wrdler/local_storage.py CHANGED
@@ -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 {}
wrdler/settings/classic-classic-0.json ADDED
@@ -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
+ }
wrdler/settings/classic-classic-1.json ADDED
@@ -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
+ }
wrdler/settings/classic-classic-2.json ADDED
@@ -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
+ }
wrdler/settings/settings.json ADDED
@@ -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
+ }
wrdler/settings_page.py CHANGED
@@ -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
- st.header("SETTINGS")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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)
wrdler/ui.py CHANGED
@@ -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, #a1a1c1, #1d64c8, #a1a1c1, #666666) !important; gap: 0.1rem !important; color: white; border-radius:15px; padding: 10px 10px 10px 10px; }
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, #a1a1a1, #666666) 1; border-radius: 8px; }
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) # preserve (default 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, #a1a1a1, #666666);">
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