Surn commited on
Commit
8fe479e
·
1 Parent(s): 55f6497

Version 0.2.31: UI/UX, settings, and audio updates

Browse files

- Updated README.md with new Streamlit SDK (1.52.1) and Python (3.12.8).
- Incremented version to 0.2.31 in `__init__.py` and `pyproject.toml`.
- Enhanced `audio.py` with error handling and smoother playback.
- Added `validate_puzzle` and `filter_word_file` functions in `generator.py`.
- Introduced new constants and `load_settings` in `constants.py`.
- Improved settings page with wordlist controls and audio settings.
- Refined UI in `ui.py` with compact layouts and performance optimizations.
- Modularized helpers in `ui_helpers.py` and reintroduced PWA support.
- Updated wordlist in `classic.txt` with the addition of "CLUB".
- Revised documentation in `claude.md` for version 0.2.31.
- Added a new task in `upgrade.md` to remove animated backgrounds.

README.md CHANGED
@@ -4,7 +4,7 @@ emoji: 🎲
4
  colorFrom: blue
5
  colorTo: indigo
6
  sdk: streamlit
7
- sdk_version: 1.51.0
8
  python_version: 3.12.8
9
  app_port: 8501
10
  app_file: app.py
@@ -19,6 +19,9 @@ tags:
19
 
20
  > **This project is used by [huggingface.co](https://huggingface.co/spaces/Surn/BattleWords) as a demonstration of interactive word games in Python.**
21
 
 
 
 
22
  BattleWords is a vocabulary learning game inspired by classic Battleship mechanics. The objective is to discover hidden words on a grid, earning points for strategic guessing before all letters are revealed.
23
 
24
  ## Current Upgrades (Wrdler Improvements)
@@ -66,12 +69,24 @@ The following non-gameplay improvements from Wrdler are being ported to BattleWo
66
 
67
  ### Version & Diagnostics
68
  - ✅ Ported version_info.py to modules
69
- - Footer snippet: app version, git commit, python, streamlit
70
 
71
  ### Dependency/Workflow Alignment
72
  - ⏳ Validate Streamlit version for st.query_params
73
  - ⏳ Align Python/Streamlit pins if needed
74
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  ## Features
76
 
77
  ### Core Gameplay
@@ -244,6 +259,16 @@ CRYPTO_PK= # Reserved for future signing
244
  - Personal high scores sidebar with filtering
245
  - Player statistics tracking (games played, averages, bests)
246
 
 
 
 
 
 
 
 
 
 
 
247
  -0.2.29
248
  - change difficulty calculation
249
  - add test_compare_difficulty_functions
@@ -527,28 +552,7 @@ def generate_sound_effect(effect: str, save_to_assets: bool = False, use_api: st
527
 
528
  Returns:
529
  - File path to the saved sound effect.
530
- """
531
-
532
- # ... [sound generation code] ...
533
-
534
- if save_to_assets:
535
- # Save to effects directory
536
- assets_dir = os.path.join(os.path.dirname(__file__), "assets", "audio", "effects")
537
- os.makedirs(assets_dir, exist_ok=True)
538
- filename = f"{effect}.wav"
539
- path = os.path.join(assets_dir, filename)
540
- with open(path, "wb") as f:
541
- f.write(audio_bytes)
542
- print(f" Saved to: {path}")
543
- else:
544
- # Save to temporary file
545
- with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmpfile:
546
- tmpfile.write(audio_bytes)
547
- path = tmpfile.name
548
- print(f" Saved to: {path}")
549
-
550
- return path
551
- ```
552
 
553
  ## Parameters
554
 
 
4
  colorFrom: blue
5
  colorTo: indigo
6
  sdk: streamlit
7
+ sdk_version: 1.52.1
8
  python_version: 3.12.8
9
  app_port: 8501
10
  app_file: app.py
 
19
 
20
  > **This project is used by [huggingface.co](https://huggingface.co/spaces/Surn/BattleWords) as a demonstration of interactive word games in Python.**
21
 
22
+ **Current Version:** 0.2.31
23
+ **Last Updated:** 2026-01-13
24
+
25
  BattleWords is a vocabulary learning game inspired by classic Battleship mechanics. The objective is to discover hidden words on a grid, earning points for strategic guessing before all letters are revealed.
26
 
27
  ## Current Upgrades (Wrdler Improvements)
 
69
 
70
  ### Version & Diagnostics
71
  - ✅ Ported version_info.py to modules
72
+ - Footer snippet: app version, git commit, python, streamlit
73
 
74
  ### Dependency/Workflow Alignment
75
  - ⏳ Validate Streamlit version for st.query_params
76
  - ⏳ Align Python/Streamlit pins if needed
77
 
78
+ ---
79
+
80
+ ## Summary of Recent Changes (v0.2.31)
81
+ - Updated README.md with new Streamlit SDK (1.52.1) and Python (3.12.8).
82
+ - Incremented version to 0.2.31 in `__init__.py` and `pyproject.toml`.
83
+ - Enhanced `audio.py` with error handling and smoother playback.
84
+ - Added `validate_puzzle` and `filter_word_file` functions in `generator.py`.
85
+ - Introduced new constants and `load_settings` in `constants.py`.
86
+ - Improved settings page with wordlist controls and audio settings.
87
+ - Refined UI in `ui.py` with compact layouts and performance optimizations.
88
+ - Modularized helpers in `ui_helpers.py` and reintroduced PWA support.
89
+
90
  ## Features
91
 
92
  ### Core Gameplay
 
259
  - Personal high scores sidebar with filtering
260
  - Player statistics tracking (games played, averages, bests)
261
 
262
+ -0.2.31
263
+ - Updated README.md with new Streamlit SDK (1.52.1) and Python (3.12.8).
264
+ - Incremented version to 0.2.31 in `__init__.py` and `pyproject.toml`.
265
+ - Enhanced `audio.py` with error handling and smoother playback.
266
+ - Added `validate_puzzle` and `filter_word_file` functions in `generator.py`.
267
+ - Introduced new constants and `load_settings` in `constants.py`.
268
+ - Improved settings page with wordlist controls and audio settings.
269
+ - Refined UI in `ui.py` with compact layouts and performance optimizations.
270
+ - Modularized helpers in `ui_helpers.py` and reintroduced PWA support.
271
+
272
  -0.2.29
273
  - change difficulty calculation
274
  - add test_compare_difficulty_functions
 
552
 
553
  Returns:
554
  - File path to the saved sound effect.
555
+ ```
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
556
 
557
  ## Parameters
558
 
battlewords/__init__.py CHANGED
@@ -1,2 +1,2 @@
1
- __version__ = "0.2.30"
2
  __all__ = ["models", "generator", "logic", "ui", "game_storage", "modules"]
 
1
+ __version__ = "0.2.31"
2
  __all__ = ["models", "generator", "logic", "ui", "game_storage", "modules"]
battlewords/audio.py CHANGED
@@ -1,6 +1,7 @@
1
  import os
2
  from typing import Optional
3
  import streamlit as st
 
4
 
5
  def _get_music_dir() -> str:
6
  return os.path.join(os.path.dirname(__file__), "assets", "audio", "music")
@@ -207,6 +208,7 @@ def play_sound_effect(effect_name: str, volume: float = 0.5) -> None:
207
  # Respect Enable Sound Effects setting from sidebar
208
  try:
209
  if not st.session_state.get("enable_sound_effects", True):
 
210
  return
211
  except Exception:
212
  pass
@@ -214,10 +216,15 @@ def play_sound_effect(effect_name: str, volume: float = 0.5) -> None:
214
  sound_files = get_sound_effect_files()
215
 
216
  if effect_name not in sound_files:
 
217
  return # Sound file doesn't exist, silently skip
218
 
219
  sound_path = sound_files[effect_name]
220
- sound_data_url = _load_audio_data_url(sound_path)
 
 
 
 
221
 
222
  # Clamp volume
223
  vol = max(0.0, min(1.0, float(volume)))
@@ -243,4 +250,5 @@ def play_sound_effect(effect_name: str, volume: float = 0.5) -> None:
243
  </script>
244
  """,
245
  height=0,
246
- )
 
 
1
  import os
2
  from typing import Optional
3
  import streamlit as st
4
+ import time
5
 
6
  def _get_music_dir() -> str:
7
  return os.path.join(os.path.dirname(__file__), "assets", "audio", "music")
 
208
  # Respect Enable Sound Effects setting from sidebar
209
  try:
210
  if not st.session_state.get("enable_sound_effects", True):
211
+ # print(f"[DEBUG] Sound effects disabled; skipping sound effect '{effect_name}'.")
212
  return
213
  except Exception:
214
  pass
 
216
  sound_files = get_sound_effect_files()
217
 
218
  if effect_name not in sound_files:
219
+ print(f"[DEBUG] Sound effect '{effect_name}' not found in available sound files.")
220
  return # Sound file doesn't exist, silently skip
221
 
222
  sound_path = sound_files[effect_name]
223
+ try:
224
+ sound_data_url = _load_audio_data_url(sound_path)
225
+ except Exception as e:
226
+ print(f"[DEBUG] Failed to load audio data for '{effect_name}' at '{sound_path}': {e}")
227
+ return
228
 
229
  # Clamp volume
230
  vol = max(0.0, min(1.0, float(volume)))
 
250
  </script>
251
  """,
252
  height=0,
253
+ )
254
+ time.sleep(0.1)
battlewords/generator.py CHANGED
@@ -173,11 +173,21 @@ def generate_puzzle(
173
  spacer=spacer,
174
  puzzle_id=puzzle_id,
175
  _retry=_retry + 1,
 
176
  )
177
  return puzzle
178
 
179
 
180
  def validate_puzzle(puzzle: Puzzle, grid_size: int = 12) -> None:
 
 
 
 
 
 
 
 
 
181
  # Bounds and overlap checks
182
  seen: set[Coord] = set()
183
  counts: Dict[int, int] = {4: 0, 5: 0, 6: 0}
@@ -220,4 +230,48 @@ def sort_word_file(filepath: str) -> List[str]:
220
  words = [line.strip() for line in lines if line.strip() and not line.strip().startswith("#")]
221
  # Sort by length, then alphabetically
222
  sorted_words = sorted(words, key=lambda w: (len(w), w))
223
- return sorted_words
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
  spacer=spacer,
174
  puzzle_id=puzzle_id,
175
  _retry=_retry + 1,
176
+ target_words=target_words,
177
  )
178
  return puzzle
179
 
180
 
181
  def validate_puzzle(puzzle: Puzzle, grid_size: int = 12) -> None:
182
+ """
183
+ Validate Wrdler puzzle constraints.
184
+
185
+ Checks:
186
+ 1. Exactly 6 words
187
+ 2. All cells within grid bounds
188
+ 3. No overlapping cells
189
+ 4. Word length distribution: exactly 2 four-letter, 2 five-letter, 2 six-letter words
190
+ """
191
  # Bounds and overlap checks
192
  seen: set[Coord] = set()
193
  counts: Dict[int, int] = {4: 0, 5: 0, 6: 0}
 
230
  words = [line.strip() for line in lines if line.strip() and not line.strip().startswith("#")]
231
  # Sort by length, then alphabetically
232
  sorted_words = sorted(words, key=lambda w: (len(w), w))
233
+ return sorted_words
234
+
235
+ def filter_word_file(filter_filepath: str, target_filepath: str) -> tuple[int, List[str]]:
236
+ """
237
+ Removes words found in filter_filepath from target_filepath.
238
+ Overwrites target_filepath with the filtered content.
239
+ Returns (number of words removed, list of removed words).
240
+ """
241
+ # Read filter words
242
+ try:
243
+ with open(filter_filepath, "r", encoding="utf-8") as f:
244
+ filter_words = {
245
+ line.strip().upper()
246
+ for line in f
247
+ if line.strip() and not line.strip().startswith("#")
248
+ }
249
+ except FileNotFoundError:
250
+ return 0, []
251
+
252
+ # Read target file lines
253
+ with open(target_filepath, "r", encoding="utf-8") as f:
254
+ lines = f.readlines()
255
+
256
+ new_lines = []
257
+ removed_words = []
258
+
259
+ for line in lines:
260
+ stripped = line.strip()
261
+ # Preserve comments and empty lines
262
+ if not stripped or stripped.startswith("#"):
263
+ new_lines.append(line)
264
+ continue
265
+
266
+ # Check if word is in filter list
267
+ if stripped.upper() in filter_words:
268
+ removed_words.append(stripped)
269
+ else:
270
+ new_lines.append(line)
271
+
272
+ # Write back if changes were made
273
+ if removed_words:
274
+ with open(target_filepath, "w", encoding="utf-8") as f:
275
+ f.writelines(new_lines)
276
+
277
+ return len(removed_words), removed_words
battlewords/modules/__init__.py CHANGED
@@ -20,17 +20,28 @@ from .storage import (
20
  )
21
 
22
  from .constants import (
 
 
23
  HF_API_TOKEN,
 
24
  HF_REPO_ID,
25
- SHORTENER_JSON_FILE,
26
  SPACE_NAME,
 
 
 
 
27
  TMPDIR,
28
  upload_file_types,
29
  model_extensions,
 
30
  image_extensions,
 
31
  audio_extensions,
 
32
  video_extensions,
33
- doc_extensions
 
 
34
  )
35
 
36
  from .file_utils import (
@@ -59,17 +70,28 @@ __all__ = [
59
  '_list_repo_files_in_folder',
60
 
61
  # constants.py
 
 
62
  'HF_API_TOKEN',
 
63
  'HF_REPO_ID',
64
- 'SHORTENER_JSON_FILE',
65
  'SPACE_NAME',
 
 
 
 
66
  'TMPDIR',
67
  'upload_file_types',
68
  'model_extensions',
 
69
  'image_extensions',
 
70
  'audio_extensions',
 
71
  'video_extensions',
 
72
  'doc_extensions',
 
73
 
74
  # file_utils.py
75
  'get_file_parts',
 
20
  )
21
 
22
  from .constants import (
23
+ APP_SETTINGS,
24
+ AI_MODELS,
25
  HF_API_TOKEN,
26
+ CRYPTO_PK,
27
  HF_REPO_ID,
 
28
  SPACE_NAME,
29
+ SHORTENER_JSON_FILE,
30
+ USE_HF_WORDS,
31
+ HF_WORD_LIST_REPO_ID,
32
+ MAX_DISPLAY_ENTRIES,
33
  TMPDIR,
34
  upload_file_types,
35
  model_extensions,
36
+ model_extensions_list,
37
  image_extensions,
38
+ image_extensions_list,
39
  audio_extensions,
40
+ audio_extensions_list,
41
  video_extensions,
42
+ video_extensions_list,
43
+ doc_extensions,
44
+ doc_extensions_list
45
  )
46
 
47
  from .file_utils import (
 
70
  '_list_repo_files_in_folder',
71
 
72
  # constants.py
73
+ 'APP_SETTINGS',
74
+ 'AI_MODELS',
75
  'HF_API_TOKEN',
76
+ 'CRYPTO_PK',
77
  'HF_REPO_ID',
 
78
  'SPACE_NAME',
79
+ 'SHORTENER_JSON_FILE',
80
+ 'USE_HF_WORDS',
81
+ 'HF_WORD_LIST_REPO_ID',
82
+ 'MAX_DISPLAY_ENTRIES',
83
  'TMPDIR',
84
  'upload_file_types',
85
  'model_extensions',
86
+ 'model_extensions_list',
87
  'image_extensions',
88
+ 'image_extensions_list',
89
  'audio_extensions',
90
+ 'audio_extensions_list',
91
  'video_extensions',
92
+ 'video_extensions_list',
93
  'doc_extensions',
94
+ 'doc_extensions_list',
95
 
96
  # file_utils.py
97
  'get_file_parts',
battlewords/modules/constants.py CHANGED
@@ -4,9 +4,11 @@ Storage-related constants for BattleWords.
4
  Trimmed version of OpenBadge constants - only includes what's needed for storage.py
5
  """
6
  import os
 
7
  import tempfile
8
  import logging
9
  from pathlib import Path
 
10
  from dotenv import load_dotenv
11
 
12
  # Load environment variables from .env file
@@ -21,8 +23,86 @@ CRYPTO_PK = os.getenv("CRYPTO_PK", None)
21
  HF_REPO_ID = os.getenv("HF_REPO_ID", "Surn/Storage")
22
  SPACE_NAME = os.getenv('SPACE_NAME', 'Surn/BattleWords')
23
  SHORTENER_JSON_FILE = "shortener.json"
 
 
 
24
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  # Temporary Directory Configuration
 
 
26
  try:
27
  if os.environ.get('TMPDIR'):
28
  TMPDIR = os.environ['TMPDIR']
 
4
  Trimmed version of OpenBadge constants - only includes what's needed for storage.py
5
  """
6
  import os
7
+ import json
8
  import tempfile
9
  import logging
10
  from pathlib import Path
11
+ from typing import Dict, Any
12
  from dotenv import load_dotenv
13
 
14
  # Load environment variables from .env file
 
23
  HF_REPO_ID = os.getenv("HF_REPO_ID", "Surn/Storage")
24
  SPACE_NAME = os.getenv('SPACE_NAME', 'Surn/BattleWords')
25
  SHORTENER_JSON_FILE = "shortener.json"
26
+ USE_HF_WORDS = os.getenv("USE_HF_WORDS", "false").lower() == "true"
27
+ HF_WORD_LIST_REPO_ID = os.getenv("HF_WORD_LIST_REPO_ID", "ysharma/Chat_with_Meta_llama3_1_8b")
28
+ MAX_DISPLAY_ENTRIES = int(os.getenv("MAX_DISPLAY_ENTRIES", 25))
29
 
30
+ # List of smaller, faster fallback models if the primary one fails
31
+ AI_MODELS = [
32
+ "microsoft/Phi-3-mini-4k-instruct",
33
+ "meta-llama/Llama-3.1-8B-Instruct",
34
+ "google/gemma-2b-it",
35
+ "distilbert/distilgpt2",
36
+ "mistralai/Mistral-7B-Instruct-v0.3",
37
+ "NousResearch/Hermes-2-Pro-Llama-3-8B"
38
+ ]
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Application Settings
42
+ # ---------------------------------------------------------------------------
43
+
44
+ def load_settings() -> Dict[str, Any]:
45
+ """
46
+ Load settings from settings.json file.
47
+
48
+ Looks for settings.json in the project root directory.
49
+ Returns merged defaults with any overrides from the file.
50
+ Environment variables can override specific settings (e.g., GAME_TITLE).
51
+
52
+ Returns:
53
+ Dict containing all application settings
54
+ """
55
+ # Path to settings.json in project root
56
+ settings_path = Path(__file__).parent.parent.parent / "settings.json"
57
+
58
+ default_settings = {
59
+ # Game identity
60
+ "game_title": os.getenv("GAME_TITLE", "Battlewords"),
61
+
62
+ # Display settings
63
+ "show_incorrect_guesses": True,
64
+ "enable_free_letters": False,
65
+ "show_challenge_links": True,
66
+
67
+ # Audio settings
68
+ "sound_effects_enabled": True,
69
+ "sound_effects_volume": 30,
70
+ "music_enabled": False,
71
+ "music_volume": 10,
72
+
73
+ # Game defaults
74
+ "default_wordlist": "classic.txt",
75
+ "default_game_mode": "classic",
76
+
77
+ # Grid configuration (Battlewords: 12x12)
78
+ "grid_rows": 12,
79
+ "grid_cols": 12,
80
+ "words_per_puzzle": 6,
81
+ "free_letters_count": 2,
82
+ "max_incorrect_guesses": 10,
83
+ "max_display_entries": MAX_DISPLAY_ENTRIES,
84
+ }
85
+
86
+ try:
87
+ if settings_path.exists():
88
+ with open(settings_path, "r", encoding="utf-8") as f:
89
+ loaded = json.load(f)
90
+ # Merge with defaults (loaded settings override defaults)
91
+ return {**default_settings, **loaded}
92
+ except Exception:
93
+ pass
94
+
95
+ return default_settings
96
+
97
+
98
+ # Load settings at module level for easy import
99
+ APP_SETTINGS = load_settings()
100
+
101
+
102
+ # ---------------------------------------------------------------------------
103
  # Temporary Directory Configuration
104
+ # ---------------------------------------------------------------------------
105
+
106
  try:
107
  if os.environ.get('TMPDIR'):
108
  TMPDIR = os.environ['TMPDIR']
battlewords/settings_page.py CHANGED
@@ -1,11 +1,17 @@
1
  from __future__ import annotations
2
 
3
  import os
 
4
  from typing import Any, Callable, Dict
5
 
6
  import streamlit as st
7
 
8
  from .local_storage import load_latest_settings, save_active_settings
 
 
 
 
 
9
 
10
 
11
  _PERSISTED_SETTING_KEYS: tuple[str, ...] = (
@@ -40,6 +46,70 @@ def _load_settings_into_session(settings: Dict[str, Any]) -> bool:
40
  return changed
41
 
42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  def render_settings_page(new_game_callback: Callable[[], None]) -> None:
44
  """Render the Settings page.
45
 
@@ -77,6 +147,32 @@ def render_settings_page(new_game_callback: Callable[[], None]) -> None:
77
  key="game_mode",
78
  )
