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

Basic Only version step 1

Browse files
.github/copilot-instructions.md CHANGED
@@ -1,6 +1,13 @@
1
- minimal changes to existing code
2
- preserve functionality when possible
3
- clear and concise comments
4
- no test unless specified
5
- no plan unless specified
6
- no compile unless specified
 
 
 
 
 
 
 
 
1
+ # Copilot Instructions
2
+
3
+ ## General Guidelines
4
+ - Minimal changes to existing code
5
+ - Preserve functionality when possible
6
+ - Clear and concise comments
7
+ - No test unless specified
8
+ - No plan unless specified
9
+ - No compile unless specified
10
+
11
+ ## Project-Specific Rules
12
+ - Remove AI generation of wordlists and audio system from the basic codebase
13
+ - Keep challenge mode/HF storage and PWA support in the basic codebase
battlewords/modules/__init__.py CHANGED
@@ -21,14 +21,11 @@ from .storage import (
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,
@@ -71,14 +68,11 @@ __all__ = [
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',
 
21
 
22
  from .constants import (
23
  APP_SETTINGS,
 
24
  HF_API_TOKEN,
25
  CRYPTO_PK,
26
  HF_REPO_ID,
27
  SPACE_NAME,
28
  SHORTENER_JSON_FILE,
 
 
29
  MAX_DISPLAY_ENTRIES,
30
  TMPDIR,
31
  upload_file_types,
 
68
 
69
  # constants.py
70
  'APP_SETTINGS',
 
71
  'HF_API_TOKEN',
72
  'CRYPTO_PK',
73
  'HF_REPO_ID',
74
  'SPACE_NAME',
75
  'SHORTENER_JSON_FILE',
 
 
76
  'MAX_DISPLAY_ENTRIES',
77
  'TMPDIR',
78
  'upload_file_types',
battlewords/modules/constants.py CHANGED
@@ -23,20 +23,8 @@ CRYPTO_PK = os.getenv("CRYPTO_PK", None)
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
  # ---------------------------------------------------------------------------
@@ -63,13 +51,7 @@ def load_settings() -> Dict[str, Any]:
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",
 
23
  HF_REPO_ID = os.getenv("HF_REPO_ID", "Surn/Storage")
24
  SPACE_NAME = os.getenv('SPACE_NAME', 'Surn/BattleWords')
25
  SHORTENER_JSON_FILE = "shortener.json"
 
 
26
  MAX_DISPLAY_ENTRIES = int(os.getenv("MAX_DISPLAY_ENTRIES", 25))
27
 
 
 
 
 
 
 
 
 
 
 
28
  # ---------------------------------------------------------------------------
29
  # Application Settings
30
  # ---------------------------------------------------------------------------
 
51
  "show_incorrect_guesses": True,
52
  "enable_free_letters": False,
53
  "show_challenge_links": True,
54
+
 
 
 
 
 
 
55
  # Game defaults
56
  "default_wordlist": "classic.txt",
57
  "default_game_mode": "classic",
battlewords/ui.py CHANGED
@@ -18,17 +18,8 @@ from .generator import generate_puzzle
18
  from .logic import build_letter_map, reveal_cell, guess_word, is_game_over, compute_tier, auto_mark_completed_words, hidden_word_display
19
  from .models import Coord, GameState, Puzzle
20
  from .word_loader import get_wordlist_files, load_word_list, compute_word_difficulties
21
- from .audio import (
22
- _get_music_dir,
23
- get_audio_tracks,
24
- _load_audio_data_url,
25
- _mount_background_audio,
26
- _inject_audio_control_sync,
27
- play_sound_effect,
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, show_spinner, fade_out_spinner, start_root_fade_in, finish_root_fade_in
32
 
33
  # --- Spinner context manager for custom spinner ---
34
  class CustomSpinner:
@@ -157,13 +148,6 @@ def _init_session() -> None:
157
  if "show_grid_ticks" not in st.session_state:
158
  st.session_state.show_grid_ticks = False
159
 
160
- # --- Add enable sound effects ---
161
- if "enable_sound_effects" not in st.session_state:
162
- st.session_state.enable_sound_effects = False
163
-
164
- if "hide_footer" not in st.session_state:
165
- st.session_state["hide_footer"] = False
166
-
167
  def _new_game() -> None:
168
  spinner_placeholder = st.empty()
169
  with CustomSpinner(spinner_placeholder, "Initializing New Game..."):
@@ -172,12 +156,6 @@ def _new_game() -> None:
172
  show_grid_ticks = st.session_state.get("show_grid_ticks", False)
173
  spacer = st.session_state.get("spacer",1)
174
  show_incorrect_guesses = st.session_state.get("show_incorrect_guesses", False)
175
- # --- Preserve music and effects settings ---
176
- music_enabled = st.session_state.get("music_enabled", False)
177
- music_track_path = st.session_state.get("music_track_path")
178
- music_volume = st.session_state.get("music_volume",15)
179
- effects_volume = st.session_state.get("effects_volume",25)
180
- enable_sound_effects = st.session_state.get("enable_sound_effects", True)
181
  # NEW: Preserve Show Challenge Share Links
182
  show_challenge_share_links = st.session_state.get("show_challenge_share_links", False)
183
 
@@ -189,13 +167,6 @@ def _new_game() -> None:
189
  st.session_state.show_grid_ticks = show_grid_ticks
190
  st.session_state.spacer = spacer
191
  st.session_state.show_incorrect_guesses = show_incorrect_guesses
192
- # --- Restore music/effects settings ---
193
- st.session_state.music_enabled = music_enabled
194
- if music_track_path:
195
- st.session_state.music_track_path = music_track_path
196
- st.session_state.music_volume = music_volume
197
- st.session_state.effects_volume = effects_volume
198
- st.session_state.enable_sound_effects = enable_sound_effects
199
  # NEW: Restore Show Challenge Share Links
200
  st.session_state.show_challenge_share_links = show_challenge_share_links
201
 
@@ -689,19 +660,6 @@ def _render_grid(state: GameState, letter_map, show_grid_ticks: bool = True):
689
  # Note: letter_map is static and built once in _init_session(), no need to rebuild
690
  _sync_back(state)
691
 
692
- # Play sound effect based on hit or miss
693
- action = (state.last_action or "").strip()
694
- if action.startswith("Revealed '"):
695
- play_sound_effect(
696
- "hit",
697
- volume=(st.session_state.get("effects_volume", 50) / 100),
698
- )
699
- elif action.startswith("Revealed empty"):
700
- play_sound_effect(
701
- "miss",
702
- volume=(st.session_state.get("effects_volume", 50) / 100),
703
- )
704
-
705
  st.rerun()
706
 
707
  def _render_hit_miss(state: GameState):
@@ -939,12 +897,10 @@ def _render_guess_form(state: GameState):
939
  if correct:
940
  st.session_state.radar_gif_path = None
941
  st.session_state.radar_gif_signature = None
942
- play_sound_effect("correct_guess", volume=(st.session_state.get("effects_volume", 50) / 100))
943
  else:
944
  # Update incorrect guesses list - keep only last 10
945
  st.session_state.incorrect_guesses.append(guess_text)
946
  st.session_state.incorrect_guesses = st.session_state.incorrect_guesses[-10:]
947
- play_sound_effect("incorrect_guess", volume=(st.session_state.get("effects_volume", 50) / 100))
948
  st.rerun()
949
 
950
 
@@ -1083,16 +1039,6 @@ def _render_score_panel(state: GameState):
1083
  # -------------------- Game Over Dialog --------------------
1084
 
1085
  def _game_over_content(state: GameState) -> None:
1086
- # Play congratulations music (not sound effect) as background if enabled
1087
- music_dir = _get_music_dir()
1088
- congrats_music_path = os.path.join(music_dir, "congratulations.mp3")
1089
- if st.session_state.get("music_enabled", False) and os.path.exists(congrats_music_path):
1090
- src_url = _load_audio_data_url(congrats_music_path)
1091
- # Play once (no loop) at configured volume
1092
- _mount_background_audio(enabled=True, src_data_url=src_url, volume=(st.session_state.get("music_volume", 100)) / 100, loop=False)
1093
- else:
1094
- _mount_background_audio(False, None, 0.0)
1095
-
1096
  # Set end_time if not already set
1097
  if state.end_time is None:
1098
  st.session_state.end_time = datetime.now()
@@ -1394,7 +1340,6 @@ def _game_over_content(state: GameState) -> None:
1394
  # Dialog actions
1395
  if st.button("Close", key="close_game_over"):
1396
  st.session_state["show_gameover_overlay"] = False
1397
- st.session_state["remount_background_audio"] = True # <-- set flag
1398
  st.rerun()
1399
 
1400
  # Prefer st.dialog/experimental_dialog; fallback to st.modal if unavailable
@@ -1418,32 +1363,8 @@ def _render_game_over(state: GameState):
1418
  spinner_placeholder = st.empty()
1419
  with CustomSpinner(spinner_placeholder, "Game Over..."):
1420
  visible = bool(st.session_state.get("show_gameover_overlay", True)) and is_game_over(state)
1421
- music_dir = _get_music_dir()
1422
  if visible:
1423
- # Mount congratulations music (play once, do not loop) only if music is enabled
1424
- congrats_music_path = os.path.join(music_dir, "congratulations.mp3")
1425
- if st.session_state.get("music_enabled", False) and os.path.exists(congrats_music_path):
1426
- src_url = _load_audio_data_url(congrats_music_path)
1427
- _mount_background_audio(enabled=True, src_data_url=src_url, volume=(st.session_state.get("music_volume", 100)) / 100, loop=False)
1428
- else:
1429
- _mount_background_audio(False, None, 0.0)
1430
  _game_over_dialog(state)
1431
- else:
1432
- # Only play background music if music is enabled
1433
- if st.session_state.get("music_enabled", False):
1434
- # Prefer user-selected track
1435
- track_path = st.session_state.get("music_track_path")
1436
- if track_path and os.path.exists(track_path):
1437
- src_url = _load_audio_data_url(track_path)
1438
- _mount_background_audio(True, src_url, (st.session_state.get("music_volume", 100)) / 100)
1439
- else:
1440
- # Fallback to a default background track if available
1441
- background_path = os.path.join(music_dir, "background.mp3")
1442
- if os.path.exists(background_path):
1443
- src_url = _load_audio_data_url(background_path)
1444
- _mount_background_audio(True, src_url, (st.session_state.get("music_volume", 100)) / 100)
1445
- else:
1446
- _mount_background_audio(False, None, 0.0)
1447
 
1448
  def _on_game_option_change() -> None:
1449
  """
@@ -1502,10 +1423,6 @@ def run_app():
1502
  except Exception:
1503
  params = {}
1504
 
1505
- # handle no footer query string param (for embedding use cases)
1506
- if "nofooter" in params:
1507
- st.session_state["hide_footer"] = True
1508
-
1509
  # Handle overlay dismissal
1510
  if params.get("overlay") == "0":
1511
  # Clear param and remember to hide overlay this session
@@ -1515,54 +1432,14 @@ def run_app():
1515
  pass
1516
  st.session_state["hide_gameover_overlay"] = True
1517
 
1518
- # Handle page navigation via query params
1519
- page = params.get("page") or "play"
1520
-
1521
  # Show spinner on initial load
1522
  if not st.session_state.get("initial_page_loaded", False):
1523
  spinner_placeholder = st.empty()
1524
  with CustomSpinner(spinner_placeholder, "Initial Load..."):
1525
  st.session_state["initial_page_loaded"] = True
1526
- st.session_state["last_nav_page"] = page
1527
- # st.rerun()
1528
-
1529
- # Show spinner when navigating via menu (query param page change)
1530
- last_nav_page = st.session_state.get("last_nav_page")
1531
- if last_nav_page is not None and last_nav_page != page:
1532
- spinner_placeholder = st.empty()
1533
- with CustomSpinner(spinner_placeholder, "Loading..."):
1534
- st.session_state["last_nav_page"] = page
1535
- st.rerun()
1536
-
1537
- # One-time spinner on initial display of the main UI.
1538
- # if page == "play" and not st.session_state.get("initial_page_loaded", False):
1539
- # spinner_placeholder = st.empty()
1540
- # with CustomSpinner(spinner_placeholder, "Loading Game..."):
1541
- # st.session_state["initial_page_loaded"] = True
1542
- # st.rerun()
1543
 
1544
- if page == "settings":
1545
- spinner_placeholder = st.empty()
1546
- with CustomSpinner(spinner_placeholder, "Loading Settings..."):
1547
- st.markdown(ocean_background_css, unsafe_allow_html=True)
1548
- #inject_ocean_layers()
1549
- inject_styles()
1550
- render_settings_page(_on_game_option_change)
1551
- if st.session_state["hide_footer"] is not True:
1552
- render_footer(current_page="settings", params=params)
1553
- return
1554
-
1555
- if page == "leaderboard":
1556
- spinner_placeholder = st.empty()
1557
- with CustomSpinner(spinner_placeholder, "Loading Leaderboard..."):
1558
- st.markdown(ocean_background_css, unsafe_allow_html=True)
1559
- #inject_ocean_layers()
1560
- inject_styles()
1561
- st.header("Leaderboard")
1562
- st.caption("Leaderboard UI will be added as part of the upgrade checklist.")
1563
- if st.session_state["hide_footer"] is not True:
1564
- render_footer(current_page="leaderboard", params=params)
1565
- return
1566
 
1567
  # Handle game_id for loading shared games
1568
  if "game_id" in params and "shared_game_loaded" not in st.session_state:
@@ -1614,8 +1491,6 @@ def run_app():
1614
  state = _to_state()
1615
  if is_game_over(state) and not st.session_state.get("hide_gameover_overlay", False):
1616
  _render_game_over(state)
1617
- if st.session_state["hide_footer"] is not True:
1618
- render_footer(current_page="play", params=params)
1619
  finish_root_fade_in(2.0)
1620
 
1621
  def _render_game_tab():
 
18
  from .logic import build_letter_map, reveal_cell, guess_word, is_game_over, compute_tier, auto_mark_completed_words, hidden_word_display
19
  from .models import Coord, GameState, Puzzle
20
  from .word_loader import get_wordlist_files, load_word_list, compute_word_difficulties
 
 
 
 
 
 
 
 
21
  from .game_storage import load_game_from_sid, save_game_to_hf, get_shareable_url, add_user_result_to_game
22
+ from .ui_helpers import inject_styles, fig_to_pil_rgba, ocean_background_css, inject_ocean_layers, pwa_service_worker, show_spinner, fade_out_spinner, start_root_fade_in, finish_root_fade_in
 
23
 
24
  # --- Spinner context manager for custom spinner ---
25
  class CustomSpinner:
 
148
  if "show_grid_ticks" not in st.session_state:
149
  st.session_state.show_grid_ticks = False
150
 
 
 
 
 
 
 
 
151
  def _new_game() -> None:
152
  spinner_placeholder = st.empty()
153
  with CustomSpinner(spinner_placeholder, "Initializing New Game..."):
 
156
  show_grid_ticks = st.session_state.get("show_grid_ticks", False)
157
  spacer = st.session_state.get("spacer",1)
158
  show_incorrect_guesses = st.session_state.get("show_incorrect_guesses", False)
 
 
 
 
 
 
159
  # NEW: Preserve Show Challenge Share Links
160
  show_challenge_share_links = st.session_state.get("show_challenge_share_links", False)
161
 
 
167
  st.session_state.show_grid_ticks = show_grid_ticks
168
  st.session_state.spacer = spacer
169
  st.session_state.show_incorrect_guesses = show_incorrect_guesses
 
 
 
 
 
 
 
170
  # NEW: Restore Show Challenge Share Links
171
  st.session_state.show_challenge_share_links = show_challenge_share_links
172
 
 
660
  # Note: letter_map is static and built once in _init_session(), no need to rebuild
661
  _sync_back(state)
662
 
 
 
 
 
 
 
 
 
 
 
 
 
 
663
  st.rerun()
664
 
665
  def _render_hit_miss(state: GameState):
 
897
  if correct:
898
  st.session_state.radar_gif_path = None
899
  st.session_state.radar_gif_signature = None
 
900
  else:
901
  # Update incorrect guesses list - keep only last 10
902
  st.session_state.incorrect_guesses.append(guess_text)
903
  st.session_state.incorrect_guesses = st.session_state.incorrect_guesses[-10:]
 
904
  st.rerun()
905
 
906
 
 
1039
  # -------------------- Game Over Dialog --------------------
1040
 
1041
  def _game_over_content(state: GameState) -> None:
 
 
 
 
 
 
 
 
 
 
1042
  # Set end_time if not already set
1043
  if state.end_time is None:
1044
  st.session_state.end_time = datetime.now()
 
1340
  # Dialog actions
1341
  if st.button("Close", key="close_game_over"):
1342
  st.session_state["show_gameover_overlay"] = False
 
1343
  st.rerun()
1344
 
1345
  # Prefer st.dialog/experimental_dialog; fallback to st.modal if unavailable
 
1363
  spinner_placeholder = st.empty()
1364
  with CustomSpinner(spinner_placeholder, "Game Over..."):
1365
  visible = bool(st.session_state.get("show_gameover_overlay", True)) and is_game_over(state)
 
1366
  if visible:
 
 
 
 
 
 
 
1367
  _game_over_dialog(state)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1368
 
1369
  def _on_game_option_change() -> None:
1370
  """
 
1423
  except Exception:
1424
  params = {}
1425
 
 
 
 
 
1426
  # Handle overlay dismissal
1427
  if params.get("overlay") == "0":
1428
  # Clear param and remember to hide overlay this session
 
1432
  pass
1433
  st.session_state["hide_gameover_overlay"] = True
1434
 
 
 
 
1435
  # Show spinner on initial load
1436
  if not st.session_state.get("initial_page_loaded", False):
1437
  spinner_placeholder = st.empty()
1438
  with CustomSpinner(spinner_placeholder, "Initial Load..."):
1439
  st.session_state["initial_page_loaded"] = True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1440
 
1441
+ # Basic branch: only render the play experience
1442
+ # Settings and leaderboard pages are disabled
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1443
 
1444
  # Handle game_id for loading shared games
1445
  if "game_id" in params and "shared_game_loaded" not in st.session_state:
 
1491
  state = _to_state()
1492
  if is_game_over(state) and not st.session_state.get("hide_gameover_overlay", False):
1493
  _render_game_over(state)
 
 
1494
  finish_root_fade_in(2.0)
1495
 
1496
  def _render_game_tab():
specs/basic.mdx ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Battlewords: Basic Branch Requirements
2
+
3
+ **Branch:** `basic`
4
+
5
+ This document defines the “basic” version scope for Battlewords on the `basic` branch. It intentionally limits features to a minimal, default-settings single-player experience.
6
+
7
+ ## Tech Stack
8
+ - Python 3.10+
9
+ - Streamlit (UI)
10
+ - matplotlib (radar)
11
+ - numpy (tick helpers)
12
+ - Pillow (GIF writer)
13
+
14
+ ## In Scope
15
+
16
+ ### Default gameplay only
17
+ - The game uses **default settings**.
18
+ - No user-configurable options are required.
19
+ - State remains local (Streamlit session state) as implemented in the core app flow.
20
+
21
+ ### UI
22
+ - A single play experience accessible from `app.py`.
23
+ - Core UI elements required to play:
24
+ - 12×12 grid
25
+ - Reveal action
26
+ - Guess form
27
+ - Score display
28
+ - Game over summary
29
+
30
+ ### Layout constraints
31
+ - No footer navigation.
32
+ - No sidebar.
33
+
34
+ ## Out of Scope (Basic Branch)
35
+
36
+ ### Settings page
37
+ - The app **does not need** a settings page.
38
+ - No query-param routing to a settings page is required.
39
+ - Any settings-related UI should be omitted/disabled in this branch.
40
+
41
+ ### Leaderboard
42
+ - The app **does not need** a leaderboard.
43
+ - No local or remote leaderboard storage is required.
44
+ - No UI navigation or pages for leaderboard are required.
45
+
46
+ ### AI generation of wordlists
47
+ - The basic branch does **not** include AI-generated wordlists or HF-model-backed word generation.
48
+ - Related AI model configuration (for example `AI_MODELS`, `USE_HF_WORDS`, `HF_WORD_LIST_REPO_ID`) should be removed/unused in this branch.
49
+
50
+ ### Audio (music + sound effects)
51
+ - The basic branch does **not** include background music or sound effects.
52
+ - Audio mounting via `component.html` and any audio assets/modules should be removed/unused in this branch.
53
+
54
+ ### Tests
55
+ - The basic branch does **not** include tests or a `tests` folder.
56
+
57
+ ## Notes
58
+ - Feature work for Settings/Leaderboard should live on non-`basic` branches.
59
+
60
+ ## Basic Functions Kept
61
+
62
+ The following functions are retained in the basic branch:
63
+ - Game initialization and puzzle generation
64
+ - Word list loading and validation
65
+ - Reveal cell action
66
+ - Guess word action
67
+ - Score calculation and tier assignment
68
+ - Radar visualization
69
+ - Game over summary display
70
+
71
+ ## UI Transitions and Effects
72
+
73
+ The basic branch uses:
74
+ - `CustomSpinner` for smooth loading and transition effects (New Game, initial load, Game Over/Congratulations popups).
75
+ - Streamlit CSS, markup, and `component.html` for UI styling and transition overlays.
76
+
77
+
78
+ ## Codebase Constraints (Basic Branch)
79
+
80
+ ### Navigation / routing
81
+ - No query-param navigation to non-game pages.
82
+ - Remove/disable any routing paths such as `?page=settings` and `?page=leaderboard`.
83
+ - The basic branch should only render the play experience.
84
+
85
+ ### Word list sources
86
+ - Word lists must be loaded from local files in `battlewords/words/`.
87
+ - Do not fetch or generate word lists via Hugging Face models or other AI inference.
88
+
89
+ ### Component usage
90
+ - `component.html` is allowed for UI transitions/effects (spinners, scroll-to-anchor, overlays).
91
+ - `component.html` must not be used for audio mounting in the basic branch.
92
+
93
+ ## Possible features to remove
94
+
95
+ The following features are currently included, but may be considered for removal in an even more minimal version:
96
+ - Challenge mode / Share Links / Remote Storage (HF)
97
+ - PWA support (service worker, manifest, installability)
98
+
99
+
100
+ ## Implementation Checklist
101
+
102
+ - App starts with no errors.
103
+ - New Game transition remains smooth (uses `CustomSpinner`).
104
+ - Core gameplay works end-to-end (reveal, guess, scoring, radar, game over).
105
+ - Game Over / Congratulations popup behaves correctly and remains smooth.
106
+ - Challenge mode works via `?game_id=...` (load, play, submit/share).
107
+ - PWA support remains functional (service worker/manifest still injected/served).
108
+ - No audio playback occurs (no background music, no sound effects).
109
+ - No AI/HF-model-backed word list generation occurs (local wordlists only).
110
+ - No settings/leaderboard pages are reachable (`?page=settings` / `?page=leaderboard` disabled).
111
+ - No footer navigation and no sidebar are rendered.
112
+
113
+
114
+
115
+ Initial Proposed Prompt:
116
+
117
+ ```text
118
+ You are working in the basic branch. Follow .github/copilot-instructions.md (minimal changes, preserve functionality, clear concise comments, no tests unless asked). Also follow basic.mdx requirements.
119
+ Goal: simplify the codebase to match the basic version.
120
+ Must keep:
121
+ • Core gameplay (grid reveal, guessing, scoring, radar, game over)
122
+ • CustomSpinner and CSS/markup/components.html usage for smooth transitions (initial load, New Game, Game Over popup)
123
+ • Challenge mode / share links / Remote Storage (HF) feature
124
+ • PWA support (service worker / manifest integration)
125
+ Must remove/disable from the basic codebase:
126
+ • AI generation of wordlists / HF-model-backed word generation (remove or make unused AI_MODELS, USE_HF_WORDS, HF_WORD_LIST_REPO_ID and any related code paths)
127
+ • Audio system (no background music, no sound effects; remove imports/usage of audio.py, audio assets, and any audio mounting via components.html)
128
+ Additional constraints:
129
+ • No footer and no sidebar in the UI.
130
+ • No query-param navigation for ?page=settings or ?page=leaderboard; basic should only render the play experience.
131
+ • Word lists must load only from local files in battlewords/words/.
132
+ Deliverable:
133
+ • Make the smallest set of code changes across the repo to enforce the above.
134
+ • Update app.py / ui.py routing accordingly.
135
+ • Remove now-unused modules/files only if they are truly unused after edits.
136
+ • Summarize exactly which files you changed and why.
137
+ • Validate the result against the "Implementation Checklist" section in this document.
138
+ ```
tests/test_apptest.py DELETED
@@ -1,7 +0,0 @@
1
- # file: D:/Projects/Battlewords/tests/test_apptest.py
2
- from streamlit.testing.v1 import AppTest
3
-
4
- def test_app_runs():
5
- at = AppTest.from_file("app.py")
6
- at.run()
7
- assert not at.exception
 
 
 
 
 
 
 
 
tests/test_compare_difficulty_functions.py DELETED
@@ -1,237 +0,0 @@
1
- # file: tests/test_compare_difficulty_functions.py
2
- import os
3
- import sys
4
- import pytest
5
-
6
- # Ensure the modules path is available
7
- sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
8
-
9
- from battlewords.modules.constants import HF_API_TOKEN
10
- from battlewords.modules.storage import gen_full_url, _get_json_from_repo, HF_REPO_ID, SHORTENER_JSON_FILE
11
- from battlewords.word_loader import compute_word_difficulties, compute_word_difficulties2, compute_word_difficulties3
12
-
13
- # Ensure the token is set for Hugging Face Hub
14
- if HF_API_TOKEN:
15
- os.environ["HF_API_TOKEN"] = HF_API_TOKEN
16
-
17
- # Define sample_words as a global variable
18
- sample_words = []
19
-
20
- def test_compare_difficulty_functions_for_challenge(capsys):
21
- """
22
- Compare compute_word_difficulties, compute_word_difficulties2, and compute_word_difficulties3
23
- for all users in a challenge identified by short_id.
24
- """
25
- global sample_words # Ensure we modify the global variable
26
-
27
- # Use a fixed short id for testing
28
- short_id = "hDjsB_dl"
29
-
30
- # Step 1: Resolve short ID to full URL
31
- status, full_url = gen_full_url(
32
- short_url=short_id,
33
- repo_id=HF_REPO_ID,
34
- json_file=SHORTENER_JSON_FILE
35
- )
36
-
37
- if status != "success_retrieved_full" or not full_url:
38
- print(
39
- f"Could not resolve short id '{short_id}'. "
40
- f"Status: {status}. "
41
- f"Check repo '{HF_REPO_ID}' and mapping file '{SHORTENER_JSON_FILE}'."
42
- )
43
- captured = capsys.readouterr()
44
- assert "Could not resolve short id" in captured.out
45
- assert not full_url, "full_url should be empty/None on failure"
46
- print("settings.json was not found or could not be resolved.")
47
- return
48
-
49
- print(f"✓ Resolved short id '{short_id}' to full URL: {full_url}")
50
-
51
- # Step 2: Extract file path from full URL
52
- url_parts = full_url.split("/resolve/main/")
53
- assert len(url_parts) == 2, f"Invalid full URL format: {full_url}"
54
- file_path = url_parts[1]
55
-
56
- # Step 3: Download and parse settings.json
57
- settings = _get_json_from_repo(HF_REPO_ID, file_path, repo_type="dataset")
58
- assert settings, "Failed to download or parse settings.json"
59
- print(f"✓ Downloaded settings.json")
60
-
61
- # Validate settings structure
62
- assert "challenge_id" in settings
63
- assert "wordlist_source" in settings
64
- assert "users" in settings
65
-
66
- wordlist_source = settings.get("wordlist_source", "wordlist.txt")
67
- users = settings.get("users", [])
68
-
69
- print(f"\nChallenge ID: {settings['challenge_id']}")
70
- print(f"Wordlist Source: {wordlist_source}")
71
- print(f"Number of Users: {len(users)}")
72
-
73
- # Step 4: Determine wordlist file path
74
- # Assuming the wordlist is in battlewords/words/ directory
75
- words_dir = os.path.join(os.path.dirname(__file__), "..", "battlewords", "words")
76
- wordlist_path = os.path.join(words_dir, wordlist_source)
77
-
78
- # If wordlist doesn't exist, try classic.txt as fallback
79
- if not os.path.exists(wordlist_path):
80
- print(f"⚠ Wordlist '{wordlist_source}' not found, using 'classic.txt' as fallback")
81
- wordlist_path = os.path.join(words_dir, "classic.txt")
82
-
83
- assert os.path.exists(wordlist_path), f"Wordlist file not found: {wordlist_path}"
84
- print(f"✓ Using wordlist: {wordlist_path}")
85
-
86
- # Step 5: Compare difficulty functions for each user
87
- print("\n" + "="*80)
88
- print("DIFFICULTY COMPARISON BY USER")
89
- print("="*80)
90
-
91
- all_results = []
92
-
93
- for user_idx, user in enumerate(users, 1):
94
- user_name = user.get("name", f"User {user_idx}")
95
- word_list = user.get("word_list", [])
96
- sample_words += word_list # Update the global variable with the latest word list
97
-
98
- if not word_list:
99
- print(f"\n[{user_idx}] {user_name}: No words assigned, skipping")
100
- continue
101
-
102
- print(f"\n[{user_idx}] {user_name}")
103
- print(f" Words: {len(word_list)} words")
104
- print(f" Sample: {', '.join(word_list[:5])}{'...' if len(word_list) > 5 else ''}")
105
-
106
- # Compute difficulties using all three functions
107
- total_diff1, difficulties1 = compute_word_difficulties(wordlist_path, word_list)
108
- total_diff2, difficulties2 = compute_word_difficulties2(wordlist_path, word_list)
109
- total_diff3, difficulties3 = compute_word_difficulties3(wordlist_path, word_list)
110
-
111
- print(f"\n Function 1 (compute_word_difficulties):")
112
- print(f" Total Difficulty: {total_diff1:.4f}")
113
- print(f" Words Processed: {len(difficulties1)}")
114
-
115
- print(f"\n Function 2 (compute_word_difficulties2):")
116
- print(f" Total Difficulty: {total_diff2:.4f}")
117
- print(f" Words Processed: {len(difficulties2)}")
118
-
119
- print(f"\n Function 3 (compute_word_difficulties3):")
120
- print(f" Total Difficulty: {total_diff3:.4f}")
121
- print(f" Words Processed: {len(difficulties3)}")
122
-
123
- # Calculate statistics
124
- if difficulties1 and difficulties2 and difficulties3:
125
- avg_diff1 = total_diff1 / len(difficulties1)
126
- avg_diff2 = total_diff2 / len(difficulties2)
127
- avg_diff3 = total_diff3 / len(difficulties3)
128
-
129
- print(f"\n Comparison:")
130
- print(f" Average Difficulty (Func1): {avg_diff1:.4f}")
131
- print(f" Average Difficulty (Func2): {avg_diff2:.4f}")
132
- print(f" Average Difficulty (Func3): {avg_diff3:.4f}")
133
- print(f" Difference (Func1 vs Func2): {abs(avg_diff1 - avg_diff2):.4f}")
134
- print(f" Difference (Func1 vs Func3): {abs(avg_diff1 - avg_diff3):.4f}")
135
- print(f" Difference (Func2 vs Func3): {abs(avg_diff2 - avg_diff3):.4f}")
136
-
137
- # Store results for final summary
138
- all_results.append({
139
- "user_name": user_name,
140
- "word_count": len(word_list),
141
- "total_diff1": total_diff1,
142
- "total_diff2": total_diff2,
143
- "total_diff3": total_diff3,
144
- "difficulties1": difficulties1,
145
- "difficulties2": difficulties2,
146
- "difficulties3": difficulties3,
147
- })
148
-
149
- # Step 6: Print summary comparison
150
- print("\n" + "="*80)
151
- print("OVERALL SUMMARY")
152
- print("="*80)
153
-
154
- if all_results:
155
- total1_sum = sum(r["total_diff1"] for r in all_results)
156
- total2_sum = sum(r["total_diff2"] for r in all_results)
157
- total3_sum = sum(r["total_diff3"] for r in all_results)
158
- total_words = sum(r["word_count"] for r in all_results)
159
-
160
- print(f"\nTotal Users Analyzed: {len(all_results)}")
161
- print(f"Total Words Across All Users: {total_words}")
162
- print(f"\nAggregate Total Difficulty:")
163
- print(f" Function 1: {total1_sum:.4f}")
164
- print(f" Function 2: {total2_sum:.4f}")
165
- print(f" Function 3: {total3_sum:.4f}")
166
- print(f" Difference (Func1 vs Func2): {abs(total1_sum - total2_sum):.4f}")
167
- print(f" Difference (Func1 vs Func3): {abs(total1_sum - total3_sum):.4f}")
168
- print(f" Difference (Func2 vs Func3): {abs(total2_sum - total3_sum):.4f}")
169
-
170
- # Validate that all functions returned results for all users
171
- assert all(r["difficulties1"] for r in all_results), "Function 1 failed for some users"
172
- assert all(r["difficulties2"] for r in all_results), "Function 2 failed for some users"
173
- assert all(r["difficulties3"] for r in all_results), "Function 3 failed for some users"
174
-
175
- print("\n✓ All tests passed!")
176
- else:
177
- print("\n⚠ No users with words found in this challenge")
178
-
179
-
180
- def test_compare_difficulty_functions_with_classic_wordlist():
181
- """
182
- Test all three difficulty functions using the classic.txt wordlist
183
- with a sample set of words.
184
- """
185
- global sample_words # Use the global variable
186
-
187
- words_dir = os.path.join(os.path.dirname(__file__), "..", "battlewords", "words")
188
- wordlist_path = os.path.join(words_dir, "classic.txt")
189
-
190
- if not os.path.exists(wordlist_path):
191
- pytest.skip(f"classic.txt not found at {wordlist_path}")
192
-
193
- # Use the global sample_words if already populated, otherwise set a default
194
- if not sample_words:
195
- sample_words = ["ABLE", "ACID", "AREA", "ARMY", "BEAR", "BOWL", "CAVE", "COIN", "ECHO", "GOLD"]
196
-
197
- print("\n" + "="*80)
198
- print("TESTING WITH CLASSIC.TXT WORDLIST")
199
- print("="*80)
200
- print(f"Sample Words: {', '.join(sample_words)}")
201
-
202
- # Compute difficulties
203
- total_diff1, difficulties1 = compute_word_difficulties(wordlist_path, sample_words)
204
- total_diff2, difficulties2 = compute_word_difficulties2(wordlist_path, sample_words)
205
- total_diff3, difficulties3 = compute_word_difficulties3(wordlist_path, sample_words)
206
-
207
- print(f"\nFunction compute_word_difficulties Results:")
208
- print(f" Total Difficulty: {total_diff1:.4f}")
209
- for word in sample_words:
210
- if word in difficulties1:
211
- print(f" {word}: {difficulties1[word]:.4f}")
212
-
213
- print(f"\nFunction compute_word_difficulties2 Results:")
214
- print(f" Total Difficulty: {total_diff2:.4f}")
215
- for word in sample_words:
216
- if word in difficulties2:
217
- print(f" {word}: {difficulties2[word]:.4f}")
218
-
219
- print(f"\nFunction compute_word_difficulties3 Results:")
220
- print(f" Total Difficulty: {total_diff3:.4f}")
221
- for word in sample_words:
222
- if word in difficulties3:
223
- print(f" {word}: {difficulties3[word]:.4f}")
224
-
225
- # Assertions
226
- assert len(difficulties1) == len(set(sample_words)), "Function 1 didn't process all words"
227
- assert len(difficulties2) == len(set(sample_words)), "Function 2 didn't process all words"
228
- assert len(difficulties3) == len(set(sample_words)), "Function 3 didn't process all words"
229
- assert total_diff1 > 0, "Function 1 total difficulty should be positive"
230
- assert total_diff2 > 0, "Function 2 total difficulty should be positive"
231
- assert total_diff3 > 0, "Function 3 total difficulty should be positive"
232
-
233
- print("\n✓ Classic wordlist test passed!")
234
-
235
-
236
- if __name__ == "__main__":
237
- pytest.main(["-s", "-v", __file__])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_download_game_settings.py DELETED
@@ -1,63 +0,0 @@
1
- # file: tests/test_download_game_settings.py
2
- import os
3
- import sys
4
- import pytest
5
-
6
- # Ensure the modules path is available
7
- sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
8
-
9
- from battlewords.modules.constants import HF_API_TOKEN # <-- Import the token
10
- from battlewords.modules.storage import gen_full_url, _get_json_from_repo, HF_REPO_ID, SHORTENER_JSON_FILE
11
-
12
- # Ensure the token is set for Hugging Face Hub
13
- if HF_API_TOKEN:
14
- os.environ["HF_API_TOKEN"] = HF_API_TOKEN
15
-
16
- def test_download_settings_by_short_id_handles_both(capsys):
17
- # Use a fixed short id for testing
18
- short_id = "hDjsB_dl"
19
-
20
- # Step 1: Resolve short ID to full URL
21
- status, full_url = gen_full_url(
22
- short_url=short_id,
23
- repo_id=HF_REPO_ID,
24
- json_file=SHORTENER_JSON_FILE
25
- )
26
-
27
- # Failure branch: provide a helpful message and assert expected failure shape
28
- if status != "success_retrieved_full" or not full_url:
29
- print(
30
- f"Could not resolve short id '{short_id}'. "
31
- f"Status: {status}. "
32
- f"Check repo '{HF_REPO_ID}' and mapping file '{SHORTENER_JSON_FILE}'."
33
- )
34
- captured = capsys.readouterr()
35
- assert "Could not resolve short id" in captured.out
36
- # Ensure failure shape is consistent
37
- assert not full_url, "full_url should be empty/None on failure"
38
- print("settings.json was not found or could not be resolved.")
39
- return
40
- else:
41
- print(f"Resolved short id '{short_id}' to full URL: {full_url}")
42
-
43
- # Success branch
44
- assert status == "success_retrieved_full", f"Failed to resolve short ID: {status}"
45
- assert full_url, "No full URL returned"
46
-
47
- # Step 2: Extract file path from full URL
48
- url_parts = full_url.split("/resolve/main/")
49
- assert len(url_parts) == 2, f"Invalid full URL format: {full_url}"
50
- file_path = url_parts[1]
51
-
52
- # Step 3: Download and parse settings.json
53
- settings = _get_json_from_repo(HF_REPO_ID, file_path, repo_type="dataset")
54
- assert settings, "Failed to download or parse settings.json"
55
-
56
- print("\nDownloaded settings.json contents:", settings)
57
- # Optionally, add more assertions about the settings structure
58
- assert "challenge_id" in settings
59
- assert "wordlist_source" in settings
60
- assert "users" in settings
61
-
62
- if __name__ == "__main__":
63
- pytest.main(["-s", __file__])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_generator.py DELETED
@@ -1,29 +0,0 @@
1
- import unittest
2
-
3
- from battlewords.generator import generate_puzzle, validate_puzzle
4
- from battlewords.models import Coord
5
-
6
-
7
- class TestGenerator(unittest.TestCase):
8
- def test_generate_valid_puzzle(self):
9
- # Provide a minimal word pool for deterministic testing
10
- words_by_len = {
11
- 4: ["TREE", "BOAT"],
12
- 5: ["APPLE", "RIVER"],
13
- 6: ["ORANGE", "PYTHON"],
14
- }
15
- p = generate_puzzle(grid_size=12, words_by_len=words_by_len, seed=1234)
16
- validate_puzzle(p, grid_size=12)
17
- # Ensure 6 words and 6 radar pulses
18
- self.assertEqual(len(p.words), 6)
19
- self.assertEqual(len(p.radar), 6)
20
- # Ensure no overlaps
21
- seen = set()
22
- for w in p.words:
23
- for c in w.cells:
24
- self.assertNotIn(c, seen)
25
- seen.add(c)
26
- self.assertTrue(0 <= c.x < 12 and 0 <= c.y < 12)
27
-
28
- if __name__ == "__main__":
29
- unittest.main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_logic.py DELETED
@@ -1,55 +0,0 @@
1
- import unittest
2
-
3
- from battlewords.logic import build_letter_map, reveal_cell, guess_word, is_game_over
4
- from battlewords.models import Coord, Word, Puzzle, GameState
5
-
6
-
7
- class TestLogic(unittest.TestCase):
8
- def make_state(self):
9
- w1 = Word("TREE", Coord(0, 0), "H")
10
- w2 = Word("APPLE", Coord(2, 0), "H")
11
- w3 = Word("ORANGE", Coord(4, 0), "H")
12
- w4 = Word("WIND", Coord(0, 6), "V")
13
- w5 = Word("MOUSE", Coord(0, 8), "V")
14
- w6 = Word("PYTHON", Coord(0, 10), "V")
15
- p = Puzzle([w1, w2, w3, w4, w5, w6])
16
- state = GameState(
17
- grid_size=12,
18
- puzzle=p,
19
- revealed=set(),
20
- guessed=set(),
21
- score=0,
22
- last_action="",
23
- can_guess=False,
24
- )
25
- return state, p
26
-
27
- def test_reveal_and_guess_gating(self):
28
- state, puzzle = self.make_state()
29
- letter_map = build_letter_map(puzzle)
30
- # Can't guess before reveal
31
- ok, pts = guess_word(state, "TREE")
32
- self.assertFalse(ok)
33
- self.assertEqual(pts, 0)
34
- # Reveal one cell then guess
35
- reveal_cell(state, letter_map, Coord(0, 0))
36
- self.assertTrue(state.can_guess)
37
- ok, pts = guess_word(state, "TREE")
38
- self.assertTrue(ok)
39
- self.assertGreater(pts, 0)
40
- self.assertIn("TREE", state.guessed)
41
- self.assertFalse(state.can_guess)
42
-
43
- def test_game_over(self):
44
- state, puzzle = self.make_state()
45
- letter_map = build_letter_map(puzzle)
46
- # Guess all words after a reveal each time
47
- for w in puzzle.words:
48
- reveal_cell(state, letter_map, w.start)
49
- ok, _ = guess_word(state, w.text)
50
- self.assertTrue(ok)
51
- self.assertTrue(is_game_over(state))
52
-
53
-
54
- if __name__ == "__main__":
55
- unittest.main()