Leaderboards Part 2, implement footer menu
Browse files- README.md +2 -1
- wrdler/leaderboard_page.py +36 -59
- wrdler/ui.py +214 -77
README.md
CHANGED
|
@@ -86,7 +86,7 @@ Wrdler is a vocabulary learning game with a simplified grid and strategic letter
|
|
| 86 |
- **Settings-Based:** Each unique combination of game-affecting settings (game mode, wordlist, free letters, etc.) has its own leaderboard. You compete only with players using the same settings.
|
| 87 |
- **Qualification:** Only the top 20 scores (sorted by score, then time, then difficulty) are displayed for each leaderboard. All submissions are stored, but only the best are shown.
|
| 88 |
- **Automatic Submission:** After each game, you can submit your score to the leaderboards from the game over popup. Challenge submissions also submit to leaderboards.
|
| 89 |
-
- **Leaderboard Page:** Access the Leaderboards from the
|
| 90 |
|
| 91 |
### Deployment & Technical
|
| 92 |
- **Dockerfile-based deployment** supported for Hugging Face Spaces and other container platforms
|
|
@@ -201,6 +201,7 @@ All test files must be placed in the `/tests` folder. This ensures a clean proje
|
|
| 201 |
4. Earn points for correct guesses and bonus points for unrevealed letters.
|
| 202 |
5. **The game ends when all six words are found or all word letters are revealed. Your score tier is displayed.**
|
| 203 |
6. **To play a shared challenge, use a link with `?game_id=<sid>`. Your result will be added to the challenge leaderboard.**
|
|
|
|
| 204 |
|
| 205 |
## Changelog
|
| 206 |
|
|
|
|
| 86 |
- **Settings-Based:** Each unique combination of game-affecting settings (game mode, wordlist, free letters, etc.) has its own leaderboard. You compete only with players using the same settings.
|
| 87 |
- **Qualification:** Only the top 20 scores (sorted by score, then time, then difficulty) are displayed for each leaderboard. All submissions are stored, but only the best are shown.
|
| 88 |
- **Automatic Submission:** After each game, you can submit your score to the leaderboards from the game over popup. Challenge submissions also submit to leaderboards.
|
| 89 |
+
- **Leaderboard Page:** Access the Leaderboards from the footer navigation to view daily, weekly, and historical results, filtered by your current settings.
|
| 90 |
|
| 91 |
### Deployment & Technical
|
| 92 |
- **Dockerfile-based deployment** supported for Hugging Face Spaces and other container platforms
|
|
|
|
| 201 |
4. Earn points for correct guesses and bonus points for unrevealed letters.
|
| 202 |
5. **The game ends when all six words are found or all word letters are revealed. Your score tier is displayed.**
|
| 203 |
6. **To play a shared challenge, use a link with `?game_id=<sid>`. Your result will be added to the challenge leaderboard.**
|
| 204 |
+
7. **Access leaderboards anytime using the footer navigation links at the bottom of the page.**
|
| 205 |
|
| 206 |
## Changelog
|
| 207 |
|
wrdler/leaderboard_page.py
CHANGED
|
@@ -10,6 +10,7 @@ __version__ = "0.2.0"
|
|
| 10 |
import streamlit as st
|
| 11 |
from datetime import datetime
|
| 12 |
from typing import Optional
|
|
|
|
| 13 |
|
| 14 |
from wrdler.leaderboard import (
|
| 15 |
load_leaderboard,
|
|
@@ -55,67 +56,36 @@ def _render_leaderboard_table(leaderboard: Optional[LeaderboardSettings], title:
|
|
| 55 |
# Get display users (limited to max_display_entries)
|
| 56 |
display_users = leaderboard.get_display_users()
|
| 57 |
|
| 58 |
-
# Build table
|
| 59 |
-
|
| 60 |
for i, user in enumerate(display_users, 1):
|
| 61 |
rank_display = _get_rank_emoji(i)
|
| 62 |
time_display = _format_time(user.time)
|
| 63 |
difficulty = f"{user.word_list_difficulty:.2f}" if user.word_list_difficulty else "-"
|
| 64 |
-
|
| 65 |
# Show challenge indicator if from a challenge
|
| 66 |
challenge_badge = " 🎯" if user.source_challenge_id else ""
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
.
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
.
|
| 88 |
-
background: #1d64c8;
|
| 89 |
-
color: white;
|
| 90 |
-
padding: 0.75rem;
|
| 91 |
-
text-align: center;
|
| 92 |
-
}}
|
| 93 |
-
.lb-table td {{
|
| 94 |
-
padding: 0.5rem 0.75rem;
|
| 95 |
-
border-bottom: 1px solid rgba(255,255,255,0.1);
|
| 96 |
-
color: white;
|
| 97 |
-
}}
|
| 98 |
-
.lb-table tr:hover {{
|
| 99 |
-
background: rgba(29, 100, 200, 0.3);
|
| 100 |
-
}}
|
| 101 |
-
</style>
|
| 102 |
-
<table class="lb-table">
|
| 103 |
-
<thead>
|
| 104 |
-
<tr>
|
| 105 |
-
<th>Rank</th>
|
| 106 |
-
<th>Player</th>
|
| 107 |
-
<th>Score</th>
|
| 108 |
-
<th>Time</th>
|
| 109 |
-
<th>Difficulty</th>
|
| 110 |
-
</tr>
|
| 111 |
-
</thead>
|
| 112 |
-
<tbody>
|
| 113 |
-
{''.join(rows)}
|
| 114 |
-
</tbody>
|
| 115 |
-
</table>
|
| 116 |
-
"""
|
| 117 |
-
|
| 118 |
-
st.markdown(table_html, unsafe_allow_html=True)
|
| 119 |
|
| 120 |
# Show entry count and last updated
|
| 121 |
total_entries = len(leaderboard.users)
|
|
@@ -139,13 +109,20 @@ def _get_current_game_settings() -> GameSettings:
|
|
| 139 |
)
|
| 140 |
|
| 141 |
|
| 142 |
-
def render_leaderboard_page():
|
| 143 |
-
"""Render the full leaderboard page.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
game_title = APP_SETTINGS.get("game_title", "Wrdler")
|
| 145 |
st.title(f"🏆 {game_title} Leaderboards")
|
| 146 |
|
| 147 |
-
# Tab selection
|
| 148 |
-
|
|
|
|
|
|
|
|
|
|
| 149 |
|
| 150 |
with tab1:
|
| 151 |
_render_daily_tab()
|
|
|
|
| 10 |
import streamlit as st
|
| 11 |
from datetime import datetime
|
| 12 |
from typing import Optional
|
| 13 |
+
import pandas as pd
|
| 14 |
|
| 15 |
from wrdler.leaderboard import (
|
| 16 |
load_leaderboard,
|
|
|
|
| 56 |
# Get display users (limited to max_display_entries)
|
| 57 |
display_users = leaderboard.get_display_users()
|
| 58 |
|
| 59 |
+
# Build table data for Streamlit native table
|
| 60 |
+
table_data = []
|
| 61 |
for i, user in enumerate(display_users, 1):
|
| 62 |
rank_display = _get_rank_emoji(i)
|
| 63 |
time_display = _format_time(user.time)
|
| 64 |
difficulty = f"{user.word_list_difficulty:.2f}" if user.word_list_difficulty else "-"
|
| 65 |
+
|
| 66 |
# Show challenge indicator if from a challenge
|
| 67 |
challenge_badge = " 🎯" if user.source_challenge_id else ""
|
| 68 |
+
username_display = f"{user.username}{challenge_badge}"
|
| 69 |
+
|
| 70 |
+
table_data.append({
|
| 71 |
+
"Rank": rank_display,
|
| 72 |
+
"Player": username_display,
|
| 73 |
+
"Score": user.score,
|
| 74 |
+
"Time": time_display,
|
| 75 |
+
"Difficulty": difficulty
|
| 76 |
+
})
|
| 77 |
+
|
| 78 |
+
# Use Streamlit's native table with custom styling
|
| 79 |
+
if table_data:
|
| 80 |
+
# Create a dataframe for better control
|
| 81 |
+
df = pd.DataFrame(table_data)
|
| 82 |
+
|
| 83 |
+
# Style the dataframe
|
| 84 |
+
styled_df = df.style.apply(lambda x: ['text-align: center; font-weight: bold; font-size: 1.2rem;' if col == 'Rank' else
|
| 85 |
+
'text-align: center; color: #20d46c; font-weight: bold;' if col == 'Score' else
|
| 86 |
+
'text-align: center;' for col in df.columns], axis=1)
|
| 87 |
+
|
| 88 |
+
st.dataframe(styled_df, use_container_width=True, hide_index=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
|
| 90 |
# Show entry count and last updated
|
| 91 |
total_entries = len(leaderboard.users)
|
|
|
|
| 109 |
)
|
| 110 |
|
| 111 |
|
| 112 |
+
def render_leaderboard_page(default_tab: str = "daily"):
|
| 113 |
+
"""Render the full leaderboard page.
|
| 114 |
+
|
| 115 |
+
Args:
|
| 116 |
+
default_tab: Which tab to show by default ("daily" or "weekly")
|
| 117 |
+
"""
|
| 118 |
game_title = APP_SETTINGS.get("game_title", "Wrdler")
|
| 119 |
st.title(f"🏆 {game_title} Leaderboards")
|
| 120 |
|
| 121 |
+
# Tab selection - set default based on parameter
|
| 122 |
+
tab_names = ["📅 Daily", "📆 Weekly", "📚 History"]
|
| 123 |
+
|
| 124 |
+
# Create tabs
|
| 125 |
+
tab1, tab2, tab3 = st.tabs(tab_names)
|
| 126 |
|
| 127 |
with tab1:
|
| 128 |
_render_daily_tab()
|
wrdler/ui.py
CHANGED
|
@@ -544,7 +544,7 @@ border-radius: 50% !important;
|
|
| 544 |
position:relative;
|
| 545 |
z-index: 1200;
|
| 546 |
}
|
| 547 |
-
.username_input id[="text_input"] { color: #fff;}
|
| 548 |
.st-emotion-cache-18kf3ut, .stColumn.st-emotion-cache-116javk {padding-bottom:3px;}
|
| 549 |
|
| 550 |
/* grid adjustments */
|
|
@@ -603,7 +603,7 @@ border-radius: 50% !important;
|
|
| 603 |
.bw-radio-circle.active.miss { background: linear-gradient(135deg, rgba(255,0,0,0.18), rgba(128,0,0,0.38)); }
|
| 604 |
.bw-radio-circle.active.miss .dot { background:#ff4b4b; box-shadow: 0 0 10px rgba(255,75,75,0.85); }
|
| 605 |
.bw-radio-caption { font-size: 0.8rem; color:#fff; opacity:0.85; letter-spacing:0.5px; }
|
| 606 |
-
@media (max-width:1000px) and (min-width:641px) {
|
| 607 |
.bw-radio-group { flex-wrap:wrap; gap: 5px; margin-bottom: 5px;}
|
| 608 |
.bw-radio-item {margin: 0 auto;}
|
| 609 |
}
|
|
@@ -1252,7 +1252,7 @@ def _render_grid(state: GameState, letter_map, show_grid_ticks: bool = True):
|
|
| 1252 |
# Determine if the game is over to reveal all remaining tiles as blanks
|
| 1253 |
game_over = is_game_over(state)
|
| 1254 |
|
| 1255 |
-
# Inject CSS for grid - adjusted for 8×6
|
| 1256 |
st.markdown(
|
| 1257 |
"""
|
| 1258 |
<style>
|
|
@@ -1642,7 +1642,7 @@ def _render_score_panel(state: GameState):
|
|
| 1642 |
<div class='bw-score-panel-container'>
|
| 1643 |
<style>
|
| 1644 |
.bold-text {{ font-weight: 700; }}
|
| 1645 |
-
.blue-background {{ background:#1d64c8; opacity:0.9;
|
| 1646 |
.shiny-border {{ position: relative; padding: 12px; background: #333; color: white; border-radius: 1.25rem; overflow: hidden; }}
|
| 1647 |
.bw-score-panel-container {{ height: 100%; overflow: hidden; text-align:center;}}
|
| 1648 |
|
|
@@ -1875,7 +1875,7 @@ def _game_over_content(state: GameState) -> None:
|
|
| 1875 |
}
|
| 1876 |
/* Ensure code block is readable */
|
| 1877 |
div[data-testid="stVerticalBlock"]:has(#bw-share-anchor) pre,
|
| 1878 |
-
div[data-testid="stVerticalBlock"]:has(#bw-share-anchor) code {
|
| 1879 |
background: rgba(0,0,0,0.25) !important;
|
| 1880 |
color: #fff !important;
|
| 1881 |
}
|
|
@@ -1912,9 +1912,12 @@ def _game_over_content(state: GameState) -> None:
|
|
| 1912 |
|
| 1913 |
# Helper function to submit to leaderboards
|
| 1914 |
def _submit_to_leaderboards(username: str, score: int, time_secs: int, word_list: list, challenge_id: str = None):
|
| 1915 |
-
"""Submit score to daily and weekly leaderboards.
|
| 1916 |
-
|
| 1917 |
-
|
|
|
|
|
|
|
|
|
|
| 1918 |
settings = GameSettings(
|
| 1919 |
game_mode=state.game_mode,
|
| 1920 |
wordlist_source=st.session_state.get("selected_wordlist", "classic.txt"),
|
|
@@ -1925,16 +1928,38 @@ def _game_over_content(state: GameState) -> None:
|
|
| 1925 |
"may_overlap": getattr(state.puzzle, "may_overlap", False)
|
| 1926 |
}
|
| 1927 |
)
|
| 1928 |
-
|
| 1929 |
-
|
| 1930 |
-
|
| 1931 |
-
|
| 1932 |
-
|
| 1933 |
-
|
| 1934 |
-
|
| 1935 |
-
|
| 1936 |
-
|
| 1937 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1938 |
return results
|
| 1939 |
|
| 1940 |
# Check if share URL already generated
|
|
@@ -1947,7 +1972,7 @@ def _game_over_content(state: GameState) -> None:
|
|
| 1947 |
word_list = [w.text for w in state.puzzle.words]
|
| 1948 |
spacer = state.puzzle.spacer
|
| 1949 |
may_overlap = state.puzzle.may_overlap
|
| 1950 |
-
wordlist_source = st.session_state.get("selected_wordlist", "
|
| 1951 |
|
| 1952 |
if is_shared_game and existing_sid:
|
| 1953 |
# Add result to existing game
|
|
@@ -2073,7 +2098,6 @@ def _game_over_content(state: GameState) -> None:
|
|
| 2073 |
margin-top:6px;
|
| 2074 |
justify-content:center;
|
| 2075 |
">
|
| 2076 |
-
|
| 2077 |
<strong><a href="{_share_url_attr}"
|
| 2078 |
target="_blank"
|
| 2079 |
rel="noopener noreferrer"
|
|
@@ -2161,6 +2185,164 @@ def _render_game_over(state: GameState):
|
|
| 2161 |
else:
|
| 2162 |
_mount_background_audio(False, None, 0.0)
|
| 2163 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2164 |
def run_app():
|
| 2165 |
# Render PWA service worker registration (meta tags in <head> via Docker)
|
| 2166 |
st.markdown(pwa_service_worker, unsafe_allow_html=True)
|
|
@@ -2180,6 +2362,17 @@ def run_app():
|
|
| 2180 |
pass
|
| 2181 |
st.session_state["hide_gameover_overlay"] = True
|
| 2182 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2183 |
# Handle game_id for loading shared games
|
| 2184 |
if "game_id" in params and "shared_game_loaded" not in st.session_state:
|
| 2185 |
sid = params.get("game_id")
|
|
@@ -2248,60 +2441,4 @@ def run_app():
|
|
| 2248 |
state = _to_state()
|
| 2249 |
if is_game_over(state) and not st.session_state.get("hide_gameover_overlay", False):
|
| 2250 |
_render_game_over(state)
|
| 2251 |
-
|
| 2252 |
-
def _on_game_option_change() -> None:
|
| 2253 |
-
"""
|
| 2254 |
-
Unified callback for game option changes.
|
| 2255 |
-
If currently in a loaded challenge, break the link by resetting challenge mode
|
| 2256 |
-
and removing the game_id query param. Then start a new game with the updated options.
|
| 2257 |
-
"""
|
| 2258 |
-
try:
|
| 2259 |
-
# Remove challenge-specific query param if present
|
| 2260 |
-
if hasattr(st, "query_params"):
|
| 2261 |
-
qp = st.query_params
|
| 2262 |
-
# st.query_params may be a Mapping; pop safely if supported
|
| 2263 |
-
try:
|
| 2264 |
-
if "game_id" in qp:
|
| 2265 |
-
qp.pop("game_id")
|
| 2266 |
-
except Exception:
|
| 2267 |
-
# Fallback: clear all params if pop not supported
|
| 2268 |
-
try:
|
| 2269 |
-
st.query_params.clear()
|
| 2270 |
-
except Exception:
|
| 2271 |
-
pass
|
| 2272 |
-
except Exception:
|
| 2273 |
-
pass
|
| 2274 |
-
|
| 2275 |
-
# Clear challenge session flags and links
|
| 2276 |
-
if st.session_state.get("loaded_game_sid") is not None:
|
| 2277 |
-
st.session_state.loaded_game_sid = None
|
| 2278 |
-
# Remove loaded challenge settings so UI no longer treats session as challenge mode
|
| 2279 |
-
st.session_state.pop("shared_game_settings", None)
|
| 2280 |
-
# Ensure the loader won't auto-reload challenge on rerun within this session
|
| 2281 |
-
st.session_state["shared_game_loaded"] = True
|
| 2282 |
-
|
| 2283 |
-
# Clear any existing generated share link tied to the previous challenge
|
| 2284 |
-
st.session_state.pop("share_url", None)
|
| 2285 |
-
st.session_state.pop("share_sid", None)
|
| 2286 |
-
|
| 2287 |
-
# Start a fresh game with updated options
|
| 2288 |
-
_new_game()
|
| 2289 |
-
|
| 2290 |
-
def _on_wordlist_change() -> None:
|
| 2291 |
-
"""
|
| 2292 |
-
Callback when wordlist selection changes.
|
| 2293 |
-
Updates session state flags for AI vs file mode.
|
| 2294 |
-
"""
|
| 2295 |
-
selected = st.session_state.get("wordlist_selector")
|
| 2296 |
-
|
| 2297 |
-
if selected == "AI Generated":
|
| 2298 |
-
st.session_state.use_ai_wordlist = True
|
| 2299 |
-
# Preserve current AI topic or use default
|
| 2300 |
-
if "ai_topic" not in st.session_state:
|
| 2301 |
-
st.session_state.ai_topic = "English"
|
| 2302 |
-
else:
|
| 2303 |
-
st.session_state.use_ai_wordlist = False
|
| 2304 |
-
st.session_state.selected_wordlist = selected
|
| 2305 |
-
|
| 2306 |
-
# Trigger new game with updated wordlist
|
| 2307 |
-
_on_game_option_change()
|
|
|
|
| 544 |
position:relative;
|
| 545 |
z-index: 1200;
|
| 546 |
}
|
| 547 |
+
.username_input id[="text_input"], .st-key-username_input id[="text_input"] { color: #fff;}
|
| 548 |
.st-emotion-cache-18kf3ut, .stColumn.st-emotion-cache-116javk {padding-bottom:3px;}
|
| 549 |
|
| 550 |
/* grid adjustments */
|
|
|
|
| 603 |
.bw-radio-circle.active.miss { background: linear-gradient(135deg, rgba(255,0,0,0.18), rgba(128,0,0,0.38)); }
|
| 604 |
.bw-radio-circle.active.miss .dot { background:#ff4b4b; box-shadow: 0 0 10px rgba(255,75,75,0.85); }
|
| 605 |
.bw-radio-caption { font-size: 0.8rem; color:#fff; opacity:0.85; letter-spacing:0.5px; }
|
| 606 |
+
@media (max-width:1000px) and (min-width: 641px) {
|
| 607 |
.bw-radio-group { flex-wrap:wrap; gap: 5px; margin-bottom: 5px;}
|
| 608 |
.bw-radio-item {margin: 0 auto;}
|
| 609 |
}
|
|
|
|
| 1252 |
# Determine if the game is over to reveal all remaining tiles as blanks
|
| 1253 |
game_over = is_game_over(state)
|
| 1254 |
|
| 1255 |
+
# Inject CSS for grid - adjusted for 8×6
|
| 1256 |
st.markdown(
|
| 1257 |
"""
|
| 1258 |
<style>
|
|
|
|
| 1642 |
<div class='bw-score-panel-container'>
|
| 1643 |
<style>
|
| 1644 |
.bold-text {{ font-weight: 700; }}
|
| 1645 |
+
.blue-background {{ background:#1d64c8; opacity:0.9; }}
|
| 1646 |
.shiny-border {{ position: relative; padding: 12px; background: #333; color: white; border-radius: 1.25rem; overflow: hidden; }}
|
| 1647 |
.bw-score-panel-container {{ height: 100%; overflow: hidden; text-align:center;}}
|
| 1648 |
|
|
|
|
| 1875 |
}
|
| 1876 |
/* Ensure code block is readable */
|
| 1877 |
div[data-testid="stVerticalBlock"]:has(#bw-share-anchor) pre,
|
| 1878 |
+
div[data-testid="stVerticalBlock"]:has(#bw-share-anchor) code, div[role="tooltip"] {
|
| 1879 |
background: rgba(0,0,0,0.25) !important;
|
| 1880 |
color: #fff !important;
|
| 1881 |
}
|
|
|
|
| 1912 |
|
| 1913 |
# Helper function to submit to leaderboards
|
| 1914 |
def _submit_to_leaderboards(username: str, score: int, time_secs: int, word_list: list, challenge_id: str = None):
|
| 1915 |
+
"""Submit score to daily and weekly leaderboards (with fallback verification).
|
| 1916 |
+
Uses main `submit_score_to_all_leaderboards` and ensures weekly entry is attempted
|
| 1917 |
+
even if the main call fails to produce a weekly result.
|
| 1918 |
+
"""
|
| 1919 |
+
from .leaderboard import GameSettings, submit_score_to_all_leaderboards, submit_to_leaderboard, get_current_weekly_id
|
| 1920 |
+
|
| 1921 |
settings = GameSettings(
|
| 1922 |
game_mode=state.game_mode,
|
| 1923 |
wordlist_source=st.session_state.get("selected_wordlist", "classic.txt"),
|
|
|
|
| 1928 |
"may_overlap": getattr(state.puzzle, "may_overlap", False)
|
| 1929 |
}
|
| 1930 |
)
|
| 1931 |
+
|
| 1932 |
+
# Primary submission (attempt both daily and weekly)
|
| 1933 |
+
try:
|
| 1934 |
+
results = submit_score_to_all_leaderboards(
|
| 1935 |
+
username=username,
|
| 1936 |
+
score=score,
|
| 1937 |
+
time_seconds=time_secs,
|
| 1938 |
+
word_list=word_list,
|
| 1939 |
+
settings=settings,
|
| 1940 |
+
word_list_difficulty=difficulty_value,
|
| 1941 |
+
source_challenge_id=challenge_id,
|
| 1942 |
+
)
|
| 1943 |
+
except Exception:
|
| 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
|
|
|
|
| 1972 |
word_list = [w.text for w in state.puzzle.words]
|
| 1973 |
spacer = state.puzzle.spacer
|
| 1974 |
may_overlap = state.puzzle.may_overlap
|
| 1975 |
+
wordlist_source = st.session_state.get("selected_wordlist", "classic.txt")
|
| 1976 |
|
| 1977 |
if is_shared_game and existing_sid:
|
| 1978 |
# Add result to existing game
|
|
|
|
| 2098 |
margin-top:6px;
|
| 2099 |
justify-content:center;
|
| 2100 |
">
|
|
|
|
| 2101 |
<strong><a href="{_share_url_attr}"
|
| 2102 |
target="_blank"
|
| 2103 |
rel="noopener noreferrer"
|
|
|
|
| 2185 |
else:
|
| 2186 |
_mount_background_audio(False, None, 0.0)
|
| 2187 |
|
| 2188 |
+
|
| 2189 |
+
def _on_game_option_change() -> None:
|
| 2190 |
+
"""
|
| 2191 |
+
Unified callback for game option changes.
|
| 2192 |
+
If currently in a loaded challenge, break the link by resetting challenge mode
|
| 2193 |
+
and removing the game_id query param. Then start a new game with the updated options.
|
| 2194 |
+
"""
|
| 2195 |
+
try:
|
| 2196 |
+
# Remove challenge-specific query param if present
|
| 2197 |
+
if hasattr(st, "query_params"):
|
| 2198 |
+
qp = st.query_params
|
| 2199 |
+
# st.query_params may be a Mapping; pop safely if supported
|
| 2200 |
+
try:
|
| 2201 |
+
if "game_id" in qp:
|
| 2202 |
+
qp.pop("game_id")
|
| 2203 |
+
except Exception:
|
| 2204 |
+
# Fallback: clear all params if pop not supported
|
| 2205 |
+
try:
|
| 2206 |
+
st.query_params.clear()
|
| 2207 |
+
except Exception:
|
| 2208 |
+
pass
|
| 2209 |
+
except Exception:
|
| 2210 |
+
pass
|
| 2211 |
+
|
| 2212 |
+
# Clear challenge session flags and links
|
| 2213 |
+
if st.session_state.get("loaded_game_sid") is not None:
|
| 2214 |
+
st.session_state.loaded_game_sid = None
|
| 2215 |
+
# Remove loaded challenge settings so UI no longer treats session as challenge mode
|
| 2216 |
+
st.session_state.pop("shared_game_settings", None)
|
| 2217 |
+
# Ensure the loader won't auto-reload challenge on rerun within this session
|
| 2218 |
+
st.session_state["shared_game_loaded"] = True
|
| 2219 |
+
|
| 2220 |
+
# Clear any existing generated share link tied to the previous challenge
|
| 2221 |
+
st.session_state.pop("share_url", None)
|
| 2222 |
+
st.session_state.pop("share_sid", None)
|
| 2223 |
+
|
| 2224 |
+
# Start a fresh game with updated options
|
| 2225 |
+
_new_game()
|
| 2226 |
+
|
| 2227 |
+
def _on_wordlist_change() -> None:
|
| 2228 |
+
"""
|
| 2229 |
+
Callback when wordlist selection changes.
|
| 2230 |
+
Updates session state flags for AI vs file mode.
|
| 2231 |
+
"""
|
| 2232 |
+
selected = st.session_state.get("wordlist_selector")
|
| 2233 |
+
|
| 2234 |
+
if selected == "AI Generated":
|
| 2235 |
+
st.session_state.use_ai_wordlist = True
|
| 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 |
+
|
| 2257 |
+
# Check if we're in challenge mode and need to preserve game_id
|
| 2258 |
+
game_id = None
|
| 2259 |
+
try:
|
| 2260 |
+
params = st.query_params
|
| 2261 |
+
if "game_id" in params:
|
| 2262 |
+
game_id = params.get("game_id")
|
| 2263 |
+
except Exception:
|
| 2264 |
+
pass
|
| 2265 |
+
|
| 2266 |
+
# Also check session state for loaded challenge
|
| 2267 |
+
if not game_id and st.session_state.get("loaded_game_sid"):
|
| 2268 |
+
game_id = st.session_state.get("loaded_game_sid")
|
| 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 = "/"
|
| 2279 |
+
|
| 2280 |
+
st.markdown(
|
| 2281 |
+
f"""
|
| 2282 |
+
<style>
|
| 2283 |
+
.bw-footer {{
|
| 2284 |
+
position: fixed;
|
| 2285 |
+
bottom: 0;
|
| 2286 |
+
left: 0;
|
| 2287 |
+
right: 0;
|
| 2288 |
+
background: linear-gradient(180deg, transparent 0%, rgba(11, 42, 74, 0.95) 30%, rgba(11, 42, 74, 0.98) 100%);
|
| 2289 |
+
padding: 0.75rem 1rem 0.5rem;
|
| 2290 |
+
z-index: 9998;
|
| 2291 |
+
text-align: center;
|
| 2292 |
+
}}
|
| 2293 |
+
.bw-footer-nav {{
|
| 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);
|
| 2309 |
+
transition: all 0.2s ease;
|
| 2310 |
+
}}
|
| 2311 |
+
.bw-footer-nav a:hover {{
|
| 2312 |
+
background: rgba(29, 100, 200, 0.6);
|
| 2313 |
+
border-color: rgba(215, 250, 255, 0.6);
|
| 2314 |
+
color: #ffffff;
|
| 2315 |
+
text-decoration: none;
|
| 2316 |
+
}}
|
| 2317 |
+
.bw-footer-nav a.active {{
|
| 2318 |
+
background: rgba(32, 212, 108, 0.3);
|
| 2319 |
+
border-color: rgba(32, 212, 108, 0.5);
|
| 2320 |
+
}}
|
| 2321 |
+
/* Add padding to main content to prevent footer overlap */
|
| 2322 |
+
.stMainBlockContainer {{
|
| 2323 |
+
padding-bottom: 70px !important;
|
| 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>
|
| 2342 |
+
""",
|
| 2343 |
+
unsafe_allow_html=True,
|
| 2344 |
+
)
|
| 2345 |
+
|
| 2346 |
def run_app():
|
| 2347 |
# Render PWA service worker registration (meta tags in <head> via Docker)
|
| 2348 |
st.markdown(pwa_service_worker, unsafe_allow_html=True)
|
|
|
|
| 2362 |
pass
|
| 2363 |
st.session_state["hide_gameover_overlay"] = True
|
| 2364 |
|
| 2365 |
+
# Handle page navigation via query params
|
| 2366 |
+
page = params.get("page", "")
|
| 2367 |
+
if page in ["daily", "weekly"]:
|
| 2368 |
+
# Render leaderboard page with ocean background
|
| 2369 |
+
from .leaderboard_page import render_leaderboard_page
|
| 2370 |
+
st.markdown(ocean_background_css, unsafe_allow_html=True)
|
| 2371 |
+
inject_ocean_layers()
|
| 2372 |
+
render_leaderboard_page(default_tab=page)
|
| 2373 |
+
_render_footer(current_page=page)
|
| 2374 |
+
return # Don't render game UI
|
| 2375 |
+
|
| 2376 |
# Handle game_id for loading shared games
|
| 2377 |
if "game_id" in params and "shared_game_loaded" not in st.session_state:
|
| 2378 |
sid = params.get("game_id")
|
|
|
|
| 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")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|