79
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  if "show_grid_ticks" not in st.session_state:
81
  st.session_state.show_grid_ticks = False
82
  st.checkbox("Show grid ticks", key="show_grid_ticks")
@@ -101,7 +197,21 @@ def render_settings_page(new_game_callback: Callable[[], None]) -> None:
101
  st.session_state.show_challenge_share_links = False
102
  st.checkbox("Show challenge share links", key="show_challenge_share_links")
103
 
104
- st.subheader("Audio")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
 
106
  if "enable_sound_effects" not in st.session_state:
107
  st.session_state.enable_sound_effects = True
@@ -118,31 +228,53 @@ def render_settings_page(new_game_callback: Callable[[], None]) -> None:
118
  key="effects_volume",
119
  )
120
 
121
- if "music_enabled" not in st.session_state:
122
- st.session_state.music_enabled = False
123
- st.checkbox("Enable music", key="music_enabled")
 
124
 
125
- if "music_volume" not in st.session_state:
126
- st.session_state.music_volume = 15
127
  st.slider(
128
- "Music volume",
129
  0,
130
  100,
131
  value=int(st.session_state.music_volume),
132
  step=1,
133
  key="music_volume",
134
- disabled=not st.session_state.music_enabled,
135
  )
136
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  st.markdown("---")
138
 
139
  col_apply, col_reset = st.columns(2)
140
 
141
  with col_apply:
142
- if st.button("Save & Apply", key="settings_save_apply", width="stretch"):
143
- snapshot = {
144
- key: st.session_state.get(key) for key in _PERSISTED_SETTING_KEYS
145
- }
146
  save_active_settings(snapshot)
147
  st.session_state["_settings_saved_notice"] = "Settings saved."
148
  new_game_callback()
@@ -153,7 +285,7 @@ def render_settings_page(new_game_callback: Callable[[], None]) -> None:
153
  st.rerun()
154
 
155
  with col_reset:
156
- if st.button("Discard changes", key="settings_discard", width="stretch"):
157
  latest = load_latest_settings()
158
  if latest:
159
  _load_settings_into_session(latest)
@@ -165,3 +297,5 @@ def render_settings_page(new_game_callback: Callable[[], None]) -> None:
165
 
166
  settings_dir = os.path.join(os.path.dirname(__file__), "settings")
167
  st.caption(f"Settings are stored locally under: {settings_dir}")
 
 
 
1
  from __future__ import annotations
2
 
3
  import os
4
+ import time
5
  from typing import Any, Callable, Dict
6
 
7
  import streamlit as st
8
 
9
  from .local_storage import load_latest_settings, save_active_settings
10
+ from battlewords.modules.version_info import versions_html # version info footer
11
+ from .generator import sort_word_file, filter_word_file
12
+ from .audio import get_audio_tracks, _inject_audio_control_sync, get_sound_effect_files
13
+ from .generator import sort_word_file
14
+ from .word_loader import get_wordlist_files
15
 
16
 
