Spaces:
Running
Running
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 +7 -0
- battlewords/__init__.py +1 -1
- battlewords/game_storage.py +45 -15
- battlewords/ui.py +204 -126
- battlewords/word_loader.py +84 -1
- specs/requirements.md +6 -0
- specs/specs.md +7 -0
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.
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
user_result = {
|
| 88 |
-
"uid": generate_uid(),
|
| 89 |
"username": username,
|
| 90 |
-
"word_list": word_list,
|
| 91 |
-
"score": score,
|
| 92 |
-
"time": time_seconds,
|
| 93 |
-
"timestamp": datetime.now(timezone.utc).isoformat()
|
| 94 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
|
| 96 |
settings = {
|
| 97 |
-
"challenge_id": challenge_id,
|
| 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],
|
| 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 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
user_result = {
|
| 153 |
-
"uid": generate_uid(),
|
| 154 |
"username": username,
|
| 155 |
-
"word_list": word_list,
|
| 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
|
| 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:
|
| 223 |
-
margin-top:
|
| 224 |
-
font-size:
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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: -
|
| 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
|
| 1448 |
-
|
| 1449 |
-
<div class
|
| 1450 |
-
<div class
|
| 1451 |
-
<div class
|
| 1452 |
-
<div class
|
| 1453 |
-
<div class
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 1487 |
-
|
| 1488 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1489 |
|
| 1490 |
-
|
| 1491 |
-
if "player_username" not in st.session_state:
|
| 1492 |
-
st.session_state["player_username"] = ""
|
| 1493 |
|
| 1494 |
-
|
| 1495 |
-
"
|
| 1496 |
-
|
| 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 |
-
|
| 1506 |
-
|
| 1507 |
-
|
| 1508 |
|
| 1509 |
-
|
| 1510 |
-
|
| 1511 |
-
|
| 1512 |
-
|
| 1513 |
-
|
| 1514 |
-
|
| 1515 |
-
|
| 1516 |
-
|
| 1517 |
-
|
| 1518 |
-
|
| 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 |
-
|
| 1528 |
-
|
| 1529 |
-
|
| 1530 |
-
|
| 1531 |
-
|
| 1532 |
-
|
| 1533 |
-
|
| 1534 |
-
|
| 1535 |
-
|
| 1536 |
-
|
| 1537 |
-
|
| 1538 |
-
|
| 1539 |
-
|
| 1540 |
-
|
| 1541 |
-
|
| 1542 |
-
|
| 1543 |
-
|
| 1544 |
-
|
| 1545 |
-
|
| 1546 |
-
|
| 1547 |
-
|
| 1548 |
|
| 1549 |
-
|
| 1550 |
-
|
| 1551 |
-
|
| 1552 |
-
|
| 1553 |
-
|
|
|
|
|
|
|
|
|
|
| 1554 |
else:
|
| 1555 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1556 |
|
| 1557 |
-
|
| 1558 |
-
|
| 1559 |
-
|
| 1560 |
-
|
| 1561 |
-
|
| 1562 |
-
|
| 1563 |
-
|
| 1564 |
-
|
| 1565 |
-
|
| 1566 |
-
|
| 1567 |
-
|
| 1568 |
-
|
| 1569 |
-
|
| 1570 |
-
|
| 1571 |
-
|
| 1572 |
-
|
| 1573 |
-
|
| 1574 |
-
|
| 1575 |
-
|
| 1576 |
-
|
| 1577 |
-
|
| 1578 |
-
|
| 1579 |
-
|
| 1580 |
-
|
| 1581 |
-
|
| 1582 |
-
|
| 1583 |
-
|
| 1584 |
-
|
| 1585 |
-
|
| 1586 |
-
|
| 1587 |
-
|
| 1588 |
-
|
| 1589 |
-
|
| 1590 |
-
|
| 1591 |
-
|
| 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.
|