Surn commited on
Commit
07fc9c2
ยท
1 Parent(s): fedb9b2

Leaderboard Part 3

Browse files
pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
  [project]
2
  name = "wrdler"
3
- version = "0.2.0"
4
  description = "Wrdler vocabulary puzzle game - simplified version based on BattleWords with 8x6 grid, horizontal words only, no scope, 2 free letter guesses, and a settings-based daily/weekly leaderboard system. Features leaderboard UI, challenge sharing, and AI word lists."
5
  readme = "README.md"
6
  requires-python = ">=3.12,<3.13"
 
1
  [project]
2
  name = "wrdler"
3
+ version = "0.2.1"
4
  description = "Wrdler vocabulary puzzle game - simplified version based on BattleWords with 8x6 grid, horizontal words only, no scope, 2 free letter guesses, and a settings-based daily/weekly leaderboard system. Features leaderboard UI, challenge sharing, and AI word lists."
5
  readme = "README.md"
6
  requires-python = ">=3.12,<3.13"
wrdler/__init__.py CHANGED
@@ -9,5 +9,5 @@ Key differences from BattleWords:
9
  - Daily and weekly leaderboards
10
  """
11
 
12
- __version__ = "0.2.0"
13
  __all__ = ["models", "generator", "logic", "ui", "word_loader", "leaderboard", "leaderboard_page"]
 
9
  - Daily and weekly leaderboards
10
  """
11
 
12
+ __version__ = "0.2.1"
13
  __all__ = ["models", "generator", "logic", "ui", "word_loader", "leaderboard", "leaderboard_page"]