17
  _PERSISTED_SETTING_KEYS: tuple[str, ...] = (
 
46
  return changed
47
 
48
 
49
+ def _sort_wordlist(filename: str, new_game_callback: Callable[[], None]) -> None:
50
+
51
+ words_dir = os.path.join(os.path.dirname(__file__), "words")
52
+ filepath = os.path.join(words_dir, filename)
53
+ sorted_words = sort_word_file(filepath)
54
+
55
+ with open(filepath, "w", encoding="utf-8") as f:
56
+ f.write("# Optional: place a large A-Z word list here (one word per line).\n")
57
+ f.write("# The app falls back to built-in pools if fewer than 500 words per length are found.\n")
58
+ for word in sorted_words:
59
+ f.write(f"{word}\n")
60
+
61
+ st.success(f"{filename} sorted by length and alphabetically. Starting new game in 5 seconds...")
62
+ time.sleep(5)
63
+ new_game_callback()
64
+
65
+
66
+ def _filter_wordlist(filename: str) -> None:
67
+ words_dir = os.path.join(os.path.dirname(__file__), "words")
68
+ filepath = os.path.join(words_dir, filename)
69
+
70
+ try:
71
+ with open(filepath, "r", encoding="utf-8") as f:
72
+ lines = f.readlines()
73
+ except Exception as exc:
74
+ st.error(f"Failed to read wordlist: {exc}")
75
+ return
76
+
77
+ header_lines = [ln for ln in lines if ln.strip().startswith("#")]
78
+ word_lines = [ln.strip() for ln in lines if ln.strip() and not ln.strip().startswith("#")]
79
+
80
+ # Keep only A-Z words, uppercase, lengths 4-6; dedupe while preserving order
81
+ kept: list[str] = []
82
+ seen: set[str] = set()
83
+ removed_count = 0
84
+ for raw in word_lines:
85
+ w = raw.strip().upper()
86
+ if not w.isalpha() or not w.isascii():
87
+ removed_count += 1
88
+ continue
89
+ if len(w) not in (4, 5, 6):
90
+ removed_count += 1
91
+ continue
92
+ if w in seen:
93
+ removed_count += 1
94
+ continue
95
+ seen.add(w)
96
+ kept.append(w)
97
+
98
+ with open(filepath, "w", encoding="utf-8") as f:
99
+ if header_lines:
100
+ for ln in header_lines:
101
+ f.write(ln if ln.endswith("\n") else ln + "\n")
102
+ else:
103
+ f.write("# Optional: place a large A-Z word list here (one word per line).\n")
104
+ f.write("# The app falls back to built-in pools if fewer than 500 words per length are found.\n")
105
+ for w in kept:
106
+ f.write(f"{w}\n")
107
+
108
+ st.success(
109
+ f"Filtered wordlist '{filename}': removed {removed_count} invalid/duplicate/out-of-range entries."
110
+ )
111
+
112
+
113
  def render_settings_page(new_game_callback: Callable[[], None]) -> None:
114
  """Render the Settings page.
115
 
 
147
  key="game_mode",
148
  )
149
 
150
+ st.subheader("Wordlist Controls")
151
+
152
+ files = get_wordlist_files()
153
+ if files and "selected_wordlist" not in st.session_state:
154
+ st.session_state.selected_wordlist = "classic.txt" if "classic.txt" in files else files[0]
155
+
156
+ if files:
157
+ st.selectbox(
158
+ "Select wordlist",
159
+ options=files,
160
+ index=files.index(st.session_state.selected_wordlist)
161
+ if st.session_state.selected_wordlist in files
162
+ else 0,
163
+ key="selected_wordlist",
164
+ )
165
+
166
+ col_sort, col_filter = st.columns(2)
167
+ with col_sort:
168
+ if st.button("Sort Wordlist", key="sort_wordlist_btn", width=125):
169
+ _sort_wordlist(st.session_state.selected_wordlist, new_game_callback)
170
+ with col_filter:
171
+ if st.button("Filter Wordlist", key="filter_wordlist_btn", width=125):
172
+ _filter_wordlist(st.session_state.selected_wordlist)
173
+ else:
174
+ st.info("No word lists found in words/ directory.")
175
+
176
  if "show_grid_ticks" not in st.session_state:
177
  st.session_state.show_grid_ticks = False
178
  st.checkbox("Show grid ticks", key="show_grid_ticks")
 
197
  st.session_state.show_challenge_share_links = False
198
  st.checkbox("Show challenge share links", key="show_challenge_share_links")
199
 
200
+ # Audio settings
201
+ st.header("Audio")
202
+
203
+ # --- List sound effects in effects folder ---
204
+ effects = get_sound_effect_files()
205
+ if effects:
206
+ effect_list = ", ".join(sorted(effects.keys()))
207
+ st.caption(f"Sound effects found in wrdler/assets/audio/effects: {effect_list}")
208
+ else:
209
+ st.caption("No sound effects found in wrdler/assets/audio/effects.")
210
+
211
+ if "music_enabled" not in st.session_state:
212
+ st.session_state.music_enabled = False
213
+ if "music_volume" not in st.session_state:
214
+ st.session_state.music_volume = 15
215
 
216
  if "enable_sound_effects" not in st.session_state:
217
  st.session_state.enable_sound_effects = True
 
228
  key="effects_volume",
229
  )
230
 
231
+ tracks = get_audio_tracks()
232
+ st.caption(f"{len(tracks)} audio file{'s' if len(tracks) != 1 else ''} found in wrdler/assets/audio/music")
233
+
234
+ enabled = st.checkbox("Enable music", value=st.session_state.music_enabled, key="music_enabled")
235
 
 
 
236
  st.slider(
237
+ "Volume",
238
  0,
239
  100,
240
  value=int(st.session_state.music_volume),
241
  step=1,
242
  key="music_volume",
243
+ disabled=not (enabled and bool(tracks)),
244
  )
245
 
246
+ selected_path = None
247
+ if tracks:
248
+ options = [p for _, p in tracks]
249
+ # Default to first track if none chosen yet
250
+ if "music_track_path" not in st.session_state or st.session_state.music_track_path not in options:
251
+ st.session_state.music_track_path = options[0]
252
+
253
+ def _fmt(p: str) -> str:
254
+ # Find friendly label for path
255
+ for name, path in tracks:
256
+ if path == p:
257
+ return name
258
+ return os.path.splitext(os.path.basename(p))[0]
259
+
260
+ selected_path = st.selectbox(
261
+ "Track",
262
+ options=options,
263
+ index=options.index(st.session_state.music_track_path),
264
+ format_func=_fmt,
265
+ key="music_track_path",
266
+ disabled=not enabled,
267
+ )
268
+ else:
269
+ st.caption("Place .mp3 files in wrdler/assets/audio/music to enable music.")
270
+
271
  st.markdown("---")
272
 
273
  col_apply, col_reset = st.columns(2)
274
 
275
  with col_apply:
276
+ if st.button("Save & Apply", key="settings_save_apply_btn", width="stretch"):
277
+ snapshot = {key: st.session_state.get(key) for key in _PERSISTED_SETTING_KEYS}
 
 
278
  save_active_settings(snapshot)
279
  st.session_state["_settings_saved_notice"] = "Settings saved."
280
  new_game_callback()
 
285
  st.rerun()
286
 
287
  with col_reset:
288
+ if st.button("Discard changes", key="settings_discard_btn", width="stretch"):
289
  latest = load_latest_settings()
290
  if latest:
291
  _load_settings_into_session(latest)
 
297
 
298
  settings_dir = os.path.join(os.path.dirname(__file__), "settings")
299
  st.caption(f"Settings are stored locally under: {settings_dir}")
300
+
301
+ st.markdown(versions_html(), unsafe_allow_html=True)
battlewords/ui.py CHANGED
@@ -14,11 +14,10 @@ import numpy as np
14
  import time
15
  from datetime import datetime
16
 
17
- from .generator import generate_puzzle, sort_word_file
18
  from .logic import build_letter_map, reveal_cell, guess_word, is_game_over, compute_tier, auto_mark_completed_words, hidden_word_display
19
  from .models import Coord, GameState, Puzzle
20
  from .word_loader import get_wordlist_files, load_word_list, compute_word_difficulties
21
- from battlewords.modules.version_info import versions_html # version info footer
22
  from .audio import (
23
  _get_music_dir,
24
  get_audio_tracks,
@@ -29,143 +28,12 @@ from .audio import (
29
  )
30
  from .game_storage import load_game_from_sid, save_game_to_hf, get_shareable_url, add_user_result_to_game
31
  from .settings_page import render_settings_page
32
- from .ui_helpers import render_footer
33
-
34
- # PWA (Progressive Web App) Support
35
- # Enables installing BattleWords as a native-feeling mobile app
36
- # Note: PWA meta tags are injected into <head> via Docker build (inject-pwa-head.sh)
37
- # This ensures proper PWA detection by browsers
38
- pwa_service_worker = """
39
- <script>
40
- // Register service worker for offline functionality
41
- // Note: Using inline Blob URL to bypass Streamlit's text/plain content-type for .js files
42
- if ('serviceWorker' in navigator) {
43
- window.addEventListener('load', () => {
44
- // Service worker code as string (inline to avoid MIME type issues)
45
- const swCode = `
46
- const CACHE_NAME = 'battlewords-v0.2.29';
47
- const RUNTIME_CACHE = 'battlewords-runtime';
48
-
49
- const PRECACHE_URLS = [
50
- '/',
51
- '/app/static/manifest.json',
52
- '/app/static/icon-192.png',
53
- '/app/static/icon-512.png'
54
- ];
55
-
56
- self.addEventListener('install', event => {
57
- console.log('[ServiceWorker] Installing...');
58
- event.waitUntil(
59
- caches.open(CACHE_NAME)
60
- .then(cache => {
61
- console.log('[ServiceWorker] Precaching app shell');
62
- return cache.addAll(PRECACHE_URLS);
63
- })
64
- .then(() => self.skipWaiting())
65
- );
66
- });
67
-
68
- self.addEventListener('activate', event => {
69
- console.log('[ServiceWorker] Activating...');
70
- event.waitUntil(
71
- caches.keys().then(cacheNames => {
72
- return Promise.all(
73
- cacheNames.map(cacheName => {
74
- if (cacheName !== CACHE_NAME && cacheName !== RUNTIME_CACHE) {
75
- console.log('[ServiceWorker] Deleting old cache:', cacheName);
76
- return caches.delete(cacheName);
77
- }
78
- })
79
- );
80
- }).then(() => self.clients.claim())
81
- );
82
- });
83
-
84
- self.addEventListener('fetch', event => {
85
- if (event.request.method !== 'GET') return;
86
- if (!event.request.url.startsWith('http')) return;
87
-
88
- event.respondWith(
89
- caches.open(RUNTIME_CACHE).then(cache => {
90
- return fetch(event.request)
91
- .then(response => {
92
- if (response.status === 200) {
93
- cache.put(event.request, response.clone());
94
- }
95
- return response;
96
- })
97
- .catch(() => {
98
- return caches.match(event.request).then(cachedResponse => {
99
- if (cachedResponse) {
100
- console.log('[ServiceWorker] Serving from cache:', event.request.url);
101
- return cachedResponse;
102
- }
103
- return new Response('Offline - Please check your connection', {
104
- status: 503,
105
- statusText: 'Service Unavailable',
106
- headers: new Headers({'Content-Type': 'text/plain'})
107
- });
108
- });
109
- });
110
- })
111
- );
112
- });
113
-
114
- self.addEventListener('message', event => {
115
- if (event.data.action === 'skipWaiting') {
116
- self.skipWaiting();
117
- }
118
- });
119
- `;
120
-
121
- // Create Blob URL for service worker
122
- const blob = new Blob([swCode], { type: 'application/javascript' });
123
- const swUrl = URL.createObjectURL(blob);
124
-
125
- navigator.serviceWorker.register(swUrl)
126
- .then(registration => {
127
- console.log('[PWA] Service Worker registered successfully:', registration.scope);
128
-
129
- registration.addEventListener('updatefound', () => {
130
- const newWorker = registration.installing;
131
- newWorker.addEventListener('statechange', () => {
132
- if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
133
- console.log('[PWA] New version available! Refresh to update.');
134
- }
135
- });
136
- });
137
- })
138
- .catch(error => {
139
- console.log('[PWA] Service Worker registration failed:', error);
140
- });
141
- });
142
- }
143
-
144
- // Prompt user to install PWA (for browsers that support it)
145
- let deferredPrompt;
146
- window.addEventListener('beforeinstallprompt', (e) => {
147
- console.log('[PWA] Install prompt available');
148
- e.preventDefault();
149
- deferredPrompt = e;
150
- // Could show custom install button here if desired
151
- });
152
-
153
- // Track when user installs the app
154
- window.addEventListener('appinstalled', () => {
155
- console.log('[PWA] BattleWords installed successfully!');
156
- deferredPrompt = null;
157
- });
158
- </script>
159
- """
160
 
161
  CoordLike = Tuple[int, int]
162
 
163
- def fig_to_pil_rgba(fig):
164
- canvas = FigureCanvas(fig)
165
- canvas.draw()
166
- w, h = fig.canvas.get_width_height()
167
- img = np.frombuffer(canvas.buffer_rgba(), dtype=np.uint8).reshape(h, w, 4)
168
- return Image.fromarray(img, mode="RGBA")
169
 
170
  def _coord_to_xy(c) -> CoordLike:
171
  # Supports dataclass Coord(x, y) or a 2-tuple/list.
@@ -191,308 +59,8 @@ def _build_letter_map(puzzle) -> dict[CoordLike, str]:
191
  letters[xy] = text[i]
192
  return letters
193
 
194
- ocean_background_css = """
195
- <style>
196
- :root {
197
- --water-deep: #0b2a4a;
198
- --water-mid: #0f3968;
199
- --water-lite: #165ba8;
200
- --water-sky: #1d64c8;
201
- --foam: rgba(255,255,255,0.18);
202
- }
203
-
204
- .stAppHeader{
205
- opacity:0.6;
206
- }
207
-
208
- .stApp {
209
- margin: 0;
210
- min-height: 100vh;
211
- overflow: hidden; /* prevent scrollbars from animated layers */
212
- background-attachment: scroll;
213
- position: relative;
214
-
215
- /* Static base gradient */
216
- background-image:
217
- linear-gradient(180deg, var(--water-sky) 0%, var(--water-lite) 35%, var(--water-mid) 70%, var(--water-deep) 100%);
218
- background-size: 100% 100%;
219
- background-position: 50% 50%;
220
-
221
- animation: none !important;
222
- will-change: background-position;
223
- }
224
-
225
- /* Animated overlay waves (under bg layers) */
226
- .stApp::before {
227
- content: "";
228
- position: absolute;
229
- inset: -10% -10%;
230
- z-index: 0;
231
- pointer-events: none;
232
- background:
233
- repeating-linear-gradient(0deg, rgba(255,255,255,0.10) 0 2px, transparent 2px 22px),
234
- repeating-linear-gradient(90deg, rgba(255,255,255,0.06) 0 1px, transparent 1px 18px);
235
- mix-blend-mode: screen;
236
- opacity: 0.10;
237
- /*animation: waveOverlayScroll 16s linear infinite;*/
238
- }
239
- .stIFrame {
240
- margin-bottom:25px;
241
- }
242
-
243
- @keyframes waveOverlayScroll {
244
- 0% { background-position: 0px 0px, 0px 0px; }
245
- 100% { background-position: -800px 0px, 0px -600px; }
246
- }
247
-
248
- /* Keep Streamlit content above background/overlay */
249
- .stApp > div { position: relative; z-index: 5; }
250
-
251
- /* Slower, more subtle animations */
252
- @keyframes oceanHighlight {
253
- 0% { background-position: 50% 0%; }
254
- 50% { background-position: 60% 8%; }
255
- 100% { background-position: 50% 0%; }
256
- }
257
-
258
- @keyframes oceanLong {
259
- 0% { background-position: 0% 50%; }
260
- 100% { background-position: -100% 50%; }
261
- }
262
-
263
- @keyframes oceanMid {
264
- 0% { background-position: 100% 50%; }
265
- 100% { background-position: 200% 50%; }
266
- }
267
-
268
- @keyframes oceanFine {
269
- 0% { background-position: 0% 50%; }
270
- 100% { background-position: 100% 50%; }
271
- }
272
-
273
- /* Reduced motion */
274
- @media (prefers-reduced-motion: reduce) {
275
- .stApp, .stApp::before { animation: none; }
276
- }
277
- </style>
278
- """
279
-
280
- def inject_ocean_layers() -> None:
281
- st.markdown(
282
- """
283
- <style>
284
- .bw-bg-container {
285
- position: fixed; /* fixed to viewport, not stApp */
286
- inset: 0;
287
- z-index: 1; /* below content (z=5) but above ::before (z=0) */
288
- pointer-events: none;
289
- overflow: hidden; /* clip children */
290
- }
291
- .bw-bg-layer {
292
- position: absolute;
293
- inset: 0;
294
- width: 100vw;
295
- height: 100vh;
296
- pointer-events: none;
297
- }
298
- /* Explicit stacking order with slower animations */
299
- .bw-bg-highlight {
300
- z-index: 11;
301
- background: radial-gradient(150% 100% at 50% -20%, rgba(255,255,255,0.10) 0%, transparent 60%);
302
- background-size: 150% 150%; /* reduced from 300% */
303
- /* animation: oceanHighlight 12s ease-in-out infinite; */ /* doubled from 6s */
304
- }
305
- .bw-bg-long {
306
- z-index: 12;
307
- background: repeating-linear-gradient(-6deg, rgba(255,255,255,0.08) 0px, rgba(255,255,255,0.08) 18px, rgba(0,0,0,0.04) 18px, rgba(0,0,0,0.04) 48px);
308
- background-size: 150% 150%; /* reduced from 320% */
309
- /* animation: oceanLong 36s linear infinite; */ /* doubled from 18s */
310
- opacity: 0.2;
311
- }
312
- .bw-bg-mid {
313
- z-index: 13;
314
- background: repeating-linear-gradient(-12deg, rgba(255,255,255,0.10) 0px, rgba(255,255,255,0.10) 10px, rgba(0,0,0,0.05) 10px, rgba(0,0,0,0.05) 26px);
315
- background-size: 150% 150%; /* reduced from 260% */
316
- /* animation: oceanMid 24s linear infinite; */ /* doubled from 12s */
317
- opacity: 0.2;
318
- }
319
- .bw-bg-fine {
320
- z-index: 14;
321
- background: repeating-linear-gradient(-18deg, var(--foam) 0px, var(--foam) 4px, transparent 4px, transparent 12px);
322
- background-size: 120% 120%; /* reduced from 200% */
323
- animation: oceanFine 14s linear infinite; /* doubled from 7s */
324
- opacity: 0.15;
325
- }
326
- </style>
327
- <div class="bw-bg-container">
328
- <div class="bw-bg-layer bw-bg-highlight"></div>
329
- <div class="bw-bg-layer bw-bg-long"></div>
330
- <div class="bw-bg-layer bw-bg-mid"></div>
331
- <div class="bw-bg-layer bw-bg-fine"></div>
332
- </div>
333
- """,
334
- unsafe_allow_html=True,
335
- )
336
-
337
- def inject_styles() -> None:
338
- st.markdown(
339
- """
340
- <style>
341
- /* Center main content and limit width */
342
- # .stApp, body {
343
- # background: rgba(29, 100, 200, 0.5);
344
- # }
345
- .stMainBlockContainer {
346
- max-width: 1100px;
347
- }
348
- .stHeading {
349
- margin-bottom: -1.5rem !important;
350
- margin-top: -1.5rem !important;
351
- # font-size: 1.75rem !important; /* Title */
352
- line-height: 1.1 !important;
353
- }
354
- #reveal-letters-in-cells-then-guess-the-words {
355
- font-size: 16px !important; /* Subheader */
356
- margin-top: .1rem !important;
357
- }
358
- /* Base grid cell visuals */
359
- .bw-row { display: flex; gap: 0.1rem; flex-wrap: nowrap; min-height: 48px;}
360
- .bw-cell {
361
- width: 100%;
362
- gap: 0.1rem;
363
- aspect-ratio: 1 / 1;
364
- line-height: 1.6;
365
- display: flex;
366
- align-items: center;
367
- justify-content: center;
368
- border: 1px solid #1d64c8;
369
- border-radius: 0;
370
- font-weight: 700;
371
- user-select: none;
372
- padding: 0.5rem 0.75rem;
373
- font-size: 1.4rem;
374
- min-height: 2.5rem;
375
- min-width: 1.25em;
376
- transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
377
- background: #1d64c8; /* Base cell color */
378
- color: #FFFFFF; /* Base text color for contrast */
379
- }
380
- /* Found letter cells */
381
- .bw-cell.letter { background: #d7faff; color: #050057; }
382
- /* Optional empty state if ever used */
383
- .bw-cell.empty { background: #3a3a3a; color: #ffffff;}
384
- /* Completed word cells */
385
- .bw-cell.bw-cell-complete { background: #050057 !important; color: #d7faff !important; }
386
-
387
- /* Final score style */
388
- .bw-final-score { color: #1ca41c !important; font-weight: 800; }
389
- .stExpander {z-index: 10;width: 50%;}
390
- div[data-testid="stToastContainer"], div[data-testid="stToast"] {
391
- margin: 0 auto;
392
- }
393
-
394
- /* Make grid buttons square and fill their column */
395
- div[data-testid="stButton"]{
396
- margin: 0 auto;
397
- text-align: center;
398
- }
399
- div[data-testid="stButton"] button { max-width: 100%; aspect-ratio: 1 / 1; border-radius: 0; background: #1d64c8; color: #ffffff; font-weight: 700; padding: 0.5rem 0.75rem; min-height: 2.5rem; min-width: 2.5rem;}
400
- .st-key-new_game_btn, .st-key-sort_wordlist_btn { margin: 0 auto; aspect-ratio: unset; }
401
- .st-key-new_game_btn > div[data-testid="stButton"] button, .st-key-sort_wordlist_btn > div[data-testid="stButton"] button { aspect-ratio: unset; text-align:center; height: auto;}
402
-
403
- div[data-testid="column"], .st-emotion-cache-zh2fnc { width: auto !important; flex: 1 1 auto !important; min-width: 100% !important; max-width: 100% !important; }
404
- .st-emotion-cache-1permvm, .st-emotion-cache-1n6tfoc { gap:0.15rem !important; min-height: 2.5rem; min-width: 2.5rem;}
405
-
406
- .bw-grid-row-anchor + div[data-testid="stHorizontalBlock"] { flex-wrap: nowrap !important; overflow-x: auto !important; margin: 2px 0 !important; }
407
- .bw-grid-row-anchor + div[data-testid="stHorizontalBlock"] > div[data-testid="column"] { flex: 0 0 auto !important; }
408
- .bw-grid-row-anchor { height: 0; margin: 0; padding: 0; }
409
- .st-emotion-cache-1n6tfoc { background: linear-gradient(-45deg, #a1a1a1, #ffffff, #a1a1a1, #666666); gap: 0.1rem !important; color: white; border-radius:15px; padding: 10px 10px 10px 5px; }
410
- .st-emotion-cache-1n6tfoc::before { content: ''; position: absolute; top: 0; left: 0; right: 0; bottom: 0; border-radius: 10px; margin: 5px;}
411
- .st-key-guess_input, .st-key-guess_submit { flex-direction: row; display: flex; flex-wrap: wrap; justify-content: flex-start; align-items: flex-end; }
412
-
413
- /* grid adjustments */
414
- @media (min-width: 560px){
415
- div[data-testid="stButton"] button { max-width: 100%; aspect-ratio: 1 / 1; min-height: calc(100% + 20px) !important;}
416
- .st-key-new_game_btn > div[data-testid="stButton"] button, .st-key-sort_wordlist_btn > div[data-testid="stButton"] button { min-height: calc(100% + 20px) !important;}
417
- .st-emotion-cache-ckafi0 { gap:0.1rem !important; min-height: calc(100% + 20px) !important; aspect-ratio: 1 / 1; width: auto !important; flex: 1 1 auto !important; min-width: 100% !important; max-width: 100% !important;}
418
- /*.st-emotion-cache-1n6tfoc { aspect-ratio: 1 / 1; min-height: calc(100% + 20px) !important;}*/
419
- .st-emotion-cache-1n6tfoc::before { min-height: calc(100% + 20px) !important; }
420
-
421
- }
422
- div[data-testid="stElementToolbarButtonContainer"], button[data-testid="stBaseButton-elementToolbar"], button[data-testid="stBaseButton-elementToolbar"]:hover {
423
- display: none;
424
- }
425
-
426
- /* Mobile styles */
427
- @media (max-width: 640px) {
428
- .bw-cell, div[data-testid="stButton"] button, .st-emotion-cache-1permvm {min-width: 1.5rem; min-height:40px;}
429
- #bw-main-anchor + div[data-testid="stHorizontalBlock"] { flex-direction: column-reverse !important; width: 100% !important; max-width: 100vw !important; }
430
- #bw-main-anchor + div[data-testid="stHorizontalBlock"] > div[data-testid="column"] { width: 100% !important; min-width: 100% !important; max-width: 100% !important; flex: 1 1 100% !important; }
431
- .bw-grid-row-anchor + div[data-testid="stHorizontalBlock"] { flex-wrap: nowrap !important; overflow-x: auto !important; margin: 2px 0 !important; }
432
- .st-emotion-cache-17i4tbh { min-width: calc(8.33333% - 1rem); }
433
- }
434
-
435
- .bold-text { font-weight: 700; }
436
- .blue-background { background:#1d64c8; opacity:0.9; }
437
- .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; }
438
- .shiny-border { position: relative; padding: 12px; background: #333; color: white; border-radius: 1.25rem; overflow: hidden; }
439
- .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; }
440
- .bw-score-panel-container { height: 100%; overflow: hidden; text-align:center;}
441
- .bw-score-panel-container table tbody tr h3 {display: flex;flex-direction: row;justify-content: space-evenly;flex-wrap: wrap;}
442
- .shiny-border:hover::before { left: 100%; }
443
-
444
- .bw-radio-group { display:flex; align-items:flex-start; gap: 10px; flex-flow: row; }
445
- .bw-radio-item { display:flex; flex-direction:column; align-items:center; gap:6px; text-align:center;}
446
- .bw-radio-circle { width: 45px; height: 45px; border-radius: 50%; border: 4px solid; background: rgba(255,255,255,0.06); display: grid; place-items: center; color:#fff; font-weight:700; }
447
- .bw-radio-circle .dot { width: 14px; height: 14px; border-radius: 50%; background:#777; box-shadow: inset 0 0 0 2px rgba(255,255,255,0.25); }
448
- .bw-radio-circle.active.hit { background: linear-gradient(135deg, rgba(0,255,127,0.18), rgba(0,128,64,0.38)); }
449
- .bw-radio-circle.active.hit .dot { background:#20d46c; box-shadow: 0 0 10px rgba(32,212,108,0.85); }
450
- .bw-radio-circle.active.miss { background: linear-gradient(135deg, rgba(255,0,0,0.18), rgba(128,0,0,0.38)); }
451
- .bw-radio-circle.active.miss .dot { background:#ff4b4b; box-shadow: 0 0 10px rgba(255,75,75,0.85); }
452
- .bw-radio-caption { font-size: 0.8rem; color:#fff; opacity:0.85; letter-spacing:0.5px; }
453
- @media (max-width:1000px) and (min-width:641px) {
454
- .bw-radio-group { flex-wrap:wrap; gap: 5px; margin-bottom: 5px;}
455
- .bw-radio-item {margin: 0 auto;}
456
- }
457
- @media (max-width:640px) {
458
- .bw-radio-item { margin:unset;}
459
- }
460
-
461
- /* Make the sidebar scrollable */
462
- section[data-testid="stSidebar"] {
463
- max-height: 100vh;
464
- overflow-y: auto;
465
- overflow-x: hidden;
466
- scrollbar-width: thin;
467
- scrollbar-color: transparent transparent;
468
- opacity:0.75;
469
- }
470
 
471
- .st-emotion-cache-wp60of {
472
- width: 720px;
473
- position: absolute;
474
- max-width:100%;
475
- }
476
- .stImage {max-width:240px;}
477
- [id^="text_input"] {
478
- background-color:#fff;
479
- color:#000;
480
- caret-color:#333;}
481
-
482
- @media (min-width:720px) {
483
- .st-emotion-cache-wp60of {
484
- left: calc(calc(100% - 720px) / 2);
485
- }
486
- }
487
 
488
- /* Helper to absolutely/fixed position Streamlit component wrapper when showing modal */
489
- .bw-component-abs { position: fixed !important; inset: 0 !important; z-index: 99999 !important; width: 100vw !important; height: 100vh !important; margin: 0 !important; padding: 0 !important; }
490
- /* Generic hide utility */
491
- .hide { display: none !important; pointer-events: none !important; }
492
- </style>
493
- """,
494
- unsafe_allow_html=True,
495
- )
496
 
497
  def _init_session() -> None:
498
  if "initialized" in st.session_state and st.session_state.initialized:
@@ -564,6 +132,13 @@ def _init_session() -> None:
564
  # NEW: Initialize Show Challenge Share Links (default OFF)
565
  if "show_challenge_share_links" not in st.session_state:
566
  st.session_state.show_challenge_share_links = False
 
 
 
 
 
 
 
567
 
568
  def _new_game() -> None:
569
  selected = st.session_state.get("selected_wordlist")
@@ -1009,7 +584,7 @@ def _render_grid(state: GameState, letter_map, show_grid_ticks: bool = True):
1009
  .st-emotion-cache-ig7yu6 {
1010
  min-width: calc(30% - 1.5rem);
1011
  }
1012
- .st-emotion-cache-15oaysa, .st-emotion-cache-8ocv8 {
1013
  min-width: calc(8.33333% - 1rem);
1014
  }
1015
  }
@@ -1100,25 +675,6 @@ def _render_grid(state: GameState, letter_map, show_grid_ticks: bool = True):
1100
 
1101
  st.rerun()
1102
 
1103
- def _sort_wordlist(filename):
1104
- import os
1105
- import time # Add this import
1106
-
1107
- WORDS_DIR = os.path.join(os.path.dirname(__file__), "words")
1108
- filepath = os.path.join(WORDS_DIR, filename)
1109
- sorted_words = sort_word_file(filepath)
1110
- # Optionally, write sorted words back to file
1111
- with open(filepath, "w", encoding="utf-8") as f:
1112
- # Re-add header if needed
1113
- f.write("# Optional: place a large A–Z word list here (one word per line).\n")
1114
- f.write("# The app falls back to built-in pools if fewer than 500 words per length are found.\n")
1115
- for word in sorted_words:
1116
- f.write(f"{word}\n")
1117
- # Show a message in Streamlit
1118
- st.success(f"{filename} sorted by length and alphabetically. Starting new game in 5 seconds...")
1119
- time.sleep(5) # 5 second delay before starting new game
1120
- _new_game()
1121
-
1122
  def _render_hit_miss(state: GameState):
1123
  # Determine last reveal outcome from last_action string
1124
  action = (state.last_action or "").strip()
@@ -1162,6 +718,18 @@ def _render_guess_form(state: GameState):
1162
  if "incorrect_guesses" not in st.session_state:
1163
  st.session_state.incorrect_guesses = []
1164
 
 
 
 
 
 
 
 
 
 
 
 
 
1165
  # Prepare tooltip text for native browser tooltip (stack vertically)
1166
  recent_incorrect = st.session_state.incorrect_guesses[-10:]
1167
  if st.session_state.get("show_incorrect_guesses", True) and recent_incorrect:
@@ -1222,6 +790,10 @@ def _render_guess_form(state: GameState):
1222
  font-size: 0.8rem;
1223
  cursor: help;
1224
  }
 
 
 
 
1225
  .st-key-guess_input .stTooltipIcon .stTooltipHoverTarget:hover::after {
1226
  color: #ff9999;
1227
  }
@@ -1229,17 +801,30 @@ def _render_guess_form(state: GameState):
1229
  .stForm { padding-bottom: 30px; }
1230
 
1231
  @media (max-width: 640px) {
1232
- .st-emotion-cache-1xwdq91, .st-emotion-cache-1r70o5b {
1233
- max-width: max-content; min-width:33%;
1234
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
1235
  }
1236
  </style>
1237
  """,
1238
  unsafe_allow_html=True,
1239
  )
1240
 
1241
- with st.form("guess_form", width=300, clear_on_submit=True):
1242
- col1, col2 = st.columns([2, 1], vertical_alignment="bottom")
1243
  with col1:
1244
  guess_text = st.text_input(
1245
  "Your Guess",
@@ -1251,6 +836,8 @@ def _render_guess_form(state: GameState):
1251
  )
1252
  with col2:
1253
  submitted = st.form_submit_button("OK", disabled=not state.can_guess, width=100, key="guess_submit")
 
 
1254
 
1255
  # Show compact list below input if setting is enabled
1256
  #if st.session_state.get("show_incorrect_guesses", True) and recent_incorrect:
@@ -1260,19 +847,21 @@ def _render_guess_form(state: GameState):
1260
  # )
1261
 
1262
  if submitted:
1263
- correct, _ = guess_word(state, guess_text)
1264
- _sync_back(state)
1265
- # Invalidate radar GIF cache if guess changed the set of guessed words
1266
- if correct:
1267
- st.session_state.radar_gif_path = None
1268
- st.session_state.radar_gif_signature = None
1269
- play_sound_effect("correct_guess", volume=(st.session_state.get("effects_volume", 50) / 100))
1270
- else:
1271
- # Update incorrect guesses list - keep only last 10
1272
- st.session_state.incorrect_guesses.append(guess_text)
1273
- st.session_state.incorrect_guesses = st.session_state.incorrect_guesses[-10:]
1274
- play_sound_effect("incorrect_guess", volume=(st.session_state.get("effects_volume", 50) / 100))
1275
- st.rerun()
 
 
1276
 
1277
 
1278
  # -------------------- Score Panel --------------------
@@ -1340,12 +929,14 @@ def _render_score_panel(state: GameState):
1340
  <div class='bw-score-panel-container'>
1341
  <style>
1342
  .bold-text {{ font-weight: 700; }}
1343
- .blue-background {{ background:#1d64c8; opacity:0.9; color:#fff; }}
1344
- .shiny-border {{ position: relative; padding: 12px; background: #333; color: white; border-radius: 1.25rem; overflow: hidden; }}
1345
  .bw-score-panel-container {{ height: 100%; overflow: hidden; text-align:center;}}
1346
  .bw-score-panel-container table tbody tr h3 {{display: flex;flex-direction: row;justify-content: space-evenly;flex-wrap: wrap;}}
 
1347
  table {{ width: 100%; margin: 0 auto; border-collapse: separate; border-spacing: 0; }}
1348
- th, td {{ padding: 6px 8px; }}
 
1349
  </style>
1350
  <table class='shiny-border' style="background: linear-gradient(-45deg, #a1a1a1, #ffffff, #a1a1a1, #666666);">
1351
  {table_inner}
@@ -1820,7 +1411,7 @@ def run_app():
1820
 
1821
  if page == "settings":
1822
  st.markdown(ocean_background_css, unsafe_allow_html=True)
1823
- inject_ocean_layers()
1824
  inject_styles()
1825
  render_settings_page(_on_game_option_change)
1826
  render_footer(current_page="settings")
@@ -1828,7 +1419,7 @@ def run_app():
1828
 
1829
  if page == "leaderboard":
1830
  st.markdown(ocean_background_css, unsafe_allow_html=True)
1831
- inject_ocean_layers()
1832
  inject_styles()
1833
  st.header("Leaderboard")
1834
  st.caption("Leaderboard UI will be added as part of the upgrade checklist.")
@@ -1865,12 +1456,12 @@ def run_app():
1865
 
1866
  _init_session()
1867
  st.markdown(ocean_background_css, unsafe_allow_html=True)
1868
- inject_ocean_layers() # <-- add the animated layers
1869
  _render_header()
1870
 
1871
  state = _to_state()
1872
 
1873
- st.markdown('<div id="bw-main-anchor"></div>', unsafe_allow_html=True)
1874
  left, right = st.columns([3, 2], gap="medium")
1875
  with right:
1876
  _render_radar(
@@ -1882,19 +1473,20 @@ def run_app():
1882
  stagger_radar=False,
1883
  show_ticks=st.session_state.get("show_grid_ticks", False),
1884
  )
1885
- one, two = st.columns([1, 2], gap="medium")
1886
- with one:
1887
- _render_correct_try_again(state)
1888
- with two:
1889
- _render_guess_form(state)
1890
  _render_score_panel(state)
1891
  with left:
 
1892
  _render_grid(
1893
  state,
1894
  st.session_state.letter_map,
1895
  show_grid_ticks=st.session_state.get("show_grid_ticks", True),
1896
  )
1897
- st.button("New Game", width=125, on_click=_new_game, key="new_game_btn")
1898
 
1899
  # End condition (only show overlay if dismissed)
1900
  state = _to_state()
 
14
  import time
15
  from datetime import datetime
16
 
17
+ from .generator import generate_puzzle
18
  from .logic import build_letter_map, reveal_cell, guess_word, is_game_over, compute_tier, auto_mark_completed_words, hidden_word_display
19
  from .models import Coord, GameState, Puzzle
20
  from .word_loader import get_wordlist_files, load_word_list, compute_word_difficulties
 
21
  from .audio import (
22
  _get_music_dir,
23
  get_audio_tracks,
 
28
  )
29
  from .game_storage import load_game_from_sid, save_game_to_hf, get_shareable_url, add_user_result_to_game
30
  from .settings_page import render_settings_page
31
+ from .ui_helpers import render_footer, inject_styles, fig_to_pil_rgba, ocean_background_css, inject_ocean_layers, pwa_service_worker
32
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
 
34
  CoordLike = Tuple[int, int]
35
 
36
+
 
 
 
 
 
37
 
38
  def _coord_to_xy(c) -> CoordLike:
39
  # Supports dataclass Coord(x, y) or a 2-tuple/list.
 
59
  letters[xy] = text[i]
60
  return letters
61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
 
 
 
 
 
 
 
 
 
64
 
65
  def _init_session() -> None:
66
  if "initialized" in st.session_state and st.session_state.initialized:
 
132
  # NEW: Initialize Show Challenge Share Links (default OFF)
133
  if "show_challenge_share_links" not in st.session_state:
134
  st.session_state.show_challenge_share_links = False
135
+
136
+ if "show_grid_ticks" not in st.session_state:
137
+ st.session_state.show_grid_ticks = False
138
+
139
+ # --- Add enable sound effects ---
140
+ if "enable_sound_effects" not in st.session_state:
141
+ st.session_state.enable_sound_effects = False
142
 
143
  def _new_game() -> None:
144
  selected = st.session_state.get("selected_wordlist")
 
584
  .st-emotion-cache-ig7yu6 {
585
  min-width: calc(30% - 1.5rem);
586
  }
587
+ .st-emotion-cache-15oaysa, .st-emotion-cache-8ocv8, .st-emotion-cache-1yoilpv {
588
  min-width: calc(8.33333% - 1rem);
589
  }
590
  }
 
675
 
676
  st.rerun()
677
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
678
  def _render_hit_miss(state: GameState):
679
  # Determine last reveal outcome from last_action string
680
  action = (state.last_action or "").strip()
 
718
  if "incorrect_guesses" not in st.session_state:
719
  st.session_state.incorrect_guesses = []
720
 
721
+ # Enable guessing after correct guess or letter reveal
722
+ action = (state.last_action or "").strip()
723
+ if action.startswith("Correct!") or action.startswith("Revealed '"):
724
+ st.session_state.can_guess = True
725
+ else:
726
+ if state.game_mode in ("easy", "too easy"):
727
+ if action.startswith("Revealed '") or action.startswith("Revealed empty"):
728
+ st.session_state.can_guess = True
729
+ else:
730
+ if action.startswith("Revealed '"):
731
+ st.session_state.can_guess = True
732
+
733
  # Prepare tooltip text for native browser tooltip (stack vertically)
734
  recent_incorrect = st.session_state.incorrect_guesses[-10:]
735
  if st.session_state.get("show_incorrect_guesses", True) and recent_incorrect:
 
790
  font-size: 0.8rem;
791
  cursor: help;
792
  }
793
+ .st-key-guess_input .stTooltipIcon .stTooltipHoverTarget:hover::after {
794
+ font-size: 0.8rem;
795
+ cursor: help;
796
+ }
797
  .st-key-guess_input .stTooltipIcon .stTooltipHoverTarget:hover::after {
798
  color: #ff9999;
799
  }
 
801
  .stForm { padding-bottom: 30px; }
802
 
803
  @media (max-width: 640px) {
804
+ .st-emotion-cache-1xwdq91, .st-emotion-cache-1r70o5, {
805
+ max-width: max-content; min-width:33%;
806
+ }
807
+ .st-emotion-cache-emqb34, .st-emotion-cache-192m4g1 {
808
+ min-width: calc(50% - 1.5rem);
809
+ width: calc(50% - 1.5rem);
810
+ flex:unset;
811
+ }
812
+ .st-emotion-cache-1c94k11, .st-emotion-cache-abr9nb {
813
+ min-width: calc(50% - 1.5rem);
814
+ width: calc(50% - 1.5rem);
815
+ flex:unset;
816
+ }
817
+ .st-emotion-cache-bw9c3d, .st-emotion-cache-1w9nhvb, .st-emotion-cache-bah2wz, .st-emotion-cache-tuze3w, .st-emotion-cache-nwgyir, .st-emotion-cache-1pzr114 {
818
+ min-width: unset;
819
+ }
820
  }
821
  </style>
822
  """,
823
  unsafe_allow_html=True,
824
  )
825
 
826
+ with st.form("guess_form", width="stretch", clear_on_submit=True):
827
+ col1, col2, col3 = st.columns([0.4, 0.25, 0.35], vertical_alignment="bottom")
828
  with col1:
829
  guess_text = st.text_input(
830
  "Your Guess",
 
836
  )
837
  with col2:
838
  submitted = st.form_submit_button("OK", disabled=not state.can_guess, width=100, key="guess_submit")
839
+ with col3:
840
+ _render_correct_try_again(state)
841
 
842
  # Show compact list below input if setting is enabled
843
  #if st.session_state.get("show_incorrect_guesses", True) and recent_incorrect:
 
847
  # )
848
 
849
  if submitted:
850
+ # Only process guesses with at least 4 characters (prevent blank/short submissions)
851
+ if len(guess_text.strip()) >= 4:
852
+ correct, _ = guess_word(state, guess_text)
853
+ _sync_back(state)
854
+ # Invalidate radar GIF cache if guess changed the set of guessed words
855
+ if correct:
856
+ st.session_state.radar_gif_path = None
857
+ st.session_state.radar_gif_signature = None
858
+ play_sound_effect("correct_guess", volume=(st.session_state.get("effects_volume", 50) / 100))
859
+ else:
860
+ # Update incorrect guesses list - keep only last 10
861
+ st.session_state.incorrect_guesses.append(guess_text)
862
+ st.session_state.incorrect_guesses = st.session_state.incorrect_guesses[-10:]
863
+ play_sound_effect("incorrect_guess", volume=(st.session_state.get("effects_volume", 50) / 100))
864
+ st.rerun()
865
 
866
 
867
  # -------------------- Score Panel --------------------
 
929
  <div class='bw-score-panel-container'>
930
  <style>
931
  .bold-text {{ font-weight: 700; }}
932
+ .blue-background {{ background:#1d64c8dd; opacity:0.9; color:#fff; }}
933
+ .shiny-border {{ position: relative; padding: 10px; background: #333; color: white; border-radius: 1.25rem; overflow: hidden; }}
934
  .bw-score-panel-container {{ height: 100%; overflow: hidden; text-align:center;}}
935
  .bw-score-panel-container table tbody tr h3 {{display: flex;flex-direction: row;justify-content: space-evenly;flex-wrap: wrap;}}
936
+
937
  table {{ width: 100%; margin: 0 auto; border-collapse: separate; border-spacing: 0; }}
938
+ th, td {{ padding: 3px 4px; }}
939
+
940
  </style>
941
  <table class='shiny-border' style="background: linear-gradient(-45deg, #a1a1a1, #ffffff, #a1a1a1, #666666);">
942
  {table_inner}
 
1411
 
1412
  if page == "settings":
1413
  st.markdown(ocean_background_css, unsafe_allow_html=True)
1414
+ #inject_ocean_layers()
1415
  inject_styles()
1416
  render_settings_page(_on_game_option_change)
1417
  render_footer(current_page="settings")
 
1419
 
1420
  if page == "leaderboard":
1421
  st.markdown(ocean_background_css, unsafe_allow_html=True)
1422
+ #inject_ocean_layers()
1423
  inject_styles()
1424
  st.header("Leaderboard")
1425
  st.caption("Leaderboard UI will be added as part of the upgrade checklist.")
 
1456
 
1457
  _init_session()
1458
  st.markdown(ocean_background_css, unsafe_allow_html=True)
1459
+ #inject_ocean_layers() # <-- add the animated layers
1460
  _render_header()
1461
 
1462
  state = _to_state()
1463
 
1464
+ st.markdown('<div id="bw-main-anchor"></div>', unsafe_allow_html=True)
1465
  left, right = st.columns([3, 2], gap="medium")
1466
  with right:
1467
  _render_radar(
 
1473
  stagger_radar=False,
1474
  show_ticks=st.session_state.get("show_grid_ticks", False),
1475
  )
1476
+ # one, two = st.columns([1, 2], gap="medium")
1477
+ # with one:
1478
+ # _render_correct_try_again(state)
1479
+ # with two:
1480
+ _render_guess_form(state)
1481
  _render_score_panel(state)
1482
  with left:
1483
+ st.button("New Game", width=125, on_click=_new_game, key="new_game_btn")
1484
  _render_grid(
1485
  state,
1486
  st.session_state.letter_map,
1487
  show_grid_ticks=st.session_state.get("show_grid_ticks", True),
1488
  )
1489
+
1490
 
1491
  # End condition (only show overlay if dismissed)
1492
  state = _to_state()
battlewords/ui_helpers.py CHANGED
@@ -3,8 +3,477 @@ from __future__ import annotations
3
  from typing import Optional
4
 
5
  import streamlit as st
 
 
 
 
 
 
 
6
 
 
 
 
 
 
 
7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  def render_footer(current_page: str = "play") -> None:
9
  """Render footer navigation.
10
 
@@ -88,3 +557,52 @@ def render_footer(current_page: str = "play") -> None:
88
  """,
89
  unsafe_allow_html=True,
90
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  from typing import Optional
4
 
5
  import streamlit as st
6
+ import numpy as np
7
+ from PIL import Image
8
+ from matplotlib.backends.backend_agg import FigureCanvasAgg as FigureCanvas
9
+ from . import __version__ as version
10
+ from .modules.constants import APP_SETTINGS
11
+ import base64
12
+ import os
13
 
14
+ def fig_to_pil_rgba(fig):
15
+ canvas = FigureCanvas(fig)
16
+ canvas.draw()
17
+ w, h = fig.canvas.get_width_height()
18
+ img = np.frombuffer(canvas.buffer_rgba(), dtype=np.uint8).reshape(h, w, 4)
19
+ return Image.fromarray(img, mode="RGBA")
20
 
21
+ def get_effective_game_title() -> str:
22
+ """
23
+ Get the effective game title, prioritizing:
24
+ 1. Challenge-specific game_title from shared_game_settings
25
+ 2. Session state game_title (if set)
26
+ 3. APP_SETTINGS default
27
+ 4. Fallback to "Wrdler"
28
+ Returns:
29
+ str: The effective game title.
30
+ """
31
+ # First check shared game settings (challenge mode)
32
+ shared_settings = st.session_state.get("shared_game_settings")
33
+ if shared_settings and shared_settings.get("game_title"):
34
+ return shared_settings["game_title"]
35
+
36
+ # Then check session state
37
+ if st.session_state.get("game_title"):
38
+ return st.session_state["game_title"]
39
+
40
+ # Fall back to APP_SETTINGS
41
+ return APP_SETTINGS.get("game_title", "Battlewords")
42
+
43
+ # PWA (Progressive Web App) Support
44
+ # Enables installing BattleWords as a native-feeling mobile app
45
+ # Note: PWA meta tags are injected into <head> via Docker build (inject-pwa-head.sh)
46
+ # This ensures proper PWA detection by browsers
47
+ pwa_service_worker = """
48
+ <script>
49
+ // Register service worker for offline functionality
50
+ // Note: Using inline Blob URL to bypass Streamlit's text/plain content-type for .js files
51
+ if ('serviceWorker' in navigator) {
52
+ window.addEventListener('load', () => {
53
+ // Service worker code as string (inline to avoid MIME type issues)
54
+ const swCode = `
55
+ const CACHE_NAME = 'battlewords-v0.2.29';
56
+ const RUNTIME_CACHE = 'battlewords-runtime';
57
+
58
+ const PRECACHE_URLS = [
59
+ '/',
60
+ '/app/static/manifest.json',
61
+ '/app/static/icon-192.png',
62
+ '/app/static/icon-512.png'
63
+ ];
64
+
65
+ self.addEventListener('install', event => {
66
+ console.log('[ServiceWorker] Installing...');
67
+ event.waitUntil(
68
+ caches.open(CACHE_NAME)
69
+ .then(cache => {
70
+ console.log('[ServiceWorker] Precaching app shell');
71
+ return cache.addAll(PRECACHE_URLS);
72
+ })
73
+ .then(() => self.skipWaiting())
74
+ );
75
+ });
76
+
77
+ self.addEventListener('activate', event => {
78
+ console.log('[ServiceWorker] Activating...');
79
+ event.waitUntil(
80
+ caches.keys().then(cacheNames => {
81
+ return Promise.all(
82
+ cacheNames.map(cacheName => {
83
+ if (cacheName !== CACHE_NAME && cacheName !== RUNTIME_CACHE) {
84
+ console.log('[ServiceWorker] Deleting old cache:', cacheName);
85
+ return caches.delete(cacheName);
86
+ }
87
+ })
88
+ );
89
+ }).then(() => self.clients.claim())
90
+ );
91
+ });
92
+
93
+ self.addEventListener('fetch', event => {
94
+ if (event.request.method !== 'GET') return;
95
+ if (!event.request.url.startsWith('http')) return;
96
+
97
+ event.respondWith(
98
+ caches.open(RUNTIME_CACHE).then(cache => {
99
+ return fetch(event.request)
100
+ .then(response => {
101
+ if (response.status === 200) {
102
+ cache.put(event.request, response.clone());
103
+ }
104
+ return response;
105
+ })
106
+ .catch(() => {
107
+ return caches.match(event.request).then(cachedResponse => {
108
+ if (cachedResponse) {
109
+ console.log('[ServiceWorker] Serving from cache:', event.request.url);
110
+ return cachedResponse;
111
+ }
112
+ return new Response('Offline - Please check your connection', {
113
+ status: 503,
114
+ statusText: 'Service Unavailable',
115
+ headers: new Headers({'Content-Type': 'text/plain'})
116
+ });
117
+ });
118
+ });
119
+ })
120
+ );
121
+ });
122
+
123
+ self.addEventListener('message', event => {
124
+ if (event.data.action === 'skipWaiting') {
125
+ self.skipWaiting();
126
+ }
127
+ });
128
+ `;
129
+
130
+ // Create Blob URL for service worker
131
+ const blob = new Blob([swCode], { type: 'application/javascript' });
132
+ const swUrl = URL.createObjectURL(blob);
133
+
134
+ navigator.serviceWorker.register(swUrl)
135
+ .then(registration => {
136
+ console.log('[PWA] Service Worker registered successfully:', registration.scope);
137
+
138
+ registration.addEventListener('updatefound', () => {
139
+ const newWorker = registration.installing;
140
+ newWorker.addEventListener('statechange', () => {
141
+ if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
142
+ console.log('[PWA] New version available! Refresh to update.');
143
+ }
144
+ });
145
+ });
146
+ })
147
+ .catch(error => {
148
+ console.log('[PWA] Service Worker registration failed:', error);
149
+ });
150
+ });
151
+ }
152
+
153
+ // Prompt user to install PWA (for browsers that support it)
154
+ let deferredPrompt;
155
+ window.addEventListener('beforeinstallprompt', (e) => {
156
+ console.log('[PWA] Install prompt available');
157
+ e.preventDefault();
158
+ deferredPrompt = e;
159
+ // Could show custom install button here if desired
160
+ });
161
+
162
+ // Track when user installs the app
163
+ window.addEventListener('appinstalled', () => {
164
+ console.log('[PWA] BattleWords installed successfully!');
165
+ deferredPrompt = null;
166
+ });
167
+ </script>
168
+ """
169
+
170
+
171
+ ocean_background_css = """
172
+ <style>
173
+ :root {
174
+ --water-deep: #0b2a4a;
175
+ --water-mid: #0f3968;
176
+ --water-lite: #165ba8;
177
+ --water-sky: #1d64c8;
178
+ --foam: rgba(255,255,255,0.18);
179
+ }
180
+
181
+ .stAppHeader{
182
+ opacity:0.6;
183
+ }
184
+
185
+ .stApp {
186
+ margin: 0;
187
+ min-height: 100vh;
188
+ overflow: hidden; /* prevent scrollbars from animated layers */
189
+ background-attachment: scroll;
190
+ position: relative;
191
+
192
+ /* Static base gradient */
193
+ background-image:
194
+ linear-gradient(180deg, var(--water-sky) 0%, var(--water-lite) 35%, var(--water-mid) 70%, var(--water-deep) 100%);
195
+ background-size: 100% 100%;
196
+ background-position: 50% 50%;
197
+
198
+ animation: none !important;
199
+ will-change: background-position;
200
+ }
201
+
202
+ /* Animated overlay waves (under bg layers) */
203
+ .stApp::before {
204
+ content: "";
205
+ position: absolute;
206
+ inset: -10% -10%;
207
+ z-index: 0;
208
+ pointer-events: none;
209
+ background:
210
+ repeating-linear-gradient(0deg, rgba(255,255,255,0.10) 0 2px, transparent 2px 22px),
211
+ repeating-linear-gradient(90deg, rgba(255,255,255,0.06) 0 1px, transparent 1px 18px);
212
+ mix-blend-mode: screen;
213
+ opacity: 0.10;
214
+ /*animation: waveOverlayScroll 16s linear infinite;*/
215
+ }
216
+ .stIFrame {
217
+ margin-bottom:25px;
218
+ }
219
+
220
+ @keyframes waveOverlayScroll {
221
+ 0% { background-position: 0px 0px, 0px 0px; }
222
+ 100% { background-position: -800px 0px, 0px -600px; }
223
+ }
224
+
225
+ /* Keep Streamlit content above background/overlay */
226
+ .stApp > div { position: relative; z-index: 5; }
227
+
228
+ /* Slower, more subtle animations */
229
+ @keyframes oceanHighlight {
230
+ 0% { background-position: 50% 0%; }
231
+ 50% { background-position: 60% 8%; }
232
+ 100% { background-position: 50% 0%; }
233
+ }
234
+
235
+ @keyframes oceanLong {
236
+ 0% { background-position: 0% 50%; }
237
+ 100% { background-position: -100% 50%; }
238
+ }
239
+
240
+ @keyframes oceanMid {
241
+ 0% { background-position: 100% 50%; }
242
+ 100% { background-position: 200% 50%; }
243
+ }
244
+
245
+ @keyframes oceanFine {
246
+ 0% { background-position: 0% 50%; }
247
+ 100% { background-position: 100% 50%; }
248
+ }
249
+
250
+ /* Reduced motion */
251
+ @media (prefers-reduced-motion: reduce) {
252
+ .stApp, .stApp::before { animation: none; }
253
+ }
254
+ </style>
255
+ """
256
+
257
+ def inject_ocean_layers() -> None:
258
+ st.markdown(
259
+ """
260
+ <style>
261
+ .bw-bg-container {
262
+ position: fixed; /* fixed to viewport, not stApp */
263
+ inset: 0;
264
+ z-index: 1; /* below content (z=5) but above ::before (z=0) */
265
+ pointer-events: none;
266
+ overflow: hidden; /* clip children */
267
+ }
268
+ .bw-bg-layer {
269
+ position: absolute;
270
+ inset: 0;
271
+ width: 100vw;
272
+ height: 100vh;
273
+ pointer-events: none;
274
+ }
275
+ /* Explicit stacking order with slower animations */
276
+ .bw-bg-highlight {
277
+ z-index: 11;
278
+ background: radial-gradient(150% 100% at 50% -20%, rgba(255,255,255,0.10) 0%, transparent 60%);
279
+ background-size: 150% 150%; /* reduced from 300% */
280
+ /* animation: oceanHighlight 12s ease-in-out infinite; */ /* doubled from 6s */
281
+ }
282
+ .bw-bg-long {
283
+ z-index: 12;
284
+ background: repeating-linear-gradient(-6deg, rgba(255,255,255,0.08) 0px, rgba(255,255,255,0.08) 18px, rgba(0,0,0,0.04) 18px, rgba(0,0,0,0.04) 48px);
285
+ background-size: 150% 150%; /* reduced from 320% */
286
+ /* animation: oceanLong 36s linear infinite; */ /* doubled from 18s */
287
+ opacity: 0.2;
288
+ }
289
+ .bw-bg-mid {
290
+ z-index: 13;
291
+ background: repeating-linear-gradient(-12deg, rgba(255,255,255,0.10) 0px, rgba(255,255,255,0.10) 10px, rgba(0,0,0,0.05) 10px, rgba(0,0,0,0.05) 26px);
292
+ background-size: 150% 150%; /* reduced from 260% */
293
+ /* animation: oceanMid 24s linear infinite; */ /* doubled from 12s */
294
+ opacity: 0.2;
295
+ }
296
+ .bw-bg-fine {
297
+ z-index: 14;
298
+ background: repeating-linear-gradient(-18deg, var(--foam) 0px, var(--foam) 4px, transparent 4px, transparent 12px);
299
+ background-size: 120% 120%; /* reduced from 200% */
300
+ animation: oceanFine 14s linear infinite; /* doubled from 7s */
301
+ opacity: 0.15;
302
+ }
303
+ </style>
304
+ <div class="bw-bg-container">
305
+ <div class="bw-bg-layer bw-bg-highlight"></div>
306
+ <div class="bw-bg-layer bw-bg-long"></div>
307
+ <div class="bw-bg-layer bw-bg-mid"></div>
308
+ <div class="bw-bg-layer bw-bg-fine"></div>
309
+ </div>
310
+ """,
311
+ unsafe_allow_html=True,
312
+ )
313
+
314
+
315
+ def inject_styles() -> None:
316
+ st.markdown(
317
+ """
318
+ <style>
319
+ /* Center main content and limit width */
320
+ # .stApp, body {
321
+ # background: rgba(29, 100, 200, 0.5);
322
+ # }
323
+ .stMainBlockContainer {
324
+ max-width: 1100px;
325
+ }
326
+ .stHeading {
327
+ margin-bottom: -1.5rem !important;
328
+ margin-top: -1.5rem !important;
329
+ # font-size: 1.75rem !important; /* Title */
330
+ line-height: 1.1 !important;
331
+ }
332
+ #reveal-letters-in-cells-then-guess-the-words {
333
+ font-size: 16px !important; /* Subheader */
334
+ margin-top: .1rem !important;
335
+ }
336
+ /* Base grid cell visuals */
337
+ .bw-row { display: flex; gap: 0.1rem; flex-wrap: nowrap; min-height: 48px;}
338
+ .bw-cell {
339
+ width: 100%;
340
+ gap: 0.1rem;
341
+ aspect-ratio: 1 / 1;
342
+ line-height: 1.6;
343
+ display: flex;
344
+ align-items: center;
345
+ justify-content: center;
346
+ border: 1px solid #1d64c8;
347
+ border-radius: 0;
348
+ font-weight: 700;
349
+ user-select: none;
350
+ padding: 0.5rem 0.75rem;
351
+ font-size: 1.4rem;
352
+ min-height: 2.5rem;
353
+ min-width: 1.25em;
354
+ transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
355
+ background: #1d64c8; /* Base cell color */
356
+ color: #FFFFFF; /* Base text color for contrast */
357
+ }
358
+ /* Found letter cells */
359
+ .bw-cell.letter { background: #d7faff; color: #050057; }
360
+ /* Optional empty state if ever used */
361
+ .bw-cell.empty { background: #3a3a3a; color: #ffffff;}
362
+ /* Completed word cells */
363
+ .bw-cell.bw-cell-complete { background: #050057 !important; color: #d7faff !important; }
364
+
365
+ /* Final score style */
366
+ .bw-final-score { color: #1ca41c !important; font-weight: 800; }
367
+ .stExpander {z-index: 10;width: 50%;}
368
+ div[data-testid="stToastContainer"], div[data-testid="stToast"] {
369
+ margin: 0 auto;
370
+ }
371
+
372
+ /* Make grid buttons square and fill their column */
373
+ div[data-testid="stButton"]{
374
+ margin: 0 auto;
375
+ text-align: center;
376
+ }
377
+ div[data-testid="stButton"] button { max-width: 100%; aspect-ratio: 16 / 11; min-height: 1.75rem; display: flex;}
378
+ .st-key-new_game_btn > div[data-testid="stButton"] button, .st-key-sort_wordlist_btn > div[data-testid="stButton"] button, .st-key-filter_wordlist_btn > div[data-testid="stButton"] button { min-height: calc(100% + 20px) !important;}
379
+ /* div[data-testid="stButton"] button { max-width: 100%; aspect-ratio: 1 / 1; border-radius: 0; background: #1d64c8; color: #ffffff; font-weight: 700; padding: 0.5rem 0.75rem; min-height: 2.5rem; min-width: 2.5rem;} */
380
+ .st-key-new_game_btn, .st-key-sort_wordlist_btn, .st-key-filter_wordlist_btn, .st-key-settings_save_apply_btn, .st-key-settings_discard_btn { margin: 0 auto; aspect-ratio: unset; z-index:9999;}
381
+ .st-key-new_game_btn > div[data-testid="stButton"] button, .st-key-sort_wordlist_btn > div[data-testid="stButton"] button, .st-key-filter_wordlist_btn > div[data-testid="stButton"] button, .st-key-settings_save_apply_btn > div[data-testid="stButton"] button, .st-key-settings_discard_btn > div[data-testid="stButton"] button { aspect-ratio: unset; text-align:center; height: auto; width: 160px; border-radius:8px;}
382
+
383
+
384
+ div[data-testid="column"], .st-emotion-cache-zh2fnc, .st-emotion-cache-1tj828o { width: auto !important; flex: 1 1 auto !important; min-width: 100% !important; max-width: 100% !important; border-radius:0;}
385
+ .st-emotion-cache-1permvm, .st-emotion-cache-1n6tfoc { gap:0.15rem !important; min-height: 2.5rem; min-width: 2.5rem;}
386
+
387
+ .bw-grid-row-anchor + div[data-testid="stHorizontalBlock"] { flex-wrap: nowrap !important; overflow-x: auto !important; margin: 2px 0 !important; }
388
+ .bw-grid-row-anchor + div[data-testid="stHorizontalBlock"] > div[data-testid="column"] { flex: 0 0 auto !important; }
389
+ .bw-grid-row-anchor { height: 0; margin: 0; padding: 0; }
390
+ .st-emotion-cache-1n6tfoc { background: linear-gradient(-45deg, #a1a1a1, #ffffff, #a1a1a1, #666666); gap: 0.1rem !important; color: white; border-radius:15px; padding: 10px 10px 10px 5px; }
391
+ .st-emotion-cache-1n6tfoc::before { content: ''; position: absolute; top: 0; left: 0; right: 0; bottom: 0; border-radius: 10px; margin: 5px;}
392
+ .st-key-guess_input, .st-key-guess_submit { flex-direction: row; display: flex; flex-wrap: wrap; justify-content: flex-start; align-items: flex-end; }
393
+
394
+ /* grid adjustments */
395
+ @media (min-width: 560px){
396
+ div[data-testid="stButton"] button { max-width: 100%; aspect-ratio: 1 / 1; min-height: 40px !important;}
397
+ .st-key-new_game_btn > div[data-testid="stButton"] button, .st-key-sort_wordlist_btn > div[data-testid="stButton"] button { min-height: calc(100% + 20px) !important;}
398
+ .st-emotion-cache-ckafi0 { gap:0.1rem !important; min-height: calc(100% + 20px) !important; aspect-ratio: 1 / 1; width: auto !important; flex: 1 1 auto !important; min-width: 100% !important; max-width: 100% !important;}
399
+ /*.st-emotion-cache-1n6tfoc { aspect-ratio: 1 / 1; min-height: calc(100% + 20px) !important;}*/
400
+ .st-emotion-cache-1n6tfoc::before { min-height: calc(100% + 20px) !important; }
401
+
402
+ }
403
+ div[data-testid="stElementToolbarButtonContainer"], button[data-testid="stBaseButton-elementToolbar"], button[data-testid="stBaseButton-elementToolbar"]:hover {
404
+ display: none;
405
+ }
406
+
407
+ /* Mobile styles */
408
+ @media (max-width: 640px) {
409
+ .bw-cell, div[data-testid="stButton"] button, .st-emotion-cache-1permvm {min-width: 1.5rem; min-height:40px;}
410
+ #bw-main-anchor + div[data-testid="stHorizontalBlock"] { flex-direction: column-reverse !important; width: 100% !important; max-width: 100vw !important; }
411
+ #bw-main-anchor + div[data-testid="stHorizontalBlock"] > div[data-testid="column"] { width: 100% !important; min-width: 100% !important; max-width: 100% !important; flex: 1 1 100% !important; }
412
+ .bw-grid-row-anchor + div[data-testid="stHorizontalBlock"] { flex-wrap: nowrap !important; overflow-x: auto !important; margin: 2px 0 !important; }
413
+ .st-emotion-cache-17i4tbh, .st-emotion-cache-1yoilpv { min-width: calc(8.33333% - 1rem); }
414
+ }
415
+
416
+ .bold-text { font-weight: 700; }
417
+ .blue-background { background:#1d64c8; opacity:0.9; }
418
+ .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; }
419
+ .shiny-border { position: relative; padding: 10px; background: #333; color: white; border-radius: 1.25rem; overflow: hidden; }
420
+ .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; }
421
+ .shiny-border:hover::before { left: 100%; }
422
+
423
+ .bw-radio-group { display:flex; align-items:flex-start; gap: 5px; flex-flow: row; min-height: 92px;}
424
+ .bw-radio-item { display:flex; flex-direction:column; align-items:center; gap:6px; text-align:center;}
425
+ .bw-radio-circle { width: 45px; height: 45px; border-radius: 50%; border: 4px solid; background: rgba(255,255,255,0.06); display: grid; place-items: center; color:#fff; font-weight:700; }
426
+ .bw-radio-circle .dot { width: 14px; height: 14px; border-radius: 50%; background:#777; box-shadow: inset 0 0 0 2px rgba(255,255,255,0.25); }
427
+ .bw-radio-circle.active.hit { background: linear-gradient(135deg, rgba(0,255,127,0.18), rgba(0,128,64,0.38)); }
428
+ .bw-radio-circle.active.hit .dot { background:#20d46c; box-shadow: 0 0 10px rgba(32,212,108,0.85); }
429
+ .bw-radio-circle.active.miss { background: linear-gradient(135deg, rgba(255,0,0,0.18), rgba(128,0,0,0.38)); }
430
+ .bw-radio-circle.active.miss .dot { background:#ff4b4b; box-shadow: 0 0 10px rgba(255,75,75,0.85); }
431
+ .bw-radio-caption { font-size: 0.8rem; color:#fff; opacity:0.85; letter-spacing:0.5px; }
432
+ @media (max-width:1000px) and (min-width:641px) {
433
+ .bw-radio-group { flex-wrap:wrap; gap: 5px; margin-bottom: 5px;}
434
+ .bw-radio-item {margin: 0 auto;}
435
+ }
436
+ @media (max-width:640px) {
437
+ .bw-radio-item { margin:unset;}
438
+ }
439
+
440
+ /* Make the sidebar scrollable */
441
+ section[data-testid="stSidebar"] {
442
+ max-height: 100vh;
443
+ overflow-y: auto;
444
+ overflow-x: hidden;
445
+ scrollbar-width: thin;
446
+ scrollbar-color: transparent transparent;
447
+ opacity:0.75;
448
+ }
449
+
450
+ .st-emotion-cache-wp60of {
451
+ width: 720px;
452
+ position: absolute;
453
+ max-width:100%;
454
+ }
455
+ .stImage {max-width:240px;}
456
+ [id^="text_input"] {
457
+ background-color:#fff;
458
+ color:#000;
459
+ caret-color:#333;}
460
+
461
+ @media (min-width:720px) {
462
+ .st-emotion-cache-wp60of {
463
+ left: calc(calc(100% - 720px) / 2);
464
+ }
465
+ }
466
+
467
+ /* Helper to absolutely/fixed position Streamlit component wrapper when showing modal */
468
+ .bw-component-abs { position: fixed !important; inset: 0 !important; z-index: 99999 !important; width: 100vw !important; height: 100vh !important; margin: 0 !important; padding: 0 !important; }
469
+ /* Generic hide utility */
470
+ .hide { display: none !important; pointer-events: none !important; }
471
+ </style>
472
+ """,
473
+ unsafe_allow_html=True,
474
+ )
475
+
476
+ # --- Footer Navigation ---
477
  def render_footer(current_page: str = "play") -> None:
478
  """Render footer navigation.
479
 
 
557
  """,
558
  unsafe_allow_html=True,
559
  )
560
+
561
+
562
+ # --- Spinner Overlay ---
563
+ def show_spinner(message: str = "Loading..."):
564
+ """
565
+ Show a full-page overlay with a wrdler.gif spinner and optional message.
566
+
567
+ Args:
568
+ message (str): Message to display with the spinner.
569
+ """
570
+ # gif_path = os.path.join(os.path.dirname(__file__), "assets", "wrdler.gif")
571
+ # gif_data = None
572
+ # if os.path.exists(gif_path):
573
+ # with open(gif_path, "rb") as f:
574
+ # gif_data = base64.b64encode(f.read()).decode("utf-8")
575
+ # img_tag = f'<img src="data:image/gif;base64,{gif_data}" alt="Loading..." width="192" height="192" />'
576
+ # else:
577
+ # img_tag = '<div style="width:80px;height:80px;background:#eee;border-radius:16px;"></div>'
578
+
579
+ st.markdown(
580
+ f'''
581
+ <style>
582
+ .bw-spinner-overlay {{
583
+ position: fixed; z-index: 99999; top: 0; left: 0; width: 100vw; height: 100vh;
584
+ # transition: background 0.2s ease, color 0.2s ease, border-color 0.2s ease;
585
+ background: rgba(11,42,74,1.0); display: flex; align-items: center; justify-content: center;
586
+ }}
587
+ .bw-spinner-img {{
588
+ width: 150px; height: 150px; border-radius: 16px; box-shadow: 0 0 8px #1d64c8;
589
+ background: #fff; display: flex; align-items: center; justify-content: center;
590
+ }}
591
+ .modal-spinner-inner {{
592
+ background: rgba(255,255,255,0.75); padding: 1rem 2rem; border-radius: 1rem;
593
+ box-shadow: 0 0 16px #1d64c8;
594
+ font-size: 1.5rem; color: #1d64c8;
595
+ }}
596
+ .bw-spinner-msg {{
597
+ color: #1d64c8; font-size: 1.2rem; margin-top: 1.5rem; text-align: center; font-weight: 600;
598
+ }}
599
+ </style>
600
+ <div class="bw-spinner-overlay">
601
+ <div class="modal-spinner-inner" style="display:flex; flex-direction:column; align-items:center;">
602
+ <div class="bw-spinner-img stImage"><img src="/app/static/wrdler.gif" alt="Loading..." width="192" height="192" /></div>
603
+ <div class="bw-spinner-msg">{message}</div>
604
+ </div>
605
+ </div>
606
+ ''',
607
+ unsafe_allow_html=True
608
+ )
battlewords/words/classic.txt CHANGED
@@ -45,7 +45,6 @@ COIN
45
  COPY
46
  CORN
47
  COST
48
- COST
49
  CUTE
50
  DASH
51
  DATE
 
45
  COPY
46
  CORN
47
  COST
 
48
  CUTE
49
  DASH
50
  DATE
claude.md CHANGED
@@ -3,8 +3,8 @@
3
  ## Project Overview
4
  BattleWords is a vocabulary learning game inspired by Battleship mechanics, built with Streamlit and Python 3.12. Players reveal cells on a 12x12 grid to discover hidden words and earn points for strategic guessing.
5
 
6
- **Current Version:** 0.2.30 (Stable - PWA, Challenge Mode & Remote Storage)
7
- **Last Updated:** 2026-01-12
8
  **Next Version:** 0.3.0 (In Development - Wrdler improvements)
9
  **Repository:** https://github.com/Oncorporation/BattleWords.git
10
  **Live Demo:** https://huggingface.co/spaces/Surn/BattleWords
@@ -49,8 +49,8 @@ The following non-gameplay improvements from Wrdler are being ported to BattleWo
49
  - ⏳ Leaderboard browsing UI (Today/Daily/Weekly/History)
50
 
51
  ### Storage/Utilities
52
- - HF repo folder listing helper
53
- - HF repo list files in folder helper
54
 
55
  ### Version & Diagnostics
56
  - ✅ Ported version_info.py to modules
@@ -62,26 +62,15 @@ The following non-gameplay improvements from Wrdler are being ported to BattleWo
62
 
63
  ---
64
 
65
- ## Recent Changes & Branch Status
66
-
67
- **Latest (v0.2.28):**
68
- - Progressive Web App (PWA) support
69
- - Added `service worker` and `manifest.json`
70
- - Basic offline caching of static assets
71
- - INSTALL_GUIDE.md added with install steps
72
- - No gameplay logic changes
73
- - **UI Update:**
74
- - Leaderboard navigation is now in the footer menu, not the sidebar
75
- - Footer navigation links to Leaderboard, Play, and Settings pages
76
- - Leaderboard page routing uses query parameters and custom navigation links
77
- - Game over dialog integrates leaderboard submission and displays qualification results
78
-
79
- **Previous (v0.2.27):**
80
- - New setting: "Show Challenge Share Links" (default OFF)
81
- - Header Challenge Mode banner hides the "Share this challenge" link when disabled
82
- - Game Over dialog still lets you submit or create a challenge, but hides the generated share URL when disabled (shows a success message instead)
83
- - Setting is stored in Streamlit session state and preserved across "New Game"
84
- - No changes to `logic.py` or `game_storage.py`; this is a UI-only visibility feature
85
 
86
  ## Core Gameplay
87
  - 12x12 grid with 6 hidden words (2×4-letter, 2×5-letter, 2×6-letter)
 
3
  ## Project Overview
4
  BattleWords is a vocabulary learning game inspired by Battleship mechanics, built with Streamlit and Python 3.12. Players reveal cells on a 12x12 grid to discover hidden words and earn points for strategic guessing.
5
 
6
+ **Current Version:** 0.2.31 (Stable - PWA, Challenge Mode & Remote Storage)
7
+ **Last Updated:** 2026-01-13
8
  **Next Version:** 0.3.0 (In Development - Wrdler improvements)
9
  **Repository:** https://github.com/Oncorporation/BattleWords.git
10
  **Live Demo:** https://huggingface.co/spaces/Surn/BattleWords
 
49
  - ⏳ Leaderboard browsing UI (Today/Daily/Weekly/History)
50
 
51
  ### Storage/Utilities
52
+ - HF repo folder listing helper
53
+ - HF repo list files in folder helper
54
 
55
  ### Version & Diagnostics
56
  - ✅ Ported version_info.py to modules
 
62
 
63
  ---
64
 
65
+ ## Summary of Recent Changes (v0.2.31)
66
+ - Updated README.md with new Streamlit SDK (1.52.1) and Python (3.12.8).
67
+ - Incremented version to 0.2.31 in `__init__.py` and `pyproject.toml`.
68
+ - Enhanced `audio.py` with error handling and smoother playback.
69
+ - Added `validate_puzzle` and `filter_word_file` functions in `generator.py`.
70
+ - Introduced new constants and `load_settings` in `constants.py`.
71
+ - Improved settings page with wordlist controls and audio settings.
72
+ - Refined UI in `ui.py` with compact layouts and performance optimizations.
73
+ - Modularized helpers in `ui_helpers.py` and reintroduced PWA support.
 
 
 
 
 
 
 
 
 
 
 
74
 
75
  ## Core Gameplay
76
  - 12x12 grid with 6 hidden words (2×4-letter, 2×5-letter, 2×6-letter)
pyproject.toml CHANGED
@@ -1,11 +1,11 @@
1
  [project]
2
  name = "battlewords"
3
- version = "0.2.30"
4
  description = "BattleWords vocabulary game with game sharing via shortened game_id URL referencing server-side JSON settings"
5
  readme = "README.md"
6
  requires-python = ">=3.12,<3.13"
7
  dependencies = [
8
- "streamlit>=1.51.0",
9
  "matplotlib>=3.8",
10
  "requests>=2.31.0",
11
  ]
 
1
  [project]
2
  name = "battlewords"
3
+ version = "0.2.31"
4
  description = "BattleWords vocabulary game with game sharing via shortened game_id URL referencing server-side JSON settings"
5
  readme = "README.md"
6
  requires-python = ">=3.12,<3.13"
7
  dependencies = [
8
+ "streamlit>=1.52.1",
9
  "matplotlib>=3.8",
10
  "requests>=2.31.0",
11
  ]
specs/specs.md CHANGED
@@ -1,5 +1,8 @@
1
  # Battlewords Game Requirements (specs.md)
2
 
 
 
 
3
  ## Overview
4
  Battlewords is inspired by the classic Battleship game, but uses words instead of ships. The objective is to discover hidden words on a grid, earning points for strategic guessing before all letters are revealed.
5
 
@@ -56,6 +59,18 @@ The following non-gameplay improvements from Wrdler are being ported to BattleWo
56
 
57
  ---
58
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  ## Game Board
60
  - 12 x 12 grid.
61
  - Six hidden words:
 
1
  # Battlewords Game Requirements (specs.md)
2
 
3
+ **Current Version:** 0.2.31
4
+ **Last Updated:** 2026-01-13
5
+
6
  ## Overview
7
  Battlewords is inspired by the classic Battleship game, but uses words instead of ships. The objective is to discover hidden words on a grid, earning points for strategic guessing before all letters are revealed.
8
 
 
59
 
60
  ---
61
 
62
+ ## Summary of Recent Changes (v0.2.31)
63
+ - Updated README.md with new Streamlit SDK (1.52.1) and Python (3.12.8).
64
+ - Incremented version to 0.2.31 in `__init__.py` and `pyproject.toml`.
65
+ - Enhanced `audio.py` with error handling and smoother playback.
66
+ - Added `validate_puzzle` and `filter_word_file` functions in `generator.py`.
67
+ - Introduced new constants and `load_settings` in `constants.py`.
68
+ - Improved settings page with wordlist controls and audio settings.
69
+ - Refined UI in `ui.py` with compact layouts and performance optimizations.
70
+ - Modularized helpers in `ui_helpers.py` and reintroduced PWA support.
71
+
72
+ ---
73
+
74
  ## Game Board
75
  - 12 x 12 grid.
76
  - Six hidden words:
specs/upgrade.md CHANGED
@@ -1,5 +1,18 @@
1
  # Upgrade checklist: porting useful `wrdler` improvements into `battlewords`
2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  ## Context summary (for a fresh chat)
4
 
5
  - **Goal:** Bring over non-gameplay improvements from `wrdler` into `battlewords`—primarily **leaderboards**, **settings management**, **UI/navigation**, and **remote storage helpers**.
@@ -34,9 +47,10 @@ Scope: only non-gameplay changes (leaderboards, settings, UI/UX, storage/utiliti
34
 
35
  ## UI/UX Improvements (from Wrdler)
36
 
37
- - [ ] Combine "Your Guess" and guess result into a single UI element, as in Wrdler.
38
- - [ ] Shrink space in scoreboard for a more compact screen layout.
39
  - [x] Radar graphic size reduced to 240px to save processing and load time.
 
40
 
41
  ---
42
 
 
1
  # Upgrade checklist: porting useful `wrdler` improvements into `battlewords`
2
 
3
+ **Current Version:** 0.2.31
4
+ **Last Updated:** 2026-01-13
5
+
6
+ ## Summary of Recent Changes (v0.2.31)
7
+ - Updated README.md with new Streamlit SDK (1.52.1) and Python (3.12.8).
8
+ - Incremented version to 0.2.31 in `__init__.py` and `pyproject.toml`.
9
+ - Enhanced `audio.py` with error handling and smoother playback.
10
+ - Added `validate_puzzle` and `filter_word_file` functions in `generator.py`.
11
+ - Introduced new constants and `load_settings` in `constants.py`.
12
+ - Improved settings page with wordlist controls and audio settings.
13
+ - Refined UI in `ui.py` with compact layouts and performance optimizations.
14
+ - Modularized helpers in `ui_helpers.py` and reintroduced PWA support.
15
+
16
  ## Context summary (for a fresh chat)
17
 
18
  - **Goal:** Bring over non-gameplay improvements from `wrdler` into `battlewords`—primarily **leaderboards**, **settings management**, **UI/navigation**, and **remote storage helpers**.
 
47
 
48
  ## UI/UX Improvements (from Wrdler)
49
 
50
+ - [x] Combine "Your Guess" and guess result into a single UI element, as in Wrdler.
51
+ - [x] Shrink space in scoreboard for a more compact screen layout.
52
  - [x] Radar graphic size reduced to 240px to save processing and load time.
53
+ - [x] Remove waves from background for cleaner look and better performance.
54
 
55
  ---
56