Surn commited on
Commit
522064b
·
1 Parent(s): c8aba37

Add word difficulty and UI/UX improvements

Browse files

- Updated to version 0.2.24 with changelog in README.md.
- Introduced `compute_word_difficulties` in `word_loader.py`.
- Enhanced `game_storage.py` to calculate and store word difficulty.
- Improved `ui.py` with compact layout, better tooltips, and
leaderboard updates to display word difficulty.
- Refactored "Share Your Challenge" for better usability.
- Updated `requirements.md` and `specs.md` to document new features.
- Enhanced Challenge Mode with difficulty display in leaderboard.

README.md CHANGED
@@ -120,6 +120,13 @@ docker run -p8501:8501 battlewords
120
  - High Scores: local leaderboard tracking top scores by wordlist and game mode.
121
  - Persistent Storage: all game results saved locally for personal statistics without accounts.
122
  - Challenge Mode: remote storage of challenge results, multi-user leaderboard, and shareable links.
 
 
 
 
 
 
 
123
 
124
  -0.2.23
125
  - Update miss and correct guess sound effects to new versions
 
120
  - High Scores: local leaderboard tracking top scores by wordlist and game mode.
121
  - Persistent Storage: all game results saved locally for personal statistics without accounts.
122
  - Challenge Mode: remote storage of challenge results, multi-user leaderboard, and shareable links.
123
+
124
+ -0.2.24
125
+ - compress height
126
+ - change incorrect guess tooltip location
127
+ - update final screen layout
128
+ - add word difficulty formula
129
+ - update documentation
130
 
131
  -0.2.23
132
  - Update miss and correct guess sound effects to new versions
battlewords/__init__.py CHANGED
@@ -1,2 +1,2 @@
1
- __version__ = "0.2.23"
2
  __all__ = ["models", "generator", "logic", "ui", "game_storage"]
 
1
+ __version__ = "0.2.24"
2
  __all__ = ["models", "generator", "logic", "ui", "game_storage"]
battlewords/game_storage.py CHANGED
@@ -24,6 +24,7 @@ from battlewords.modules import (
24
  )
25
  from battlewords.modules.storage import _get_json_from_repo
26
  from battlewords.local_storage import save_json_to_file
 
27
 
28
  # Configure logging
29
  logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