wrdler/leaderboard.py CHANGED
@@ -678,13 +678,57 @@ def check_qualification(
678
  return entry_diff > lowest_diff
679
 
680
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
681
  def submit_to_leaderboard(
682
  entry_type: EntryType,
683
  period_id: str,
684
  user_entry: UserEntry,
685
  settings: GameSettings,
686
  repo_id: Optional[str] = None
687
- ) -> Tuple[bool, Optional[int]]:
688
  """
689
  Submit a user entry to a leaderboard if it qualifies.
690
 
@@ -696,7 +740,10 @@ def submit_to_leaderboard(
696
  repo_id: Repository ID
697
 
698
  Returns:
699
- Tuple of (success, rank) where rank is 1-indexed position or None if didn't qualify
 
 
 
700
  """
701
  if repo_id is None:
702
  repo_id = HF_REPO_ID
@@ -714,7 +761,7 @@ def submit_to_leaderboard(
714
 
715
  if not qualifies:
716
  logger.info(f"? Score {user_entry.score} did not qualify for top {MAX_DISPLAY_ENTRIES} in {period_id}")
717
- return False, None
718
 
719
  # Add entry and sort
720
  leaderboard.users.append(user_entry)
@@ -732,15 +779,15 @@ def submit_to_leaderboard(
732
  logger.info(f"? Score {user_entry.score} was sorted out of top {MAX_DISPLAY_ENTRIES}")
733
  # Still save the entry (stored but not displayed)
734
  save_leaderboard(leaderboard, file_id, repo_id)
735
- return False, None
736
 
737
  # Save leaderboard
738
  if save_leaderboard(leaderboard, file_id, repo_id):
739
- logger.info(f"? Added to {entry_type} leaderboard at rank {rank}")
740
- return True, rank
741
  else:
742
  logger.error(f"? Failed to save leaderboard {period_id}")
743
- return False, None
744
 
745
 
746
  def submit_score_to_all_leaderboards(
@@ -771,8 +818,19 @@ def submit_score_to_all_leaderboards(
771
  Returns:
772
  Dict with results:
773
  {
774
- "daily": {"qualified": bool, "rank": int|None, "id": str},
775
- "weekly": {"qualified": bool, "rank": int|None, "id": str},
 
 
 
 
 
 
 
 
 
 
 
776
  "entry_uid": str,
777
  "settings": {...}
778
  }
@@ -794,7 +852,7 @@ def submit_score_to_all_leaderboards(
794
  )
795
 
796
  # Submit to daily
797
- daily_qualified, daily_rank = submit_to_leaderboard(
798
  "daily", daily_id, daily_entry, settings, repo_id
799
  )
800
 
@@ -809,13 +867,50 @@ def submit_score_to_all_leaderboards(
809
  )
810
 
811
  # Submit to weekly
812
- weekly_qualified, weekly_rank = submit_to_leaderboard(
813
  "weekly", weekly_id, weekly_entry, settings, repo_id
814
  )
815
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
816
  results = {
817
- "daily": {"qualified": daily_qualified, "rank": daily_rank, "id": daily_id},
818
- "weekly": {"qualified": weekly_qualified, "rank": weekly_rank, "id": weekly_id},
 
 
 
 
 
 
 
 
 
 
 
819
  "entry_uid": daily_entry.uid,
820
  "settings": settings.to_dict()
821
  }
 
678
  return entry_diff > lowest_diff
679
 
680
 
681
+ def get_leaderboard_url(entry_type: EntryType, period_id: str, file_id: str, base_url: str = None) -> str:
682
+ """
683
+ Generate a URL to view a specific leaderboard on the leaderboard page.
684
+
685
+ Args:
686
+ entry_type: "daily" or "weekly"
687
+ period_id: Date or week identifier
688
+ file_id: File identifier (e.g., "classic-classic-0")
689
+ base_url: Optional base URL override
690
+
691
+ Returns:
692
+ URL to the leaderboard page with appropriate query parameters
693
+ """
694
+ from urllib.parse import urlencode
695
+
696
+ # Determine base URL (use current host or default to production)
697
+ if base_url is None:
698
+ # Try to get from environment or default to production space
699
+ import os
700
+ from wrdler.modules.constants import SPACE_NAME
701
+
702
+ # Check if running locally
703
+ if os.environ.get("IS_LOCAL", "true").lower() == "true":
704
+ port = os.environ.get("PORT") or os.environ.get("STREAMLIT_SERVER_PORT") or "8501"
705
+ host = os.environ.get("HOST") or os.environ.get("STREAMLIT_SERVER_ADDRESS") or "localhost"
706
+ base_url = f"http://{host}:{port}"
707
+ else:
708
+ # Production HuggingFace Space URL
709
+ space = (SPACE_NAME or "surn/wrdler").lower().replace("/", "-")
710
+ base_url = f"https://{space}.hf.space"
711
+
712
+ # Build query parameters based on entry type
713
+ if entry_type == "daily":
714
+ params = {"page": "daily", "gidd": file_id}
715
+ elif entry_type == "weekly":
716
+ params = {"page": "weekly", "gidw": file_id}
717
+ else:
718
+ # Fallback to generic page view
719
+ params = {"page": entry_type}
720
+
721
+ query_string = urlencode(params)
722
+ return f"{base_url}/?{query_string}"
723
+
724
+
725
  def submit_to_leaderboard(
726
  entry_type: EntryType,
727
  period_id: str,
728
  user_entry: UserEntry,
729
  settings: GameSettings,
730
  repo_id: Optional[str] = None
731
+ ) -> Tuple[bool, Optional[int], Optional[str]]:
732
  """
733
  Submit a user entry to a leaderboard if it qualifies.
734
 
 
740
  repo_id: Repository ID
741
 
742
  Returns:
743
+ Tuple of (success, rank, file_id) where:
744
+ - success: True if qualified and saved
745
+ - rank: 1-indexed position or None if didn't qualify
746
+ - file_id: File identifier for this leaderboard or None if didn't qualify
747
  """
748
  if repo_id is None:
749
  repo_id = HF_REPO_ID
 
761
 
762
  if not qualifies:
763
  logger.info(f"? Score {user_entry.score} did not qualify for top {MAX_DISPLAY_ENTRIES} in {period_id}")
764
+ return False, None, None
765
 
766
  # Add entry and sort
767
  leaderboard.users.append(user_entry)
 
779
  logger.info(f"? Score {user_entry.score} was sorted out of top {MAX_DISPLAY_ENTRIES}")
780
  # Still save the entry (stored but not displayed)
781
  save_leaderboard(leaderboard, file_id, repo_id)
782
+ return False, None, None
783
 
784
  # Save leaderboard
785
  if save_leaderboard(leaderboard, file_id, repo_id):
786
+ logger.info(f"? Added to {entry_type} leaderboard at rank {rank}, file_id: {file_id}")
787
+ return True, rank, file_id
788
  else:
789
  logger.error(f"? Failed to save leaderboard {period_id}")
790
+ return False, None, None
791
 
792
 
793
  def submit_score_to_all_leaderboards(
 
818
  Returns:
819
  Dict with results:
820
  {
821
+ "daily": {
822
+ "qualified": bool,
823
+ "rank": int|None,
824
+ "id": str,
825
+ "file_id": str|None
826
+ },
827
+ "weekly": {
828
+ "qualified": bool,
829
+ "rank": int|None,
830
+ "id": str,
831
+ "file_id": str|None
832
+ },
833
+ "leaderboard_url": str|None, # Combined URL with both gidd and gidw
834
  "entry_uid": str,
835
  "settings": {...}
836
  }
 
852
  )
853
 
854
  # Submit to daily
855
+ daily_qualified, daily_rank, daily_file_id = submit_to_leaderboard(
856
  "daily", daily_id, daily_entry, settings, repo_id
857
  )
858
 
 
867
  )
868
 
869
  # Submit to weekly
870
+ weekly_qualified, weekly_rank, weekly_file_id = submit_to_leaderboard(
871
  "weekly", weekly_id, weekly_entry, settings, repo_id
872
  )
873
 
874
+ # Build combined leaderboard URL with both gidd and gidw if either qualified
875
+ leaderboard_url = None
876
+ if daily_file_id or weekly_file_id:
877
+ from urllib.parse import urlencode
878
+ import os
879
+ from wrdler.modules.constants import SPACE_NAME
880
+
881
+ # Determine base URL
882
+ if os.environ.get("IS_LOCAL", "true").lower() == "true":
883
+ port = os.environ.get("PORT") or os.environ.get("STREAMLIT_SERVER_PORT") or "8501"
884
+ host = os.environ.get("HOST") or os.environ.get("STREAMLIT_SERVER_ADDRESS") or "localhost"
885
+ base_url = f"http://{host}:{port}"
886
+ else:
887
+ space = (SPACE_NAME or "surn/wrdler").lower().replace("/", "-")
888
+ base_url = f"https://{space}.hf.space"
889
+
890
+ # Build query parameters with both file_ids (when available)
891
+ params = {}
892
+ if daily_file_id:
893
+ params["gidd"] = daily_file_id
894
+ if weekly_file_id:
895
+ params["gidw"] = weekly_file_id
896
+
897
+ query_string = urlencode(params)
898
+ leaderboard_url = f"{base_url}/?{query_string}"
899
+
900
  results = {
901
+ "daily": {
902
+ "qualified": daily_qualified,
903
+ "rank": daily_rank,
904
+ "id": daily_id,
905
+ "file_id": daily_file_id
906
+ },
907
+ "weekly": {
908
+ "qualified": weekly_qualified,
909
+ "rank": weekly_rank,
910
+ "id": weekly_id,
911
+ "file_id": weekly_file_id
912
+ },
913
+ "leaderboard_url": leaderboard_url,
914
  "entry_uid": daily_entry.uid,
915
  "settings": settings.to_dict()
916
  }
wrdler/leaderboard_page.py CHANGED
@@ -124,41 +124,90 @@ def render_leaderboard_page(default_tab: str = "daily"):
124
  """Render the full leaderboard page.
125
 
126
  Args:
127
- default_tab: Which tab to show by default ("daily" or "weekly")
128
  """
129
  game_title = APP_SETTINGS.get("game_title", "Wrdler")
130
  st.title(f"๐Ÿ† {game_title} Leaderboards")
131
 
132
- # Inject CSS to style the active tab
133
  st.markdown(
134
  """
135
  <style>
136
- /* Target Streamlit tabs: pick the selected tab button/link */
137
- div[role="tablist"] p {padding: 0.5em 1em !important;}
138
- div[role="tablist"] [aria-selected="true"] {
139
- color: #ffffff !important;
140
- border: 1px solid #4CAF50 !important;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
  }
142
- div[role="tablist"] [aria-selected="true"] p {font-weight: bold !important;}
143
  </style>
144
  """,
145
  unsafe_allow_html=True
146
  )
147
 
148
- # Tab selection - set default based on parameter
149
- tab_names = ["๐Ÿ“… Daily", "๐Ÿ“† Weekly", "๐Ÿ“š History"]
 
 
 
150
 
151
- # Create tabs
152
- tab1, tab2, tab3 = st.tabs(tab_names)
 
 
 
 
 
 
 
 
 
 
153
 
154
- with tab1:
155
- _render_daily_tab()
 
 
 
 
 
 
 
 
 
 
156
 
157
- with tab2:
 
 
 
 
 
158
  _render_weekly_tab()
159
-
160
- with tab3:
161
  _render_history_tab()
 
 
 
162
 
163
 
164
  def _render_daily_tab():
@@ -181,7 +230,7 @@ def _render_daily_tab():
181
  except ValueError:
182
  date_title = date_id
183
 
184
- # List all settings folders (file_ids) for this period
185
  settings_list = list_settings_for_period("daily", date_id)
186
  if not settings_list:
187
  with st.expander(date_title, expanded=False):
@@ -305,11 +354,129 @@ def _render_history_tab():
305
  st.info("No weekly leaderboards found.")
306
 
307
 
308
- # Entry point for standalone testing
309
- if __name__ == "__main__":
310
- st.set_page_config(
311
- page_title="Wrdler Leaderboards",
312
- page_icon="๐Ÿ†",
313
- layout="wide"
314
- )
315
- render_leaderboard_page()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
  """Render the full leaderboard page.
125
 
126
  Args:
127
+ default_tab: Which tab to show by default ("today", "daily", "weekly", or "history")
128
  """
129
  game_title = APP_SETTINGS.get("game_title", "Wrdler")
130
  st.title(f"๐Ÿ† {game_title} Leaderboards")
131
 
132
+ # Inject CSS for navigation links
133
  st.markdown(
134
  """
135
  <style>
136
+ /* Tab navigation links */
137
+ .bw-tab-nav {
138
+ display: flex;
139
+ gap: 0.5rem;
140
+ margin-bottom: 1rem;
141
+ flex-wrap: wrap;
142
+ }
143
+ .bw-tab-nav a {
144
+ padding: 0.5rem 1rem;
145
+ border-radius: 0.5rem;
146
+ text-decoration: none;
147
+ background: rgba(29, 100, 200, 0.3);
148
+ color: #d7faff;
149
+ border: 1px solid rgba(215, 250, 255, 0.3);
150
+ transition: all 0.2s ease;
151
+ }
152
+ .bw-tab-nav a:hover {
153
+ background: rgba(29, 100, 200, 0.6);
154
+ border-color: rgba(215, 250, 255, 0.6);
155
+ }
156
+ .bw-tab-nav a.active {
157
+ background: rgba(32, 212, 108, 0.3);
158
+ border-color: rgba(32, 212, 108, 0.5);
159
+ color: #ffffff;
160
+ font-weight: bold;
161
  }
 
162
  </style>
163
  """,
164
  unsafe_allow_html=True
165
  )
166
 
167
+ # Check for query parameters to determine which tab content to show
168
+ try:
169
+ params = st.query_params if hasattr(st, "query_params") else {}
170
+ except Exception:
171
+ params = {}
172
 
173
+ page_param = params.get("page", "")
174
+ gidd = params.get("gidd", None)
175
+ gidw = params.get("gidw", None)
176
+
177
+ # Determine active tab based on query params
178
+ # Priority: gidd/gidw -> page param -> default_tab
179
+ if gidd or gidw:
180
+ active_tab = "today"
181
+ elif page_param in ["today", "daily", "weekly", "history"]:
182
+ active_tab = page_param
183
+ else:
184
+ active_tab = default_tab if default_tab in ["today", "daily", "weekly", "history"] else "daily"
185
 
186
+ # Render custom navigation links (these work as page navigation)
187
+ st.markdown(
188
+ f"""
189
+ <div class="bw-tab-nav">
190
+ <a href="?page=today" target="_self" class="{'active' if active_tab == 'today' else ''}">๐ŸŒŸ Today</a>
191
+ <a href="?page=daily" target="_self" class="{'active' if active_tab == 'daily' else ''}">๐Ÿ“… Daily</a>
192
+ <a href="?page=weekly" target="_self" class="{'active' if active_tab == 'weekly' else ''}">๐Ÿ“† Weekly</a>
193
+ <a href="?page=history" target="_self" class="{'active' if active_tab == 'history' else ''}">๐Ÿ“š History</a>
194
+ </div>
195
+ """,
196
+ unsafe_allow_html=True
197
+ )
198
 
199
+ # Render content based on active tab
200
+ if active_tab == "today":
201
+ _render_today_tab()
202
+ elif active_tab == "daily":
203
+ _render_daily_tab()
204
+ elif active_tab == "weekly":
205
  _render_weekly_tab()
206
+ elif active_tab == "history":
 
207
  _render_history_tab()
208
+ else:
209
+ # Fallback to daily
210
+ _render_daily_tab()
211
 
212
 
213
  def _render_daily_tab():
 
230
  except ValueError:
231
  date_title = date_id
232
 
233
+ # List all settings folders (file_id) for this period
234
  settings_list = list_settings_for_period("daily", date_id)
235
  if not settings_list:
236
  with st.expander(date_title, expanded=False):
 
354
  st.info("No weekly leaderboards found.")
355
 
356
 
357
+ def _render_today_tab():
358
+ """Render today's leaderboards tab - shows current daily and weekly leaderboards.
359
+
360
+ If query string parameters are present:
361
+ - gidd: Show only the specified daily leaderboard (file_id)
362
+ - gidw: Show only the specified weekly leaderboard (file_id)
363
+
364
+ Otherwise, show all current daily and weekly leaderboards in two columns.
365
+ """
366
+ # Get query parameters
367
+ try:
368
+ params = st.query_params if hasattr(st, "query_params") else {}
369
+ except Exception:
370
+ params = {}
371
+
372
+ gidd = params.get("gidd", None)
373
+ gidw = params.get("gidw", None)
374
+
375
+ # If both query params are present, show filtered view
376
+ if gidd or gidw:
377
+ st.header("๐ŸŽฏ Today's Leaderboards (Filtered)")
378
+
379
+ # Use two columns for filtered view as well
380
+ col1, col2 = st.columns(2)
381
+
382
+ # Show daily leaderboard if gidd is specified
383
+ with col1:
384
+ if gidd:
385
+ st.subheader("๐Ÿ“… Daily")
386
+ daily_id = get_current_daily_id()
387
+ lb = load_leaderboard("daily", daily_id, gidd)
388
+
389
+ if lb:
390
+ try:
391
+ date_obj = datetime.strptime(daily_id, "%Y-%m-%d")
392
+ date_title = "๐ŸŒŸ Today " + date_obj.strftime("%A, %B %d, %Y")
393
+ except ValueError:
394
+ date_title = daily_id
395
+
396
+ header_suffix = _settings_badge(lb)
397
+ with st.expander(f"{date_title} {header_suffix}", expanded=True):
398
+ st.caption(f"Settings: mode={lb.game_mode}, source={lb.wordlist_source}, show_incorrect={lb.show_incorrect_guesses}, free_letters={lb.enable_free_letters}, spacer={lb.puzzle_options.get('spacer', 0)}, overlap={lb.puzzle_options.get('may_overlap', False)}")
399
+ _render_leaderboard_table(lb, "")
400
+ else:
401
+ st.warning(f"Daily leaderboard not found: {gidd}")
402
+
403
+ # Show weekly leaderboard if gidw is specified
404
+ with col2:
405
+ if gidw:
406
+ st.subheader("๐Ÿ“† Weekly")
407
+ weekly_id = get_current_weekly_id()
408
+ lb = load_leaderboard("weekly", weekly_id, gidw)
409
+
410
+ if lb:
411
+ try:
412
+ year, week = weekly_id.split("-W")
413
+ week_title = f"Week {int(week)}, {year}"
414
+ except ValueError:
415
+ week_title = weekly_id
416
+
417
+ header_suffix = _settings_badge(lb)
418
+ with st.expander(f"{week_title} {header_suffix}", expanded=True):
419
+ st.caption(f"Settings: mode={lb.game_mode}, source={lb.wordlist_source}, show_incorrect={lb.show_incorrect_guesses}, free_letters={lb.enable_free_letters}, spacer={lb.puzzle_options.get('spacer', 0)}, overlap={lb.puzzle_options.get('may_overlap', False)}")
420
+ _render_leaderboard_table(lb, "")
421
+ else:
422
+ st.warning(f"Weekly leaderboard not found: {gidw}")
423
+ else:
424
+ # Show all current leaderboards (default view) in two columns
425
+ st.header("๐ŸŒŸ Today's Leaderboards")
426
+ st.write("Current daily and weekly leaderboards. Resets: Daily at UTC midnight, Weekly on Monday.")
427
+
428
+ col1, col2 = st.columns(2)
429
+
430
+ # Left column: Current Daily Leaderboards
431
+ with col1:
432
+ st.subheader("๐Ÿ“… Daily")
433
+ daily_id = get_current_daily_id()
434
+
435
+ try:
436
+ date_obj = datetime.strptime(daily_id, "%Y-%m-%d")
437
+ date_title = "Today " + date_obj.strftime("%A, %B %d, %Y")
438
+ except ValueError:
439
+ date_title = daily_id
440
+
441
+ settings_list = list_settings_for_period("daily", daily_id)
442
+ if not settings_list:
443
+ st.info("No daily leaderboards found for today.")
444
+ else:
445
+ for settings_info in settings_list:
446
+ file_id = settings_info["file_id"]
447
+ lb = load_leaderboard("daily", daily_id, file_id)
448
+ header_suffix = ""
449
+ if lb is not None:
450
+ header_suffix = _settings_badge(lb)
451
+ expander_title = f"{date_title} {header_suffix}" if header_suffix else date_title
452
+ with st.expander(expander_title, expanded=True):
453
+ if lb is not None:
454
+ st.caption(f"Settings: mode={lb.game_mode}, source={lb.wordlist_source}, show_incorrect={lb.show_incorrect_guesses}, free_letters={lb.enable_free_letters}, spacer={lb.puzzle_options.get('spacer', 0)}, overlap={lb.puzzle_options.get('may_overlap', False)}")
455
+ _render_leaderboard_table(lb, "")
456
+
457
+ # Right column: Current Weekly Leaderboards
458
+ with col2:
459
+ st.subheader("๐Ÿ“† Weekly")
460
+ weekly_id = get_current_weekly_id()
461
+
462
+ try:
463
+ year, week = weekly_id.split("-W")
464
+ week_title = f"Week {int(week)}, {year}"
465
+ except ValueError:
466
+ week_title = weekly_id
467
+
468
+ settings_list = list_settings_for_period("weekly", weekly_id)
469
+ if not settings_list:
470
+ st.info("No weekly leaderboards found for this week.")
471
+ else:
472
+ for settings_info in settings_list:
473
+ file_id = settings_info["file_id"]
474
+ lb = load_leaderboard("weekly", weekly_id, file_id)
475
+ header_suffix = ""
476
+ if lb is not None:
477
+ header_suffix = _settings_badge(lb)
478
+ expander_title = f"{week_title} {header_suffix}" if header_suffix else week_title
479
+ with st.expander(expander_title, expanded=True):
480
+ if lb is not None:
481
+ st.caption(f"Settings: mode={lb.game_mode}, source={lb.wordlist_source}, show_incorrect={lb.show_incorrect_guesses}, free_letters={lb.enable_free_letters}, spacer={lb.puzzle_options.get('spacer', 0)}, overlap={lb.puzzle_options.get('may_overlap', False)}")
482
+ _render_leaderboard_table(lb, "")
wrdler/logic.py CHANGED
@@ -140,6 +140,12 @@ def guess_word(state: GameState, guess_text: str) -> Tuple[bool, int]:
140
  state.last_action = "You must reveal a cell before guessing."
141
  return False, 0
142
  guess = (guess_text or "").strip().upper()
 
 
 
 
 
 
143
  if not (len(guess) in (4, 5, 6) and guess.isalpha()):
144
  state.last_action = "Guess must be Aโ€“Z and length 4, 5, or 6."
145
  state.can_guess = False
 
140
  state.last_action = "You must reveal a cell before guessing."
141
  return False, 0
142
  guess = (guess_text or "").strip().upper()
143
+
144
+ # Prevent blank or too-short guesses from being processed
145
+ # Return silently without setting last_action or affecting can_guess
146
+ if len(guess) < 4:
147
+ return False, 0
148
+
149
  if not (len(guess) in (4, 5, 6) and guess.isalpha()):
150
  state.last_action = "Guess must be Aโ€“Z and length 4, 5, or 6."
151
  state.can_guess = False
wrdler/ui.py CHANGED
@@ -17,7 +17,7 @@ from datetime import datetime
17
  from .generator import generate_puzzle, sort_word_file
18
  from .logic import build_letter_map, reveal_cell, reveal_free_letter, 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, load_word_list_or_ai, compute_word_difficulties
21
  from .version_info import versions_html # version info footer
22
  from .audio import (
23
  _get_music_dir,
@@ -539,6 +539,7 @@ border-radius: 50% !important;
539
  .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 10px; }
540
  .st-emotion-cache-1n6tfoc::before { content: ''; position: absolute; top: 0; left: 0; right: 0; bottom: 0; border-radius: 10px; margin: 5px;}
541
  .st-key-guess_input, .st-key-guess_submit { flex-direction: row; display: flex; flex-wrap: wrap; justify-content: flex-start; align-items: flex-end; }
 
542
  .st-emotion-cache-18kf3ut [class^="st-key-free_letter_"] [data-testid="stButton"] > button, button.st-emotion-cache-1ojn1jd {
543
  aspect-ratio: auto !important;
544
  position:relative;
@@ -715,7 +716,7 @@ def _init_session() -> None:
715
  st.session_state.points_by_word = {}
716
  st.session_state.letter_map = build_letter_map(puzzle)
717
  st.session_state.initialized = True
718
- st.session_state.start_time = datetime.now() # Set timer on first game
719
  st.session_state.end_time = None
720
  # Ensure game_mode is set
721
  if "game_mode" not in st.session_state:
@@ -737,6 +738,10 @@ def _init_session() -> None:
737
  if "free_letters_used" not in st.session_state:
738
  st.session_state.free_letters_used = 0
739
 
 
 
 
 
740
  def _new_game() -> None:
741
  """
742
  Create a fresh puzzle using CURRENT settings.
@@ -787,7 +792,7 @@ def _new_game() -> None:
787
  st.session_state.can_guess = False
788
  st.session_state.points_by_word = {}
789
  st.session_state.letter_map = build_letter_map(puzzle)
790
- st.session_state.start_time = datetime.now()
791
  st.session_state.end_time = None
792
  st.session_state.incorrect_guesses = []
793
  st.session_state.free_letters = set()
@@ -1044,17 +1049,63 @@ def _render_sidebar():
1044
  # Show topic input if AI mode selected
1045
  if selected == "AI Generated":
1046
  st.session_state.use_ai_wordlist = True
1047
- st.text_input(
1048
- "Topic",
1049
- value=st.session_state.ai_topic,
1050
- key="ai_topic",
1051
- placeholder="e.g., Ocean Life, Space, History",
1052
- help="Enter a topic for AI-generated words",
1053
- on_change=_on_game_option_change,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1054
  )
 
 
1055
  else:
1056
  st.session_state.use_ai_wordlist = False
1057
  st.session_state.selected_wordlist = selected
 
 
 
 
 
 
 
 
1058
 
1059
  # Only show Sort button for file-based wordlists
1060
  if not st.session_state.use_ai_wordlist:
@@ -1064,14 +1115,52 @@ def _render_sidebar():
1064
  st.info("No word lists found in words/ directory. Using AI or built-in fallback.")
1065
  # Force AI mode if no files available
1066
  st.session_state.use_ai_wordlist = True
1067
- st.text_input(
1068
- "Topic",
1069
- value=st.session_state.ai_topic,
1070
- key="ai_topic",
1071
- placeholder="e.g., Ocean Life, Space, History",
1072
- help="Enter a topic for AI-generated words",
1073
- on_change=_on_game_option_change,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1074
  )
 
 
1075
 
1076
  # Add Show Grid ticks option
1077
  if "show_grid_ticks" not in st.session_state:
@@ -1117,9 +1206,9 @@ def _render_sidebar():
1117
  # --- Add sound effects volume ---
1118
  if "effects_volume" not in st.session_state:
1119
  st.session_state.effects_volume = 25
1120
- # --- Add enable sound effects ---
1121
  if "enable_sound_effects" not in st.session_state:
1122
- st.session_state.enable_sound_effects = True
1123
  st.checkbox("Enable Sound Effects", value=st.session_state.enable_sound_effects, key="enable_sound_effects")
1124
 
1125
  enabled = st.checkbox("Enable music", value=st.session_state.music_enabled, key="music_enabled")
@@ -1185,6 +1274,11 @@ def _render_sidebar():
1185
 
1186
  def _on_free_letter_click(letter: str, state: GameState) -> None:
1187
  """Callback for free letter button clicks."""
 
 
 
 
 
1188
  # Reveal this free letter
1189
  count = reveal_free_letter(state, st.session_state.letter_map, letter)
1190
  _sync_back(state)
@@ -1366,6 +1460,11 @@ def _render_grid(state: GameState, letter_map, show_grid_ticks: bool = True):
1366
  clicked = coord
1367
 
1368
  if clicked is not None:
 
 
 
 
 
1369
  reveal_cell(state, letter_map, clicked)
1370
  # Auto-mark and award base points for any fully revealed words
1371
  if auto_mark_completed_words(state):
@@ -1494,6 +1593,10 @@ def _render_guess_form(state: GameState):
1494
  width: auto !important;
1495
  }
1496
  /* Ensure tooltip content wraps and preserves newlines for vertical stacking */
 
 
 
 
1497
  div[data-testid="stTooltipContent"], div[role="tooltip"] {
1498
  white-space: pre-wrap !important;
1499
  text-align: left !important;
@@ -1524,6 +1627,10 @@ def _render_guess_form(state: GameState):
1524
  font-size: 0.8rem;
1525
  cursor: help;
1526
  }
 
 
 
 
1527
  .st-key-guess_input .stTooltipIcon .stTooltipHoverTarget:hover::after {
1528
  color: #ff9999;
1529
  }
@@ -1562,16 +1669,19 @@ def _render_guess_form(state: GameState):
1562
  # )
1563
 
1564
  if submitted:
1565
- correct, _ = guess_word(state, guess_text)
1566
- _sync_back(state)
1567
- if correct:
1568
- play_sound_effect("correct_guess", volume=(st.session_state.get("effects_volume", 50) / 100))
1569
- else:
1570
- # Update incorrect guesses list - keep only last 10
1571
- st.session_state.incorrect_guesses.append(guess_text)
1572
- st.session_state.incorrect_guesses = st.session_state.incorrect_guesses[-10:]
1573
- play_sound_effect("incorrect_guess", volume=(st.session_state.get("effects_volume", 50) / 100))
1574
- st.rerun()
 
 
 
1575
 
1576
 
1577
  # -------------------- Score Panel --------------------
@@ -1615,13 +1725,22 @@ def _render_score_panel(state: GameState):
1615
  )
1616
  rows_html.append(row_html)
1617
 
1618
- # Initial time shown from server; JS will tick client-side
1619
  now = datetime.now()
1620
- start = state.start_time or now
1621
- end = state.end_time or (now if is_game_over(state) else None)
1622
- elapsed = (end or now) - start
1623
- mins, secs = divmod(int(elapsed.total_seconds()), 60)
1624
- timer_str = f"{mins:02d}:{secs:02d}"
 
 
 
 
 
 
 
 
 
1625
 
1626
  span_id = f"bw-timer-{getattr(state.puzzle, 'uid', 'default')}"
1627
  timer_span_html = f"<span id=\"{span_id}\" style='font-size:1rem; color:#ffffff;'>&nbsp;โฑ {timer_str}</span>"
@@ -1633,10 +1752,6 @@ def _render_score_panel(state: GameState):
1633
  )
1634
  rows_html.append(total_row_html)
1635
 
1636
- # Build a self-contained HTML document so JS runs inside the component iframe
1637
- start_ms = int(start.timestamp() * 1000)
1638
- end_ms = int(end.timestamp() * 1000) if end else None
1639
-
1640
  table_inner = "".join(rows_html)
1641
  html_doc = f"""
1642
  <div class='bw-score-panel-container'>
@@ -1661,7 +1776,13 @@ def _render_score_panel(state: GameState):
1661
  var span = document.getElementById("{span_id}");
1662
  if (!span) return;
1663
  var startMs = {start_ms};
1664
- var endMs = {"null" if end_ms is None else end_ms};
 
 
 
 
 
 
1665
 
1666
  function fmt(ms) {{
1667
  var total = Math.max(0, Math.floor(ms / 1000));
@@ -1726,7 +1847,7 @@ def _game_over_content(state: GameState) -> None:
1726
  end = state.end_time or datetime.now()
1727
  elapsed = end - start
1728
  elapsed_seconds = int(elapsed.total_seconds())
1729
- mins, secs = divmod(elapsed_seconds, 60)
1730
  timer_str = f"{mins:02d}:{secs:02d}"
1731
 
1732
  # Compute optional word list difficulty for current run
@@ -1793,7 +1914,7 @@ def _game_over_content(state: GameState) -> None:
1793
  }
1794
  .m-2 {display: flex;flex-direction: row;justify-content: space-evenly;flex-wrap: wrap;font-size: 1.25rem; font-weight: bold;}
1795
  .st-key-new_game_btn_dialog button, .st-key-close_game_over button {
1796
- height: 50px !important;
1797
  }
1798
  .st-key-new_game_btn button {
1799
  transition: none !important;
@@ -1944,23 +2065,24 @@ def _game_over_content(state: GameState) -> None:
1944
  # If the unified submit fails, try a best-effort weekly submit below
1945
  results = {"daily": {"qualified": False, "rank": None, "id": None}, "weekly": {"qualified": False, "rank": None, "id": None}}
1946
 
1947
- # Check weekly result and attempt a fallback single-weekly submission if missing
1948
- weekly_info = results.get("weekly") or {}
1949
- if weekly_info.get("id") is None or (weekly_info.get("qualified") is False and weekly_info.get("rank") is None):
1950
- try:
1951
- weekly_id = get_current_weekly_id()
1952
- # Build a lightweight UserEntry via submit_to_leaderboard (it returns (success, rank))
1953
- submit_to_leaderboard(
1954
- "weekly",
1955
- weekly_id,
1956
- None, # user_entry will be constructed inside submit_to_leaderboard if used directly; instead construct minimal entry below
1957
- settings,
1958
- )
1959
- except Exception:
1960
- # swallow fallback errors - main attempt already logged by leaderboard module
1961
- pass
1962
 
1963
  return results
 
1964
 
1965
  # Check if share URL already generated
1966
  if "share_url" not in st.session_state or st.session_state.get("share_url") is None:
@@ -2117,17 +2239,42 @@ def _game_over_content(state: GameState) -> None:
2117
  lb_results = st.session_state.get("leaderboard_results", {})
2118
  daily_info = lb_results.get("daily", {})
2119
  weekly_info = lb_results.get("weekly", {})
 
2120
 
 
2121
  status_parts = []
2122
  if daily_info.get("qualified"):
2123
- status_parts.append(f"Daily #{daily_info['rank']}")
2124
  if weekly_info.get("qualified"):
2125
- status_parts.append(f"Weekly #{weekly_info['rank']}")
2126
 
2127
- if status_parts:
2128
- st.info(f"๐Ÿ† Leaderboard: {', '.join(status_parts)}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2129
  else:
2130
- st.info("๐Ÿ† Submitted to leaderboards")
2131
 
2132
  st.markdown("---")
2133
 
@@ -2236,21 +2383,32 @@ def _on_wordlist_change() -> None:
2236
  # Preserve current AI topic or use default
2237
  if "ai_topic" not in st.session_state:
2238
  st.session_state.ai_topic = "English"
 
 
2239
  else:
2240
  st.session_state.use_ai_wordlist = False
2241
  st.session_state.selected_wordlist = selected
2242
-
2243
- # Trigger new game with updated wordlist
 
 
 
 
 
 
 
 
2244
  _on_game_option_change()
2245
 
2246
  def _render_footer(current_page: str = "play"):
2247
  """Render footer with navigation links to leaderboards and main game.
2248
 
2249
  Args:
2250
- current_page: Which page is currently active ("play", "daily", "weekly")
2251
  """
2252
  # Determine which link should be highlighted as active
2253
  play_active = "active" if current_page == "play" else ""
 
2254
  daily_active = "active" if current_page == "daily" else ""
2255
  weekly_active = "active" if current_page == "weekly" else ""
2256
 
@@ -2269,10 +2427,12 @@ def _render_footer(current_page: str = "play"):
2269
 
2270
  # Build URLs with game_id if in challenge mode
2271
  if game_id:
 
2272
  daily_url = f"?page=daily&game_id={game_id}"
2273
  weekly_url = f"?page=weekly&game_id={game_id}"
2274
  play_url = f"?game_id={game_id}"
2275
  else:
 
2276
  daily_url = "?page=daily"
2277
  weekly_url = "?page=weekly"
2278
  play_url = "/"
@@ -2294,15 +2454,15 @@ def _render_footer(current_page: str = "play"):
2294
  display: flex;
2295
  justify-content: center;
2296
  align-items: center;
2297
- gap: 2rem;
2298
  flex-wrap: wrap;
2299
  }}
2300
  .bw-footer-nav a {{
2301
  color: #d7faff;
2302
  text-decoration: none;
2303
  font-weight: 600;
2304
- font-size: 0.9rem;
2305
- padding: 0.5rem 1rem;
2306
  border-radius: 0.5rem;
2307
  background: rgba(29, 100, 200, 0.3);
2308
  border: 1px solid rgba(215, 250, 255, 0.3);
@@ -2324,18 +2484,19 @@ def _render_footer(current_page: str = "play"):
2324
  }}
2325
  @media (max-width: 640px) {{
2326
  .bw-footer-nav {{
2327
- gap: 0.75rem;
2328
  }}
2329
  .bw-footer-nav a {{
2330
- font-size: 0.8rem;
2331
- padding: 0.4rem 0.75rem;
2332
  }}
2333
  }}
2334
  </style>
2335
  <div class="bw-footer">
2336
  <nav class="bw-footer-nav">
2337
- <a href="{daily_url}" title="View Daily Leaderboards" target="_self" class="{daily_active}">๐Ÿ“… Daily Leaderboards</a>
2338
- <a href="{weekly_url}" title="View Weekly Leaderboards" target="_self" class="{weekly_active}">๐Ÿ“† Weekly Leaderboards</a>
 
2339
  <a href="{play_url}" title="Play Wrdler" target="_self" class="{play_active}">๐ŸŽฎ Play</a>
2340
  </nav>
2341
  </div>
@@ -2441,4 +2602,4 @@ def run_app():
2441
  state = _to_state()
2442
  if is_game_over(state) and not st.session_state.get("hide_gameover_overlay", False):
2443
  _render_game_over(state)
2444
- _render_footer(current_page="play")
 
17
  from .generator import generate_puzzle, sort_word_file
18
  from .logic import build_letter_map, reveal_cell, reveal_free_letter, 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, load_word_list_or_ai, compute_word_difficulties, get_wordlist_info
21
  from .version_info import versions_html # version info footer
22
  from .audio import (
23
  _get_music_dir,
 
539
  .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 10px; }
540
  .st-emotion-cache-1n6tfoc::before { content: ''; position: absolute; top: 0; left: 0; right: 0; bottom: 0; border-radius: 10px; margin: 5px;}
541
  .st-key-guess_input, .st-key-guess_submit { flex-direction: row; display: flex; flex-wrap: wrap; justify-content: flex-start; align-items: flex-end; }
542
+ .st-key-guess_input [id^="text_input"] {max-width: 80px;}
543
  .st-emotion-cache-18kf3ut [class^="st-key-free_letter_"] [data-testid="stButton"] > button, button.st-emotion-cache-1ojn1jd {
544
  aspect-ratio: auto !important;
545
  position:relative;
 
716
  st.session_state.points_by_word = {}
717
  st.session_state.letter_map = build_letter_map(puzzle)
718
  st.session_state.initialized = True
719
+ st.session_state.start_time = None # Timer starts on first action (grid click or free letter)
720
  st.session_state.end_time = None
721
  # Ensure game_mode is set
722
  if "game_mode" not in st.session_state:
 
738
  if "free_letters_used" not in st.session_state:
739
  st.session_state.free_letters_used = 0
740
 
741
+ # --- Add enable sound effects ---
742
+ if "enable_sound_effects" not in st.session_state:
743
+ st.session_state.enable_sound_effects = False
744
+
745
  def _new_game() -> None:
746
  """
747
  Create a fresh puzzle using CURRENT settings.
 
792
  st.session_state.can_guess = False
793
  st.session_state.points_by_word = {}
794
  st.session_state.letter_map = build_letter_map(puzzle)
795
+ st.session_state.start_time = None # Timer starts on first action
796
  st.session_state.end_time = None
797
  st.session_state.incorrect_guesses = []
798
  st.session_state.free_letters = set()
 
1049
  # Show topic input if AI mode selected
1050
  if selected == "AI Generated":
1051
  st.session_state.use_ai_wordlist = True
1052
+
1053
+ # Topic input and Generate button in columns
1054
+ topic_col, gen_col = st.columns([3, 1], gap="small")
1055
+
1056
+ with topic_col:
1057
+ st.text_input(
1058
+ "Topic",
1059
+ value=st.session_state.ai_topic,
1060
+ key="ai_topic",
1061
+ placeholder="e.g., Ocean Life, Space, History",
1062
+ help="Enter a topic for AI-generated words",
1063
+ # Remove on_change to prevent automatic generation
1064
+ )
1065
+
1066
+ with gen_col:
1067
+ # Add custom CSS for the generate button
1068
+ st.markdown(
1069
+ """
1070
+ <style>
1071
+ .st-key-ai_generate_btn {
1072
+ margin-top: 1.85rem; /* Align with text input */
1073
+ }
1074
+ .st-key-ai_generate_btn button {
1075
+ aspect-ratio: auto !important;
1076
+ height: auto !important;
1077
+ padding: 0.5rem 0.75rem !important;
1078
+ }
1079
+ </style>
1080
+ """,
1081
+ unsafe_allow_html=True,
1082
+ )
1083
+ st.button(
1084
+ "๐ŸŽฒ",
1085
+ key="ai_generate_btn",
1086
+ help="Generate wordlist from topic",
1087
+ on_click=_on_ai_generate,
1088
+ use_container_width=True,
1089
+ )
1090
+
1091
+ # Display wordlist info below topic input
1092
+ info_text = get_wordlist_info(
1093
+ use_ai=True,
1094
+ ai_topic=st.session_state.ai_topic
1095
  )
1096
+ if info_text:
1097
+ st.caption(info_text)
1098
  else:
1099
  st.session_state.use_ai_wordlist = False
1100
  st.session_state.selected_wordlist = selected
1101
+
1102
+ # Display wordlist info below selection
1103
+ info_text = get_wordlist_info(
1104
+ use_ai=False,
1105
+ selected_file=selected
1106
+ )
1107
+ if info_text:
1108
+ st.caption(info_text)
1109
 
1110
  # Only show Sort button for file-based wordlists
1111
  if not st.session_state.use_ai_wordlist:
 
1115
  st.info("No word lists found in words/ directory. Using AI or built-in fallback.")
1116
  # Force AI mode if no files available
1117
  st.session_state.use_ai_wordlist = True
1118
+
1119
+ # Topic input and Generate button in columns
1120
+ topic_col, gen_col = st.columns([3, 1], gap="small")
1121
+
1122
+ with topic_col:
1123
+ st.text_input(
1124
+ "Topic",
1125
+ value=st.session_state.ai_topic,
1126
+ key="ai_topic",
1127
+ placeholder="e.g., Ocean Life, Space, History",
1128
+ help="Enter a topic for AI-generated words",
1129
+ # Remove on_change to prevent automatic generation
1130
+ )
1131
+
1132
+ with gen_col:
1133
+ # Add custom CSS for the generate button
1134
+ st.markdown(
1135
+ """
1136
+ <style>
1137
+ .st-key-ai_generate_btn {
1138
+ margin-top: 1.85rem; /* Align with text input */
1139
+ }
1140
+ .st-key-ai_generate_btn button {
1141
+ aspect-ratio: auto !important;
1142
+ height: auto !important;
1143
+ padding: 0.5rem 0.75rem !important;
1144
+ }
1145
+ </style>
1146
+ """,
1147
+ unsafe_allow_html=True,
1148
+ )
1149
+ st.button(
1150
+ "๐ŸŽฒ",
1151
+ key="ai_generate_btn",
1152
+ help="Generate wordlist from topic",
1153
+ on_click=_on_ai_generate,
1154
+ use_container_width=True,
1155
+ )
1156
+
1157
+ # Display wordlist info below topic input
1158
+ info_text = get_wordlist_info(
1159
+ use_ai=True,
1160
+ ai_topic=st.session_state.ai_topic
1161
  )
1162
+ if info_text:
1163
+ st.caption(info_text)
1164
 
1165
  # Add Show Grid ticks option
1166
  if "show_grid_ticks" not in st.session_state:
 
1206
  # --- Add sound effects volume ---
1207
  if "effects_volume" not in st.session_state:
1208
  st.session_state.effects_volume = 25
1209
+ # --- Add enable sound effects (default OFF) ---
1210
  if "enable_sound_effects" not in st.session_state:
1211
+ st.session_state.enable_sound_effects = False
1212
  st.checkbox("Enable Sound Effects", value=st.session_state.enable_sound_effects, key="enable_sound_effects")
1213
 
1214
  enabled = st.checkbox("Enable music", value=st.session_state.music_enabled, key="music_enabled")
 
1274
 
1275
  def _on_free_letter_click(letter: str, state: GameState) -> None:
1276
  """Callback for free letter button clicks."""
1277
+ # Start timer on first action if not already started
1278
+ if st.session_state.get("start_time") is None:
1279
+ st.session_state.start_time = datetime.now()
1280
+ state.start_time = st.session_state.start_time
1281
+
1282
  # Reveal this free letter
1283
  count = reveal_free_letter(state, st.session_state.letter_map, letter)
1284
  _sync_back(state)
 
1460
  clicked = coord
1461
 
1462
  if clicked is not None:
1463
+ # Start timer on first action if not already started
1464
+ if st.session_state.get("start_time") is None:
1465
+ st.session_state.start_time = datetime.now()
1466
+ state.start_time = st.session_state.start_time
1467
+
1468
  reveal_cell(state, letter_map, clicked)
1469
  # Auto-mark and award base points for any fully revealed words
1470
  if auto_mark_completed_words(state):
 
1593
  width: auto !important;
1594
  }
1595
  /* Ensure tooltip content wraps and preserves newlines for vertical stacking */
1596
+ div[data-testid="stTooltipContent"], div[role="tooltip"] {
1597
+ width: auto !important;
1598
+ }
1599
+ /* Ensure tooltip content wraps and preserves newlines for vertical stacking */
1600
  div[data-testid="stTooltipContent"], div[role="tooltip"] {
1601
  white-space: pre-wrap !important;
1602
  text-align: left !important;
 
1627
  font-size: 0.8rem;
1628
  cursor: help;
1629
  }
1630
+ .st-key-guess_input .stTooltipIcon .stTooltipHoverTarget:hover::after {
1631
+ font-size: 0.8rem;
1632
+ cursor: help;
1633
+ }
1634
  .st-key-guess_input .stTooltipIcon .stTooltipHoverTarget:hover::after {
1635
  color: #ff9999;
1636
  }
 
1669
  # )
1670
 
1671
  if submitted:
1672
+ # Only process guesses with at least 4 characters (prevent blank/short submissions)
1673
+ if len(guess_text.strip()) >= 4:
1674
+ correct, _ = guess_word(state, guess_text)
1675
+ _sync_back(state)
1676
+ if correct:
1677
+ play_sound_effect("correct_guess", volume=(st.session_state.get("effects_volume", 50) / 100))
1678
+ else:
1679
+ # Update incorrect guesses list - keep only last 10
1680
+ st.session_state.incorrect_guesses.append(guess_text)
1681
+ st.session_state.incorrect_guesses = st.session_state.incorrect_guesses[-10:]
1682
+ play_sound_effect("incorrect_guess", volume=(st.session_state.get("effects_volume", 50) / 100))
1683
+ st.rerun()
1684
+ # If guess is too short, silently ignore (no action, no sound, no rerun)
1685
 
1686
 
1687
  # -------------------- Score Panel --------------------
 
1725
  )
1726
  rows_html.append(row_html)
1727
 
1728
+ # Timer display logic
1729
  now = datetime.now()
1730
+
1731
+ # If timer hasn't started yet, show 00:00
1732
+ if state.start_time is None:
1733
+ timer_str = "00:00"
1734
+ start_ms = "null"
1735
+ end_ms = "null"
1736
+ else:
1737
+ start = state.start_time
1738
+ end = state.end_time or (now if is_game_over(state) else None)
1739
+ elapsed = (end or now) - start
1740
+ mins, secs = divmod(int(elapsed.total_seconds()), 60)
1741
+ timer_str = f"{mins:02d}:{secs:02d}"
1742
+ start_ms = int(start.timestamp() * 1000)
1743
+ end_ms = int(end.timestamp() * 1000) if end else "null"
1744
 
1745
  span_id = f"bw-timer-{getattr(state.puzzle, 'uid', 'default')}"
1746
  timer_span_html = f"<span id=\"{span_id}\" style='font-size:1rem; color:#ffffff;'>&nbsp;โฑ {timer_str}</span>"
 
1752
  )
1753
  rows_html.append(total_row_html)
1754
 
 
 
 
 
1755
  table_inner = "".join(rows_html)
1756
  html_doc = f"""
1757
  <div class='bw-score-panel-container'>
 
1776
  var span = document.getElementById("{span_id}");
1777
  if (!span) return;
1778
  var startMs = {start_ms};
1779
+ var endMs = {end_ms};
1780
+
1781
+ // If timer hasn't started, keep at 00:00
1782
+ if (startMs === null) {{
1783
+ span.textContent = "โฑ 00:00";
1784
+ return;
1785
+ }}
1786
 
1787
  function fmt(ms) {{
1788
  var total = Math.max(0, Math.floor(ms / 1000));
 
1847
  end = state.end_time or datetime.now()
1848
  elapsed = end - start
1849
  elapsed_seconds = int(elapsed.total_seconds())
1850
+ mins, secs = divmod(int(elapsed.total_seconds()), 60)
1851
  timer_str = f"{mins:02d}:{secs:02d}"
1852
 
1853
  # Compute optional word list difficulty for current run
 
1914
  }
1915
  .m-2 {display: flex;flex-direction: row;justify-content: space-evenly;flex-wrap: wrap;font-size: 1.25rem; font-weight: bold;}
1916
  .st-key-new_game_btn_dialog button, .st-key-close_game_over button {
1917
+ height: 50px !important;
1918
  }
1919
  .st-key-new_game_btn button {
1920
  transition: none !important;
 
2065
  # If the unified submit fails, try a best-effort weekly submit below
2066
  results = {"daily": {"qualified": False, "rank": None, "id": None}, "weekly": {"qualified": False, "rank": None, "id": None}}
2067
 
2068
+ # Check weekly result and attempt a fallback single-weekly submission if missing
2069
+ weekly_info = results.get("weekly") or {}
2070
+ if weekly_info.get("id") is None or (weekly_info.get("qualified") is False and weekly_info.get("rank") is None):
2071
+ try:
2072
+ weekly_id = get_current_weekly_id()
2073
+ # Build a lightweight UserEntry via submit_to_leaderboard (it returns (success, rank))
2074
+ submit_to_leaderboard(
2075
+ "weekly",
2076
+ weekly_id,
2077
+ None, # user_entry will be constructed inside submit_to_leaderboard if used directly; instead construct minimal entry below
2078
+ settings,
2079
+ )
2080
+ except Exception:
2081
+ # swallow fallback errors - main attempt already logged by leaderboard module
2082
+ pass
2083
 
2084
  return results
2085
+
2086
 
2087
  # Check if share URL already generated
2088
  if "share_url" not in st.session_state or st.session_state.get("share_url") is None:
 
2239
  lb_results = st.session_state.get("leaderboard_results", {})
2240
  daily_info = lb_results.get("daily", {})
2241
  weekly_info = lb_results.get("weekly", {})
2242
+ leaderboard_url = lb_results.get("leaderboard_url")
2243
 
2244
+ # Build status message with single combined link
2245
  status_parts = []
2246
  if daily_info.get("qualified"):
2247
+ status_parts.append(f"๐Ÿ“… Daily #{daily_info['rank']}")
2248
  if weekly_info.get("qualified"):
2249
+ status_parts.append(f"๐Ÿ“† Weekly #{weekly_info['rank']}")
2250
 
2251
+ if status_parts and leaderboard_url:
2252
+ rankings_text = " โ€ข ".join(status_parts)
2253
+ # Ensure the leaderboard URL includes page=today for proper tab navigation
2254
+ # The URL already has gidd/gidw params, just ensure page=today is added
2255
+ if "?" in leaderboard_url:
2256
+ # Add page=today if not already present
2257
+ if "page=" not in leaderboard_url:
2258
+ leaderboard_url += "&page=today"
2259
+ else:
2260
+ # Should have query params from gidd/gidw, but add page=today as fallback
2261
+ leaderboard_url += "?page=today"
2262
+
2263
+ st.markdown(
2264
+ f"""
2265
+ <div style="margin-top: 1rem; padding: 1rem; background: rgba(32, 212, 108, 0.1); border-radius: 0.5rem; border: 1px solid rgba(32, 212, 108, 0.3);">
2266
+ <strong style="color: #20d46c;">๐Ÿ† Leaderboard Rankings:</strong><br/>
2267
+ <a href="{leaderboard_url}" target="_self" style="color: #20d46c; text-decoration: underline; font-size: 1.1rem;">{rankings_text}</a>
2268
+ </div>
2269
+ """,
2270
+ unsafe_allow_html=True
2271
+ )
2272
+ elif status_parts:
2273
+ # Qualified but no URL (shouldn't happen, but handle gracefully)
2274
+ rankings_text = " โ€ข ".join(status_parts)
2275
+ st.success(f"๐Ÿ† {rankings_text}")
2276
  else:
2277
+ st.info("๐Ÿ† Submitted to leaderboards (not in top 20)")
2278
 
2279
  st.markdown("---")
2280
 
 
2383
  # Preserve current AI topic or use default
2384
  if "ai_topic" not in st.session_state:
2385
  st.session_state.ai_topic = "English"
2386
+ # Don't trigger new game automatically for AI mode
2387
+ # User must click Generate button explicitly
2388
  else:
2389
  st.session_state.use_ai_wordlist = False
2390
  st.session_state.selected_wordlist = selected
2391
+ # Trigger new game immediately for file-based wordlists
2392
+ _on_game_option_change()
2393
+
2394
+
2395
+ def _on_ai_generate() -> None:
2396
+ """
2397
+ Callback when Generate button is clicked for AI wordlist.
2398
+ Triggers new game generation with AI words.
2399
+ """
2400
+ # Start a fresh game with AI-generated words
2401
  _on_game_option_change()
2402
 
2403
  def _render_footer(current_page: str = "play"):
2404
  """Render footer with navigation links to leaderboards and main game.
2405
 
2406
  Args:
2407
+ current_page: Which page is currently active ("play", "today", "daily", "weekly", "history")
2408
  """
2409
  # Determine which link should be highlighted as active
2410
  play_active = "active" if current_page == "play" else ""
2411
+ today_active = "active" if current_page == "today" else ""
2412
  daily_active = "active" if current_page == "daily" else ""
2413
  weekly_active = "active" if current_page == "weekly" else ""
2414
 
 
2427
 
2428
  # Build URLs with game_id if in challenge mode
2429
  if game_id:
2430
+ today_url = f"?page=today&game_id={game_id}"
2431
  daily_url = f"?page=daily&game_id={game_id}"
2432
  weekly_url = f"?page=weekly&game_id={game_id}"
2433
  play_url = f"?game_id={game_id}"
2434
  else:
2435
+ today_url = "?page=today"
2436
  daily_url = "?page=daily"
2437
  weekly_url = "?page=weekly"
2438
  play_url = "/"
 
2454
  display: flex;
2455
  justify-content: center;
2456
  align-items: center;
2457
+ gap: 1rem;
2458
  flex-wrap: wrap;
2459
  }}
2460
  .bw-footer-nav a {{
2461
  color: #d7faff;
2462
  text-decoration: none;
2463
  font-weight: 600;
2464
+ font-size: 0.85rem;
2465
+ padding: 0.4rem 0.8rem;
2466
  border-radius: 0.5rem;
2467
  background: rgba(29, 100, 200, 0.3);
2468
  border: 1px solid rgba(215, 250, 255, 0.3);
 
2484
  }}
2485
  @media (max-width: 640px) {{
2486
  .bw-footer-nav {{
2487
+ gap: 0.5rem;
2488
  }}
2489
  .bw-footer-nav a {{
2490
+ font-size: 0.75rem;
2491
+ padding: 0.35rem 0.6rem;
2492
  }}
2493
  }}
2494
  </style>
2495
  <div class="bw-footer">
2496
  <nav class="bw-footer-nav">
2497
+ <a href="{today_url}" title="View Today's Leaderboards" target="_self" class="{today_active}">๐ŸŒŸ Today</a>
2498
+ <a href="{daily_url}" title="View Daily Leaderboards" target="_self" class="{daily_active}">๐Ÿ“… Daily</a>
2499
+ <a href="{weekly_url}" title="View Weekly Leaderboards" target="_self" class="{weekly_active}">๐Ÿ“† Weekly</a>
2500
  <a href="{play_url}" title="Play Wrdler" target="_self" class="{play_active}">๐ŸŽฎ Play</a>
2501
  </nav>
2502
  </div>
 
2602
  state = _to_state()
2603
  if is_game_over(state) and not st.session_state.get("hide_gameover_overlay", False):
2604
  _render_game_over(state)
2605
+ _render_footer(current_page="play")
wrdler/word_loader.py CHANGED
@@ -447,4 +447,65 @@ def load_word_list_or_ai(
447
  return load_word_list(selected_file=selected_file)
448
  else:
449
  # Standard file-based loading
450
- return load_word_list(selected_file=selected_file)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
447
  return load_word_list(selected_file=selected_file)
448
  else:
449
  # Standard file-based loading
450
+ return load_word_list(selected_file=selected_file)
451
+
452
+ def get_wordlist_info(use_ai: bool, ai_topic: str = None, selected_file: str = None) -> str:
453
+ """
454
+ Get wordlist information for display in sidebar.
455
+
456
+ Args:
457
+ use_ai: Whether AI mode is active
458
+ ai_topic: AI topic (only used if use_ai=True)
459
+ selected_file: Selected wordlist file (only used if use_ai=False)
460
+
461
+ Returns:
462
+ Information string about the wordlist (word count and status)
463
+ """
464
+ if use_ai and ai_topic:
465
+ # Check if AI topic file exists
466
+ safe_topic = re.sub(r'[^\w\s-]', '', ai_topic.lower()).strip()
467
+ safe_topic = re.sub(r'[-\s]+', '_', safe_topic)
468
+ filename = f"{safe_topic}.txt"
469
+
470
+ words_dir = os.path.join(os.path.dirname(__file__), "words")
471
+ filepath = os.path.join(words_dir, filename)
472
+
473
+ if os.path.isfile(filepath):
474
+ # Count words in the file
475
+ try:
476
+ with open(filepath, "r", encoding="utf-8") as f:
477
+ count = 0
478
+ for line in f:
479
+ line = line.strip()
480
+ if line and not line.startswith("#"):
481
+ # Only count valid A-Z words
482
+ if re.fullmatch(r"[A-Z]+", line.upper()):
483
+ count += 1
484
+ return f"๐Ÿ“ Topic file exists: {filename} ({count} words)"
485
+ except Exception:
486
+ return f"๐Ÿ“ Topic file exists: {filename}"
487
+ else:
488
+ return f"๐Ÿ“ Topic file does not exist yet (will be created on first generation)"
489
+
490
+ elif selected_file:
491
+ # Count words in selected file
492
+ words_dir = os.path.join(os.path.dirname(__file__), "words")
493
+ filepath = os.path.join(words_dir, selected_file)
494
+
495
+ if os.path.isfile(filepath):
496
+ try:
497
+ with open(filepath, "r", encoding="utf-8") as f:
498
+ count = 0
499
+ for line in f:
500
+ line = line.strip()
501
+ if line and not line.startswith("#"):
502
+ # Only count valid A-Z words
503
+ if re.fullmatch(r"[A-Z]+", line.upper()):
504
+ count += 1
505
+ return f"๐Ÿ“Š {count} words in {selected_file}"
506
+ except Exception:
507
+ return f"๐Ÿ“Š Wordlist: {selected_file}"
508
+ else:
509
+ return f"โš ๏ธ File not found: {selected_file}"
510
+
511
+ return ""
wrdler/word_loader_ai.py CHANGED
@@ -74,7 +74,7 @@ BASE_PROMPT_TEMPLATE = (
74
  "- Output ONLY a single comma-separated list (no numbering, no extra text)\n"
75
  "- Include at least: {WORDS_PER_LENGTH} words of length 4 letters, {WORDS_PER_LENGTH} words of length 5 letters, {WORDS_PER_LENGTH} words of length 6 letters\n"
76
  "- Use ONLY uppercase A-Z letters (no diacritics, hyphens, or spaces)\n"
77
- "- No duplicates. No explanations.\n"
78
  "List:"
79
  )
80
 
 
74
  "- Output ONLY a single comma-separated list (no numbering, no extra text)\n"
75
  "- Include at least: {WORDS_PER_LENGTH} words of length 4 letters, {WORDS_PER_LENGTH} words of length 5 letters, {WORDS_PER_LENGTH} words of length 6 letters\n"
76
  "- Use ONLY uppercase A-Z letters (no diacritics, hyphens, or spaces)\n"
77
+ "- No duplicates. No explanations. No pluralization or plural form nouns, No third person singular verbs, No past tense verbs \n"
78
  "List:"
79
  )
80