@@ -83,30 +84,44 @@ def serialize_game_settings(
83
  if challenge_id is None:
84
  challenge_id = generate_uid()
85
 
86
- # Create user result entry with their own uid and word_list
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  user_result = {
88
- "uid": generate_uid(), # Unique ID for this user's game
89
  "username": username,
90
- "word_list": word_list, # Words THIS user played
91
- "score": score,
92
- "time": time_seconds,
93
- "timestamp": datetime.now(timezone.utc).isoformat()
94
  }
 
 
 
 
 
95
 
96
  settings = {
97
- "challenge_id": challenge_id, # ID for the challenge itself
98
  "game_mode": game_mode,
99
  "grid_size": grid_size,
100
  "puzzle_options": {
101
  "spacer": spacer,
102
  "may_overlap": may_overlap
103
  },
104
- "users": [user_result], # Array of user results
105
  "created_at": datetime.now(timezone.utc).isoformat(),
106
  "version": __version__
107
  }
108
 
109
- # Add wordlist_source if provided
110
  if wordlist_source:
111
  settings["wordlist_source"] = wordlist_source
112
 
@@ -148,15 +163,30 @@ def add_user_result_to_game(
148
  logger.error(f"❌ Challenge not found: {sid}")
149
  return False
150
 
151
- # Create new user result with their own uid and word_list
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  user_result = {
153
- "uid": generate_uid(), # Unique ID for this user's game
154
  "username": username,
155
- "word_list": word_list, # Words THIS user played
156
- "score": score,
157
- "time": time_seconds,
158
- "timestamp": datetime.now(timezone.utc).isoformat()
159
  }
 
 
 
 
 
160
 
161
  # Add to users array
162
  if "users" not in settings:
 
24
  )
25
  from battlewords.modules.storage import _get_json_from_repo
26
  from battlewords.local_storage import save_json_to_file
27
+ from battlewords.word_loader import compute_word_difficulties
28
 
29
  # Configure logging
30
  logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
 
84
  if challenge_id is None:
85
  challenge_id = generate_uid()
86
 
87
+ # Try compute difficulty using the source file; optional
88
+ difficulty_value: Optional[float] = None
89
+ try:
90
+ if wordlist_source:
91
+ words_dir = os.path.join(os.path.dirname(__file__), "words")
92
+ wordlist_path = os.path.join(words_dir, wordlist_source)
93
+ if os.path.exists(wordlist_path):
94
+ total_diff, _ = compute_word_difficulties(wordlist_path, words_array=word_list)
95
+ difficulty_value = float(total_diff)
96
+ except Exception as _e:
97
+ # optional field, swallow errors
98
+ difficulty_value = None
99
+
100
+ # Build user result with desired ordering: uid, username, word_list, word_list_difficulty, score, time, timestamp
101
  user_result = {
102
+ "uid": generate_uid(),
103
  "username": username,
104
+ "word_list": word_list,
 
 
 
105
  }
106
+ if difficulty_value is not None:
107
+ user_result["word_list_difficulty"] = difficulty_value
108
+ user_result["score"] = score
109
+ user_result["time"] = time_seconds
110
+ user_result["timestamp"] = datetime.now(timezone.utc).isoformat()
111
 
112
  settings = {
113
+ "challenge_id": challenge_id,
114
  "game_mode": game_mode,
115
  "grid_size": grid_size,
116
  "puzzle_options": {
117
  "spacer": spacer,
118
  "may_overlap": may_overlap
119
  },
120
+ "users": [user_result],
121
  "created_at": datetime.now(timezone.utc).isoformat(),
122
  "version": __version__
123
  }
124
 
 
125
  if wordlist_source:
126
  settings["wordlist_source"] = wordlist_source
127
 
 
163
  logger.error(f"❌ Challenge not found: {sid}")
164
  return False
165
 
166
+ # Compute optional difficulty using the saved wordlist_source if available
167
+ difficulty_value: Optional[float] = None
168
+ try:
169
+ wordlist_source = settings.get("wordlist_source")
170
+ if wordlist_source:
171
+ words_dir = os.path.join(os.path.dirname(__file__), "words")
172
+ wordlist_path = os.path.join(words_dir, wordlist_source)
173
+ if os.path.exists(wordlist_path):
174
+ total_diff, _ = compute_word_difficulties(wordlist_path, words_array=word_list)
175
+ difficulty_value = float(total_diff)
176
+ except Exception:
177
+ difficulty_value = None
178
+
179
+ # Create new user result with ordering and optional difficulty
180
  user_result = {
181
+ "uid": generate_uid(),
182
  "username": username,
183
+ "word_list": word_list,
 
 
 
184
  }
185
+ if difficulty_value is not None:
186
+ user_result["word_list_difficulty"] = difficulty_value
187
+ user_result["score"] = score
188
+ user_result["time"] = time_seconds
189
+ user_result["timestamp"] = datetime.now(timezone.utc).isoformat()
190
 
191
  # Add to users array
192
  if "users" not in settings:
battlewords/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, 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 # use loader directly
21
  from .version_info import versions_html # version info footer
22
  from .audio import (
23
  _get_music_dir,
@@ -219,9 +219,9 @@ def inject_styles() -> None:
219
  max-width: 1100px;
220
  }
221
  .stHeading {
222
- margin-bottom: 0rem !important;
223
- margin-top: 0rem !important;
224
- font-size: 2rem !important; /* Title */
225
  line-height: 1.1 !important;
226
  }
227
  /* Base grid cell visuals */
@@ -507,8 +507,13 @@ def _render_header():
507
  users = shared_settings.get("users", [])
508
 
509
  if users:
510
- # Sort users by score (descending), then by time (ascending)
511
- sorted_users = sorted(users, key=lambda u: (-u["score"], u["time"]))
 
 
 
 
 
512
  best_user = sorted_users[0]
513
  best_score = best_user["score"]
514
  best_time = best_user["time"]
@@ -521,8 +526,15 @@ def _render_header():
521
  u_mins, u_secs = divmod(user["time"], 60)
522
  u_time_str = f"{u_mins:02d}:{u_secs:02d}"
523
  medal = ["🥇", "🥈", "🥉"][i-1] if i <= 3 else f"{i}."
 
 
 
 
 
 
 
524
  leaderboard_rows.append(
525
- f"<div style='padding:0.25rem; font-size:0.85rem;'>{medal} {user['username']}: {user['score']} pts in {u_time_str}</div>"
526
  )
527
  leaderboard_html = "".join(leaderboard_rows)
528
 
@@ -572,15 +584,6 @@ def _render_header():
572
 
573
  inject_styles()
574
 
575
- st.markdown(
576
- """
577
- <style>
578
- /* Compact title and subheader */
579
-
580
- </style>
581
- """,
582
- unsafe_allow_html=True,
583
- )
584
  def _render_sidebar():
585
  with st.sidebar:
586
  st.header("SETTINGS")
@@ -1135,11 +1138,13 @@ def _render_guess_form(state: GameState):
1135
  if "incorrect_guesses" not in st.session_state:
1136
  st.session_state.incorrect_guesses = []
1137
 
1138
- # Prepare tooltip text for native browser tooltip
1139
  recent_incorrect = st.session_state.incorrect_guesses[-10:]
1140
  if st.session_state.get("show_incorrect_guesses", True) and recent_incorrect:
1141
  if recent_incorrect:
1142
- tooltip_text = "Recent incorrect guesses:\n" + "\n".join(recent_incorrect)
 
 
1143
  else:
1144
  tooltip_text = "No incorrect guesses yet"
1145
  else:
@@ -1159,9 +1164,22 @@ def _render_guess_form(state: GameState):
1159
  .st-key-guess_input .stTooltipIcon {
1160
  position: absolute;
1161
  left: 0;
1162
- bottom: -22px;
1163
  width: auto !important;
1164
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
1165
  /* Hide the default SVG info icon */
1166
  .st-key-guess_input .stTooltipIcon svg.icon {
1167
  display: none !important;
@@ -1171,6 +1189,7 @@ def _render_guess_form(state: GameState):
1171
  display: inline-flex;
1172
  align-items: center;
1173
  width: auto;
 
1174
  }
1175
  .st-key-guess_input .stTooltipIcon .stTooltipHoverTarget::after {
1176
  content: "incorrect guesses";
@@ -1383,6 +1402,26 @@ def _game_over_content(state: GameState) -> None:
1383
  mins, secs = divmod(elapsed_seconds, 60)
1384
  timer_str = f"{mins:02d}:{secs:02d}"
1385
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1386
  # Build table body HTML for dialog content
1387
  word_rows = []
1388
  for w in state.puzzle.words:
@@ -1444,13 +1483,16 @@ def _game_over_content(state: GameState) -> None:
1444
 
1445
  st.markdown(
1446
  f"""
1447
- <div class=\"bw-dialog-container shiny-border\">\n <div class=\"p-3 pt-2\">\n <div class=\"mb-2\">Congratulations!</div>
1448
- <div class=\"mb-2\">Final score: <strong class=\"text-success\">{state.score}</strong></div>
1449
- <div class=\"mb-2\">Time: <strong>{timer_str}</strong></div>
1450
- <div class=\"mb-2\">Tier: <strong>{compute_tier(state.score)}</strong></div>
1451
- <div class=\"mb-2\">Game Mode: <strong>{state.game_mode}</strong></div>
1452
- <div class=\"mb-2\">Wordlist: <strong>{st.session_state.get('selected_wordlist', '')}</strong></div>
1453
- <div class=\"mb-0\">{table_html}</div>
 
 
 
1454
  </div>
1455
  </div>
1456
  """,
@@ -1481,115 +1523,151 @@ def _game_over_content(state: GameState) -> None:
1481
 
1482
  # Share Challenge Button
1483
  st.markdown("---")
1484
- st.markdown("### 🎮 Share Your Challenge")
1485
 
1486
- # Check if this is a shared game being completed
1487
- is_shared_game = st.session_state.get("loaded_game_sid") is not None
1488
- existing_sid = st.session_state.get("loaded_game_sid")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1489
 
1490
- # Username input
1491
- if "player_username" not in st.session_state:
1492
- st.session_state["player_username"] = ""
1493
 
1494
- username = st.text_input(
1495
- "Enter your name (optional)",
1496
- value=st.session_state.get("player_username", ""),
1497
- key="username_input",
1498
- placeholder="Anonymous"
1499
- )
1500
- if username:
1501
- st.session_state["player_username"] = username
1502
- else:
1503
- username = "Anonymous"
1504
 
1505
- # Check if share URL already generated
1506
- if "share_url" not in st.session_state or st.session_state.get("share_url") is None:
1507
- button_text = "📊 Submit Your Result" if is_shared_game else "🔗 Generate Share Link"
1508
 
1509
- if st.button(button_text, key="generate_share_link", use_container_width=True):
1510
- try:
1511
- # Extract game data
1512
- word_list = [w.text for w in state.puzzle.words]
1513
- spacer = state.puzzle.spacer
1514
- may_overlap = state.puzzle.may_overlap
1515
- wordlist_source = st.session_state.get("selected_wordlist", "unknown")
1516
-
1517
- if is_shared_game and existing_sid:
1518
- # Add result to existing game
1519
- success = add_user_result_to_game(
1520
- sid=existing_sid,
1521
- username=username,
1522
- word_list=word_list, # Each user gets different words
1523
- score=state.score,
1524
- time_seconds=elapsed_seconds
1525
- )
1526
 
1527
- if success:
1528
- share_url = get_shareable_url(existing_sid)
1529
- st.session_state["share_url"] = share_url
1530
- st.session_state["share_sid"] = existing_sid
1531
- st.success(f" Result submitted for {username}!")
1532
- st.rerun()
1533
- else:
1534
- st.error("Failed to submit result")
1535
- else:
1536
- # Create new game
1537
- challenge_id, full_url, sid = save_game_to_hf(
1538
- word_list=word_list,
1539
- username=username,
1540
- score=state.score,
1541
- time_seconds=elapsed_seconds,
1542
- game_mode=state.game_mode,
1543
- grid_size=state.grid_size,
1544
- spacer=spacer,
1545
- may_overlap=may_overlap,
1546
- wordlist_source=wordlist_source
1547
- )
1548
 
1549
- if sid:
1550
- share_url = get_shareable_url(sid)
1551
- st.session_state["share_url"] = share_url
1552
- st.session_state["share_sid"] = sid
1553
- st.rerun()
 
 
 
1554
  else:
1555
- st.error("Failed to generate short URL")
 
 
 
 
 
 
 
 
 
 
 
1556
 
1557
- except Exception as e:
1558
- st.error(f"Failed to save game: {e}")
1559
- else:
1560
- # Display generated share URL
1561
- share_url = st.session_state["share_url"]
1562
- st.success("✅ Share link generated!")
1563
- st.code(share_url, language=None)
1564
-
1565
- # Copy to clipboard button
1566
- components.html(
1567
- f"""
1568
- <script>
1569
- function copyToClipboard() {{
1570
- navigator.clipboard.writeText("{share_url}").then(function() {{
1571
- alert("Share link copied to clipboard!");
1572
- }}, function(err) {{
1573
- console.error('Could not copy text: ', err);
1574
- }});
1575
- }}
1576
- </script>
1577
- <button onclick="copyToClipboard()" style="
1578
- width: 100%;
1579
- padding: 0.5rem 1rem;
1580
- background: #1d64c8;
1581
- color: white;
1582
- border: none;
1583
- border-radius: 0.5rem;
1584
- cursor: pointer;
1585
- font-size: 1rem;
1586
- font-weight: bold;
1587
- ">
1588
- 📋 Copy Link
1589
- </button>
1590
- """,
1591
- height=60
1592
- )
 
 
 
 
 
 
 
 
1593
 
1594
  st.markdown("---")
1595
 
 
17
  from .generator import generate_puzzle, sort_word_file
18
  from .logic import build_letter_map, reveal_cell, guess_word, is_game_over, compute_tier, auto_mark_completed_words, hidden_word_display
19
  from .models import Coord, GameState, Puzzle
20
+ from .word_loader import get_wordlist_files, load_word_list, compute_word_difficulties
21
  from .version_info import versions_html # version info footer
22
  from .audio import (
23
  _get_music_dir,
 
219
  max-width: 1100px;
220
  }
221
  .stHeading {
222
+ margin-bottom: -1.5rem !important;
223
+ margin-top: -1.5rem !important;
224
+ # font-size: 1.75rem !important; /* Title */
225
  line-height: 1.1 !important;
226
  }
227
  /* Base grid cell visuals */
 
507
  users = shared_settings.get("users", [])
508
 
509
  if users:
510
+ # Sort users by score (descending), then by time (ascending), then by difficulty (descending)
511
+ def leaderboard_sort_key(u):
512
+ # Use -score for descending, time for ascending, -difficulty for descending (default 0 if missing)
513
+ diff = u.get("word_list_difficulty", 0)
514
+ return (-u["score"], u["time"], -diff)
515
+
516
+ sorted_users = sorted(users, key=leaderboard_sort_key)
517
  best_user = sorted_users[0]
518
  best_score = best_user["score"]
519
  best_time = best_user["time"]
 
526
  u_mins, u_secs = divmod(user["time"], 60)
527
  u_time_str = f"{u_mins:02d}:{u_secs:02d}"
528
  medal = ["🥇", "🥈", "🥉"][i-1] if i <= 3 else f"{i}."
529
+ # show optional difficulty if present
530
+ diff_str = ""
531
+ if "word_list_difficulty" in user:
532
+ try:
533
+ diff_str = f" • diff {float(user['word_list_difficulty']):.2f}"
534
+ except Exception:
535
+ diff_str = ""
536
  leaderboard_rows.append(
537
+ f"<div style='padding:0.25rem; font-size:0.85rem;'>{medal} {user['username']}: {user['score']} pts in {u_time_str}{diff_str}</div>"
538
  )
539
  leaderboard_html = "".join(leaderboard_rows)
540
 
 
584
 
585
  inject_styles()
586
 
 
 
 
 
 
 
 
 
 
587
  def _render_sidebar():
588
  with st.sidebar:
589
  st.header("SETTINGS")
 
1138
  if "incorrect_guesses" not in st.session_state:
1139
  st.session_state.incorrect_guesses = []
1140
 
1141
+ # Prepare tooltip text for native browser tooltip (stack vertically)
1142
  recent_incorrect = st.session_state.incorrect_guesses[-10:]
1143
  if st.session_state.get("show_incorrect_guesses", True) and recent_incorrect:
1144
  if recent_incorrect:
1145
+ # Build a bullet list so items stack vertically inside the tooltip
1146
+ bullets = "\n".join(f"• {g}" for g in recent_incorrect)
1147
+ tooltip_text = "Recent incorrect guesses:\n" + bullets
1148
  else:
1149
  tooltip_text = "No incorrect guesses yet"
1150
  else:
 
1164
  .st-key-guess_input .stTooltipIcon {
1165
  position: absolute;
1166
  left: 0;
1167
+ bottom: -26px; /* slight nudge down so the tooltip appears below input */
1168
  width: auto !important;
1169
  }
1170
+ /* Ensure tooltip content wraps and preserves newlines for vertical stacking */
1171
+ div[data-testid="stTooltipContent"], div[role="tooltip"] {
1172
+ white-space: pre-wrap !important;
1173
+ text-align: left !important;
1174
+ max-width: 320px !important;
1175
+ line-height: 1.2;
1176
+ margin-bottom: 25px;
1177
+ }
1178
+ /* Nudge tooltip popover below the trigger when possible */
1179
+ div[data-testid="stTooltipPopover"] {
1180
+ margin-top: 8px !important;
1181
+ }
1182
+
1183
  /* Hide the default SVG info icon */
1184
  .st-key-guess_input .stTooltipIcon svg.icon {
1185
  display: none !important;
 
1189
  display: inline-flex;
1190
  align-items: center;
1191
  width: auto;
1192
+ min-width: 100px;
1193
  }
1194
  .st-key-guess_input .stTooltipIcon .stTooltipHoverTarget::after {
1195
  content: "incorrect guesses";
 
1402
  mins, secs = divmod(elapsed_seconds, 60)
1403
  timer_str = f"{mins:02d}:{secs:02d}"
1404
 
1405
+ # Compute optional word list difficulty for current run
1406
+ difficulty_value = None
1407
+ try:
1408
+ wordlist_source = st.session_state.get("selected_wordlist")
1409
+ if wordlist_source:
1410
+ words_dir = os.path.join(os.path.dirname(__file__), "words")
1411
+ wordlist_path = os.path.join(words_dir, wordlist_source)
1412
+ if os.path.exists(wordlist_path):
1413
+ current_words = [w.text for w in state.puzzle.words]
1414
+ total_diff, _ = compute_word_difficulties(wordlist_path, words_array=current_words)
1415
+ difficulty_value = float(total_diff)
1416
+ except Exception:
1417
+ difficulty_value = None
1418
+
1419
+ # Render difficulty line only if we have a value
1420
+ difficulty_html = (
1421
+ f'<div class="mb-2">Word list difficulty: <strong>{difficulty_value:.2f}</strong></div>'
1422
+ if difficulty_value is not None else ""
1423
+ )
1424
+
1425
  # Build table body HTML for dialog content
1426
  word_rows = []
1427
  for w in state.puzzle.words:
 
1483
 
1484
  st.markdown(
1485
  f"""
1486
+ <div class="bw-dialog-container shiny-border">
1487
+ <div class="p-3 pt-2">
1488
+ <div class="mb-2">Congratulations!</div>
1489
+ <div class="mb-2">Final score: <strong class="text-success">{state.score}</strong></div>
1490
+ <div class="mb-2">Time: <strong>{timer_str}</strong></div>
1491
+ <div class="mb-2">Tier: <strong>{compute_tier(state.score)}</strong></div>
1492
+ <div class="mb-2">Game Mode: <strong>{state.game_mode}</strong></div>
1493
+ <div class="mb-2">Wordlist: <strong>{st.session_state.get('selected_wordlist', '')}</strong></div>
1494
+ <div class="mb-0">{table_html}</div>
1495
+ {difficulty_html}
1496
  </div>
1497
  </div>
1498
  """,
 
1523
 
1524
  # Share Challenge Button
1525
  st.markdown("---")
 
1526
 
1527
+ # Style the containing Streamlit block via CSS :has() using an anchor inside this container
1528
+ with st.container():
1529
+ st.markdown(
1530
+ """
1531
+ <style>
1532
+ /* Apply the dialog background to the Streamlit block that contains our anchor */
1533
+ div[data-testid="stVerticalBlock"]:has(#bw-share-anchor) {
1534
+ border-radius: 1rem;
1535
+ box-shadow: 0 0 32px #1d64c8;
1536
+ background: linear-gradient(-45deg, #1d64c8, #ffffff, #1d64c8, #666666);
1537
+ color: #fff;
1538
+ padding: 16px;
1539
+ }
1540
+ /* Improve inner text contrast inside the styled block */
1541
+ div[data-testid="stVerticalBlock"]:has(#bw-share-anchor) h3,
1542
+ div[data-testid="stVerticalBlock"]:has(#bw-share-anchor) label,
1543
+ div[data-testid="stVerticalBlock"]:has(#bw-share-anchor) p {
1544
+ color: #fff !important;
1545
+ }
1546
+ /* Ensure code block is readable */
1547
+ div[data-testid="stVerticalBlock"]:has(#bw-share-anchor) pre,
1548
+ div[data-testid="stVerticalBlock"]:has(#bw-share-anchor) code {
1549
+ background: rgba(0,0,0,0.25) !important;
1550
+ color: #fff !important;
1551
+ }
1552
+ /* Buttons hover contrast */
1553
+ div[data-testid="stVerticalBlock"]:has(#bw-share-anchor) button:hover {
1554
+ filter: brightness(1.1);
1555
+ }
1556
+ </style>
1557
+ <div id="bw-share-anchor"></div>
1558
+ """,
1559
+ unsafe_allow_html=True,
1560
+ )
1561
 
1562
+ st.markdown("### 🎮 Share Your Challenge")
 
 
1563
 
1564
+ # Check if this is a shared game being completed
1565
+ is_shared_game = st.session_state.get("loaded_game_sid") is not None
1566
+ existing_sid = st.session_state.get("loaded_game_sid")
 
 
 
 
 
 
 
1567
 
1568
+ # Username input
1569
+ if "player_username" not in st.session_state:
1570
+ st.session_state["player_username"] = ""
1571
 
1572
+ username = st.text_input(
1573
+ "Enter your name (optional)",
1574
+ value=st.session_state.get("player_username", ""),
1575
+ key="username_input",
1576
+ placeholder="Anonymous"
1577
+ )
1578
+ if username:
1579
+ st.session_state["player_username"] = username
1580
+ else:
1581
+ username = "Anonymous"
 
 
 
 
 
 
 
1582
 
1583
+ # Check if share URL already generated
1584
+ if "share_url" not in st.session_state or st.session_state.get("share_url") is None:
1585
+ button_text = "📊 Submit Your Result" if is_shared_game else "🔗 Generate Share Link"
1586
+
1587
+ if st.button(button_text, key="generate_share_link", use_container_width=True):
1588
+ try:
1589
+ # Extract game data
1590
+ word_list = [w.text for w in state.puzzle.words]
1591
+ spacer = state.puzzle.spacer
1592
+ may_overlap = state.puzzle.may_overlap
1593
+ wordlist_source = st.session_state.get("selected_wordlist", "unknown")
1594
+
1595
+ if is_shared_game and existing_sid:
1596
+ # Add result to existing game
1597
+ success = add_user_result_to_game(
1598
+ sid=existing_sid,
1599
+ username=username,
1600
+ word_list=word_list, # Each user gets different words
1601
+ score=state.score,
1602
+ time_seconds=elapsed_seconds
1603
+ )
1604
 
1605
+ if success:
1606
+ share_url = get_shareable_url(existing_sid)
1607
+ st.session_state["share_url"] = share_url
1608
+ st.session_state["share_sid"] = existing_sid
1609
+ st.success(f"✅ Result submitted for {username}!")
1610
+ st.rerun()
1611
+ else:
1612
+ st.error("Failed to submit result")
1613
  else:
1614
+ # Create new game
1615
+ challenge_id, full_url, sid = save_game_to_hf(
1616
+ word_list=word_list,
1617
+ username=username,
1618
+ score=state.score,
1619
+ time_seconds=elapsed_seconds,
1620
+ game_mode=state.game_mode,
1621
+ grid_size=state.grid_size,
1622
+ spacer=spacer,
1623
+ may_overlap=may_overlap,
1624
+ wordlist_source=wordlist_source
1625
+ )
1626
 
1627
+ if sid:
1628
+ share_url = get_shareable_url(sid)
1629
+ st.session_state["share_url"] = share_url
1630
+ st.session_state["share_sid"] = sid
1631
+ st.rerun()
1632
+ else:
1633
+ st.error("Failed to generate short URL")
1634
+
1635
+ except Exception as e:
1636
+ st.error(f"Failed to save game: {e}")
1637
+ else:
1638
+ # Display generated share URL
1639
+ share_url = st.session_state["share_url"]
1640
+ st.success("✅ Share link generated!")
1641
+ st.code(share_url, language=None)
1642
+
1643
+ # Copy to clipboard button
1644
+ components.html(
1645
+ f"""
1646
+ <script>
1647
+ function copyToClipboard() {{
1648
+ navigator.clipboard.writeText("{share_url}").then(function() {{
1649
+ alert("Share link copied to clipboard!");
1650
+ }}, function(err) {{
1651
+ console.error('Could not copy text: ', err);
1652
+ }});
1653
+ }}
1654
+ </script>
1655
+ <button onclick="copyToClipboard()" style="
1656
+ width: 100%;
1657
+ padding: 0.5rem 1rem;
1658
+ background: #1d64c8;
1659
+ color: white;
1660
+ border: none;
1661
+ border-radius: 0.5rem;
1662
+ cursor: pointer;
1663
+ font-size: 1rem;
1664
+ font-weight: bold;
1665
+ ">
1666
+ 📋 Copy Link
1667
+ </button>
1668
+ """,
1669
+ height=60
1670
+ )
1671
 
1672
  st.markdown("---")
1673
 
battlewords/word_loader.py CHANGED
@@ -2,6 +2,7 @@ from __future__ import annotations
2
 
3
  import re
4
  import os
 
5
  from typing import Dict, List, Optional
6
 
7
  import streamlit as st
@@ -128,4 +129,86 @@ def load_word_list(selected_file: Optional[str] = None) -> Dict[int, List[str]]:
128
  except Exception:
129
  # Missing file or read error
130
  used_source = "fallback"
131
- return _finalize(FALLBACK_WORDS, used_source)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
  import re
4
  import os
5
+ import string
6
  from typing import Dict, List, Optional
7
 
8
  import streamlit as st
 
129
  except Exception:
130
  # Missing file or read error
131
  used_source = "fallback"
132
+ return _finalize(FALLBACK_WORDS, used_source)
133
+
134
+
135
+ # Ensure this function is at module scope (not indented) and import string at top
136
+ def compute_word_difficulties(file_path, words_array=None):
137
+ """
138
+ 1. Read and sanitize word list: uppercase A–Z only, skip comments/blank lines.
139
+ 2. Count occurrences of each letter across all words (A..Z only).
140
+ 3. Compute frequency f_l = count / n, rarity r_l = 1 - f_l for each letter.
141
+ 4. Count words sharing same first/last letters for each pair.
142
+ 5. If words_array provided, use it (uppercase); else use full list.
143
+ 6. For each word: get unique letters L_w, k = |L_w|.
144
+ 7. Compute average rarity a_w = sum(r_l for l in L_w) / k.
145
+ 8. Get count c_w of words with same first/last, uniqueness u_w = 1 / c_w.
146
+ 9. Difficulty d_w = [k * (26 - k)] / [(k + 1) * (a_w + u_w)] if denominator != 0, else 0.
147
+ 10. Return total difficulty (sum d_w) and dict of {word: d_w}.
148
+ """
149
+ try:
150
+ with open(file_path, 'r', encoding='utf-8') as f:
151
+ raw_lines = f.readlines()
152
+ except Exception:
153
+ return 0, {}
154
+
155
+ # Sanitize lines similarly to load_word_list()
156
+ cleaned_words = []
157
+ for raw in raw_lines:
158
+ line = raw.strip()
159
+ if not line or line.startswith("#"):
160
+ continue
161
+ if "#" in line:
162
+ line = line.split("#", 1)[0].strip()
163
+ word = line.upper()
164
+ # keep only A–Z words
165
+ if re.fullmatch(r"[A-Z]+", word):
166
+ cleaned_words.append(word)
167
+
168
+ W = cleaned_words
169
+ n = len(W)
170
+ if n == 0:
171
+ return 0, {}
172
+
173
+ letter_counts = {l: 0 for l in string.ascii_uppercase}
174
+ start_end_counts = {}
175
+
176
+ for w in W:
177
+ letters = set(w)
178
+ # Only count A..Z to avoid KeyError
179
+ for l in letters:
180
+ if l in letter_counts:
181
+ letter_counts[l] += 1
182
+ first, last = w[0], w[-1]
183
+ key = (first, last)
184
+ start_end_counts[key] = start_end_counts.get(key, 0) + 1
185
+
186
+ f_l = {l: count / n for l, count in letter_counts.items()}
187
+ r_l = {l: 1 - f for l, f in f_l.items()}
188
+
189
+ if words_array is None:
190
+ words_array = W
191
+ else:
192
+ # Ensure A–Z and uppercase for the selection as well
193
+ words_array = [
194
+ w.upper()
195
+ for w in words_array
196
+ if re.fullmatch(r"[A-Z]+", w.upper())
197
+ ]
198
+
199
+ difficulties = {}
200
+ for w in words_array:
201
+ L_w = set(w)
202
+ k = len(L_w)
203
+ if k == 0:
204
+ continue
205
+ a_w = sum(r_l.get(l, 0) for l in L_w) / k
206
+ first, last = w[0], w[-1]
207
+ c_w = start_end_counts.get((first, last), 1)
208
+ u_w = 1 / c_w
209
+ denominator = (k + 1) * (a_w + u_w)
210
+ d_w = 0 if denominator == 0 else (k * (26 - k)) / denominator
211
+ difficulties[w] = d_w
212
+
213
+ total_difficulty = sum(difficulties.values())
214
+ return total_difficulty, difficulties
specs/requirements.md CHANGED
@@ -129,6 +129,12 @@ Current Deltas (0.1.3 → 0.1.10)
129
  - Guess feedback indicator switched to Correct/Try Again.
130
  - Version footer shows commit/Python/Streamlit; ocean background effect.
131
  - Word list default/persistence fixes and sort action persists after delay.
 
 
 
 
 
 
132
 
133
  Known Issues / TODO
134
  - Word list selection bug: improper list fetched/propagated in some runs.
 
129
  - Guess feedback indicator switched to Correct/Try Again.
130
  - Version footer shows commit/Python/Streamlit; ocean background effect.
131
  - Word list default/persistence fixes and sort action persists after delay.
132
+ - 0.2.24
133
+ - compress height
134
+ - change incorrect guess tooltip location
135
+ - update final screen layout
136
+ - add word difficulty formula
137
+ - update documentation
138
 
139
  Known Issues / TODO
140
  - Word list selection bug: improper list fetched/propagated in some runs.
specs/specs.md CHANGED
@@ -59,6 +59,12 @@ Battlewords is inspired by the classic Battleship game, but uses words instead o
59
  - **High Scores:** Top scores are tracked and displayed in the sidebar, filterable by wordlist and game mode.
60
  - **Player Name:** Optional player name is saved with results.
61
 
 
 
 
 
 
 
62
  ## Storage
63
  - Game results and high scores are stored in JSON files for privacy and offline access.
64
  - Game ID is generated from the sorted word list for replay/sharing.
@@ -118,4 +124,5 @@ Security/Privacy
118
 
119
  - When loading a shared challenge via `game_id`, the leaderboard displays all user results for that challenge.
120
  - **Sorting:** The leaderboard is sorted by highest score (descending), then by fastest time (ascending).
 
121
  - Results are stored remotely in a Hugging Face dataset repo and updated via the app.
 
59
  - **High Scores:** Top scores are tracked and displayed in the sidebar, filterable by wordlist and game mode.
60
  - **Player Name:** Optional player name is saved with results.
61
 
62
+ ## New Features (v0.2.24)
63
+ - **UI Improvements:** More compact layout, improved tooltip for incorrect guesses, and updated final score screen.
64
+ - **Word Difficulty:** Added a word difficulty formula and display for each game/challenge, visible in the final score and leaderboard.
65
+ - **Challenge Mode:** Enhanced leaderboard with difficulty display, improved result submission, and clearer challenge sharing.
66
+ - **Documentation:** Updated to reflect new features and UI changes.
67
+
68
  ## Storage
69
  - Game results and high scores are stored in JSON files for privacy and offline access.
70
  - Game ID is generated from the sorted word list for replay/sharing.
 
124
 
125
  - When loading a shared challenge via `game_id`, the leaderboard displays all user results for that challenge.
126
  - **Sorting:** The leaderboard is sorted by highest score (descending), then by fastest time (ascending).
127
+ - **Difficulty:** Each result now displays a computed word list difficulty value.
128
  - Results are stored remotely in a Hugging Face dataset repo and updated via the app.