Surn commited on
Commit
f1fd35c
Β·
1 Parent(s): 5bc2001

Leaderboards Part 1

Browse files
pyproject.toml CHANGED
@@ -1,7 +1,7 @@
1
  [project]
2
  name = "wrdler"
3
- version = "0.1.0"
4
- description = "Wrdler vocabulary puzzle game - simplified version based on BattleWords with 8x6 grid, horizontal words only, no scope, and 2 free letter guesses"
5
  readme = "README.md"
6
  requires-python = ">=3.12,<3.13"
7
  dependencies = [
 
1
  [project]
2
  name = "wrdler"
3
+ version = "0.2.0"
4
+ description = "Wrdler vocabulary puzzle game - simplified version based on BattleWords with 8x6 grid, horizontal words only, no scope, and 2 free letter guesses. Features daily/weekly leaderboards."
5
  readme = "README.md"
6
  requires-python = ">=3.12,<3.13"
7
  dependencies = [
specs/leaderboard_spec.md ADDED
@@ -0,0 +1,1431 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Wrdler Leaderboard System Specification
2
+
3
+ **Document Version:** 1.2.0
4
+ **Target Project Version:** 0.2.0
5
+ **Author:** GitHub Copilot
6
+ **Date:** 2025-01-27
7
+ **Status:** Draft
8
+
9
+ ---
10
+
11
+ ## Table of Contents
12
+
13
+ 1. [Executive Summary](#1-executive-summary)
14
+ 2. [Goals and Objectives](#2-goals-and-objectives)
15
+ 3. [System Architecture](#3-system-architecture)
16
+ 4. [Data Models](#4-data-models)
17
+ 5. [New Python Modules](#5-new-python-modules)
18
+ 6. [Implementation Steps](#6-implementation-steps)
19
+ 7. [Version Changes](#7-version-changes)
20
+ 8. [File Changes Summary](#8-file-changes-summary)
21
+ 9. [API Reference](#9-api-reference)
22
+ 10. [UI Components](#10-ui-components)
23
+ 11. [Testing Requirements](#11-testing-requirements)
24
+ 12. [Migration Notes](#12-migration-notes)
25
+
26
+ ---
27
+
28
+ ## 1. Executive Summary
29
+
30
+ This specification describes the implementation of a **Daily and Weekly Leaderboard System** for Wrdler. The system will:
31
+
32
+ - Track top 20 scores for daily leaderboards (reset at UTC midnight)
33
+ - Track top 20 scores for weekly leaderboards (reset at UTC Monday 00:00)
34
+ - **Create separate leaderboards for each unique combination of game-affecting settings**
35
+ - Automatically add qualifying scores from any game completion (including challenge mode)
36
+ - Provide a dedicated leaderboard page with historical lookup capabilities
37
+ - Store leaderboard data in HuggingFace repository using existing storage infrastructure
38
+ - **Use a unified JSON format consistent with existing challenge settings.json files**
39
+
40
+ ---
41
+
42
+ ## 2. Goals and Objectives
43
+
44
+ ### Primary Goals
45
+
46
+ 1. **Settings-Based Leaderboards**: Each unique combination of game-affecting settings creates a separate leaderboard. Players using different settings compete on different leaderboards.
47
+
48
+ 2. **Daily Leaderboards**: Create and maintain daily leaderboards with top 20 entries displayed (can store more), organized by date folders (e.g., `leaderboards/daily/2025-01-27/`)
49
+
50
+ 3. **Weekly Leaderboards**: Create and maintain weekly leaderboards with top 20 entries displayed (can store more), organized by ISO week folders (e.g., `leaderboards/weekly/2025-W04/`)
51
+
52
+ 4. **Automatic Qualification**: Every game completion (challenge or solo) checks if score qualifies for the matching daily/weekly leaderboard based on current settings
53
+
54
+ 5. **Leaderboard Page**: New Streamlit page displaying:
55
+ - Last 7 days of daily leaderboards (filtered by current settings)
56
+ - Current weekly leaderboard (filtered by current settings)
57
+ - Historical lookup via dropdown
58
+
59
+ 6. **Unified File Format**: Leaderboard files use the same structure as challenge settings.json with an `entry_type` field to distinguish between "daily", "weekly", and "challenge" entries
60
+
61
+ ### Secondary Goals
62
+
63
+ - Maintain backward compatibility with existing challenge system
64
+ - Minimize HuggingFace API calls through caching
65
+ - Support sorting by score (descending), then time (ascending), then difficulty (descending)
66
+ - Use `challenge_id` as the primary identifier across all entry types
67
+
68
+ ### Game-Affecting Settings
69
+
70
+ The following settings define a unique leaderboard:
71
+
72
+ | Setting | Type | Description |
73
+ |---------|------|-------------|
74
+ | `game_mode` | string | `"classic"`, `"easy"`, `"too easy"` |
75
+ | `wordlist_source` | string | Wordlist file (e.g., `"classic.txt"`, `"easy.txt"`) |
76
+ | `show_incorrect_guesses` | bool | Whether incorrect guesses are shown |
77
+ | `enable_free_letters` | bool | Whether free letters feature is enabled |
78
+ | `puzzle_options` | object | Puzzle configuration (`spacer`, `may_overlap`) |
79
+
80
+ **Example:** A player using `game_mode: "easy"` with `wordlist_source: "easy.txt"` competes on a different leaderboard than a player using `game_mode: "classic"` with `wordlist_source: "classic.txt"`.
81
+
82
+ ---
83
+
84
+ ## 3. System Architecture
85
+
86
+ ### 3.1 Storage Structure
87
+
88
+ Each date/week can have multiple leaderboard folders, one per unique settings combination. The folder name includes the file_id suffix, and all leaderboards use `settings.json` as the filename (consistent with challenges):
89
+
90
+ ```
91
+ HF_REPO_ID/
92
+ ??? games/ # Existing challenge storage
93
+ ? ??? {challenge_id}/
94
+ ? ??? settings.json # entry_type: "challenge"
95
+ ??? leaderboards/
96
+ ? ??? daily/
97
+ ? ? ??? 2025-01-27-0/ # First settings combination for 2025-01-27
98
+ ? ? ? ??? settings.json
99
+ ? ? ??? 2025-01-27-1/ # Second settings combination for 2025-01-27
100
+ ? ? ? ??? settings.json
101
+ ? ? ??? 2025-01-26-0/
102
+ ? ? ??? settings.json
103
+ ? ??? weekly/
104
+ ? ? ??? 2025-W04-0/ # First settings combination for week 4
105
+ ? ? ? ??? settings.json
106
+ ? ? ??? 2025-W04-1/
107
+ ? ? ? ??? settings.json
108
+ ? ? ??? 2025-W03-0/
109
+ ? ? ??? settings.json
110
+ ? ??? index.json # Index of all leaderboards (maps settings to folder IDs)
111
+ ??? shortener.json # Existing URL shortener
112
+ ```
113
+
114
+ ### 3.2 Leaderboard Index File
115
+
116
+ The `index.json` file maps settings combinations to folder identifiers for fast lookup:
117
+
118
+ ```json
119
+ {
120
+ "daily": {
121
+ "2025-01-27": [
122
+ {
123
+ "file_id": 0,
124
+ "game_mode": "classic",
125
+ "wordlist_source": "classic.txt",
126
+ "show_incorrect_guesses": true,
127
+ "enable_free_letters": true,
128
+ "puzzle_options": {"spacer": 0, "may_overlap": false}
129
+ },
130
+ {
131
+ "file_id": 1,
132
+ "game_mode": "easy",
133
+ "wordlist_source": "easy.txt",
134
+ "show_incorrect_guesses": true,
135
+ "enable_free_letters": true,
136
+ "puzzle_options": {"spacer": 0, "may_overlap": false}
137
+ }
138
+ ]
139
+ },
140
+ "weekly": {
141
+ "2025-W04": [
142
+ {
143
+ "file_id": 0,
144
+ "game_mode": "classic",
145
+ "wordlist_source": "classic.txt",
146
+ "show_incorrect_guesses": true,
147
+ "enable_free_letters": true,
148
+ "puzzle_options": {"spacer": 0, "may_overlap": false}
149
+ }
150
+ ]
151
+ }
152
+ }
153
+ ```
154
+
155
+ ### 3.3 Data Flow
156
+
157
+ ```
158
+ ??????????????????????
159
+ ? Game Completion ?
160
+ ??????????????????????
161
+ ?
162
+ ?
163
+ ??????????????????????
164
+ ? Get current game ?
165
+ ? settings ?
166
+ ??????????????????????
167
+ ?
168
+ ?
169
+ ??????????????????????
170
+ ? Find matching ?
171
+ ? leaderboard or ?
172
+ ? create new one ?
173
+ ??????????????????????
174
+ ?
175
+ ?????????????
176
+ ? ?
177
+ ? ?
178
+ ????????? ???????????
179
+ ? Daily ? ? Weekly ?
180
+ ? LB ? ? LB ?
181
+ ????????? ???????????
182
+ ? ?
183
+ ????????????
184
+ ?
185
+ ?
186
+ ?????????????????????
187
+ ? Check if score ?
188
+ ? qualifies (top ?
189
+ ? 20 displayed) ?
190
+ ?????????????????????
191
+ ?
192
+ ?
193
+ ?????????????????????
194
+ ? Update & Upload ?
195
+ ? to HF repo ?
196
+ ?????????????????????
197
+ ```
198
+
199
+ ---
200
+
201
+ ## 4. Data Models
202
+
203
+ ### 4.1 Entry Type Definition
204
+
205
+ The `entry_type` field distinguishes between different types of game entries:
206
+
207
+ | entry_type | Description | Storage Location |
208
+ |------------|-------------|------------------|
209
+ | `"challenge"` | Player-created challenge for others to compete | `games/{challenge_id}/settings.json` |
210
+ | `"daily"` | Daily leaderboard entry | `leaderboards/daily/{date}-{file_id}/settings.json` |
211
+ | `"weekly"` | Weekly leaderboard entry | `leaderboards/weekly/{week}-{file_id}/settings.json` |
212
+
213
+ ### 4.2 Unified File Schema (Consistent with Challenge settings.json)
214
+
215
+ Both leaderboard files and challenge files use the **same base structure**. The settings in the file define what makes this leaderboard unique:
216
+
217
+ ```json
218
+ {
219
+ "challenge_id": "2025-01-27-0",
220
+ "entry_type": "daily",
221
+ "game_mode": "classic",
222
+ "grid_size": 8,
223
+ "puzzle_options": {
224
+ "spacer": 0,
225
+ "may_overlap": false
226
+ },
227
+ "users": [
228
+ {
229
+ "uid": "20251130T190249Z-0XLG5O",
230
+ "username": "Charles",
231
+ "word_list": ["CHEER", "PLENTY", "REAL", "ARRIVE", "DEAF", "DAILY"],
232
+ "word_list_difficulty": 117.48,
233
+ "score": 39,
234
+ "time": 132,
235
+ "timestamp": "2025-11-30T19:02:49.544933+00:00",
236
+ "source_challenge_id": null
237
+ }
238
+ ],
239
+ "created_at": "2025-11-30T19:02:49.544933+00:00",
240
+ "version": "0.2.0",
241
+ "show_incorrect_guesses": true,
242
+ "enable_free_letters": true,
243
+ "wordlist_source": "classic.txt",
244
+ "game_title": "Wrdler Gradio AI",
245
+ "max_display_entries": 20
246
+ }
247
+ ```
248
+
249
+ ### 4.3 Field Descriptions
250
+
251
+ | Field | Type | Description |
252
+ |-------|------|-------------|
253
+ | `challenge_id` | string | Unique identifier. For daily: `"2025-01-27-0"`, weekly: `"2025-W04-0"`, challenge: `"20251130T190249Z-ABCDEF"` |
254
+ | `entry_type` | string | One of: `"daily"`, `"weekly"`, `"challenge"` |
255
+ | `game_mode` | string | Game difficulty: `"classic"`, `"easy"`, `"too easy"` |
256
+ | `grid_size` | int | Grid width (8 for Wrdler) |
257
+ | `puzzle_options` | object | Puzzle configuration (defines leaderboard uniqueness) |
258
+ | `users` | array | Array of user entries (sorted by score desc, time asc, difficulty desc) |
259
+ | `created_at` | string | ISO 8601 timestamp when entry was created |
260
+ | `version` | string | Schema version |
261
+ | `show_incorrect_guesses` | bool | Display setting (defines leaderboard uniqueness) |
262
+ | `enable_free_letters` | bool | Free letters feature toggle (defines leaderboard uniqueness) |
263
+ | `wordlist_source` | string | Source wordlist file (defines leaderboard uniqueness) |
264
+ | `game_title` | string | Game title for display |
265
+ | `max_display_entries` | int | Maximum entries to display (default 20, can store more) |
266
+
267
+ ### 4.4 User Entry Schema
268
+
269
+ Each user entry in the `users` array:
270
+
271
+ ```json
272
+ {
273
+ "uid": "20251130T190249Z-0XLG5O",
274
+ "username": "Charles",
275
+ "word_list": ["CHEER", "PLENTY", "REAL", "ARRIVE", "DEAF", "DAILY"],
276
+ "word_list_difficulty": 117.48,
277
+ "score": 39,
278
+ "time": 132,
279
+ "timestamp": "2025-11-30T19:02:49.544933+00:00",
280
+ "source_challenge_id": null
281
+ }
282
+ ```
283
+
284
+ | Field | Type | Description |
285
+ |-------|------|-------------|
286
+ | `uid` | string | Unique user entry ID |
287
+ | `username` | string | Player display name |
288
+ | `word_list` | array | 6 words played |
289
+ | `word_list_difficulty` | float | Calculated difficulty score |
290
+ | `score` | int | Final score |
291
+ | `time` | int | Time in seconds |
292
+ | `timestamp` | string | ISO 8601 when entry was recorded |
293
+ | `source_challenge_id` | string\|null | If from a challenge, the original challenge_id |
294
+
295
+ ### 4.5 Settings Matching
296
+
297
+ Two leaderboards are considered the same if ALL of the following match:
298
+ - `game_mode`
299
+ - `wordlist_source`
300
+ - `show_incorrect_guesses`
301
+ - `enable_free_letters`
302
+ - `puzzle_options.spacer`
303
+ - `puzzle_options.may_overlap`
304
+
305
+ ### 4.6 Weekly Leaderboard Naming
306
+
307
+ Uses ISO 8601 week numbering:
308
+ - Format: `YYYY-Www` (e.g., `2025-W04`)
309
+ - Week starts on Monday
310
+ - Week 1 is the week containing the first Thursday of the year
311
+
312
+ ---
313
+
314
+ ## 5. New Python Modules
315
+
316
+ ### 5.1 `wrdler/leaderboard.py` (NEW FILE)
317
+
318
+ **Purpose:** Core leaderboard logic for managing daily and weekly leaderboards with settings-based separation.
319
+
320
+ ```python
321
+ # wrdler/leaderboard.py
322
+ """
323
+ Wrdler Leaderboard System
324
+
325
+ Manages daily and weekly leaderboards with automatic score submission,
326
+ qualification checking, and historical lookup.
327
+
328
+ Leaderboard Configuration:
329
+ - Max display entries: 20 per leaderboard (can store more)
330
+ - Daily reset: UTC midnight
331
+ - Weekly reset: Monday UTC 00:00 (ISO week)
332
+ - Sorting: score (desc), time (asc), difficulty (desc)
333
+ - File format: Unified with challenge settings.json
334
+ - Settings-based separation: Each unique settings combination gets its own leaderboard
335
+ """
336
+ __version__ = "0.2.0"
337
+
338
+ from dataclasses import dataclass, field
339
+ from datetime import datetime, timezone, timedelta
340
+ from typing import Dict, Any, List, Optional, Tuple, Literal
341
+ import logging
342
+
343
+ from wrdler.modules.storage import (
344
+ _get_json_from_repo,
345
+ _upload_json_to_repo
346
+ )
347
+ from wrdler.modules.constants import HF_REPO_ID, APP_SETTINGS
348
+ from wrdler.game_storage import generate_uid
349
+
350
+ logger = logging.getLogger(__name__)
351
+
352
+ # Configuration
353
+ MAX_DISPLAY_ENTRIES = 20
354
+ DAILY_LEADERBOARD_PATH = "leaderboards/daily"
355
+ WEEKLY_LEADERBOARD_PATH = "leaderboards/weekly"
356
+ LEADERBOARD_INDEX_PATH = "leaderboards/index.json"
357
+
358
+ # Entry types
359
+ EntryType = Literal["daily", "weekly", "challenge"]
360
+
361
+
362
+ @dataclass
363
+ class GameSettings:
364
+ """Settings that define a unique leaderboard."""
365
+ game_mode: str = "classic"
366
+ wordlist_source: str = "classic.txt"
367
+ show_incorrect_guesses: bool = True
368
+ enable_free_letters: bool = True
369
+ puzzle_options: Dict[str, Any] = field(default_factory=lambda: {"spacer": 0, "may_overlap": False})
370
+
371
+ def matches(self, other: "GameSettings") -> bool:
372
+ """Check if two settings are equivalent (same leaderboard)."""
373
+ return (
374
+ self.game_mode == other.game_mode and
375
+ self.wordlist_source == other.wordlist_source and
376
+ self.show_incorrect_guesses == other.show_incorrect_guesses and
377
+ self.enable_free_letters == other.enable_free_letters and
378
+ self.puzzle_options.get("spacer", 0) == other.puzzle_options.get("spacer", 0) and
379
+ self.puzzle_options.get("may_overlap", False) == other.puzzle_options.get("may_overlap", False)
380
+ )
381
+
382
+ def to_dict(self) -> Dict[str, Any]:
383
+ """Convert to dictionary."""
384
+ return {
385
+ "game_mode": self.game_mode,
386
+ "wordlist_source": self.wordlist_source,
387
+ "show_incorrect_guesses": self.show_incorrect_guesses,
388
+ "enable_free_letters": self.enable_free_letters,
389
+ "puzzle_options": self.puzzle_options,
390
+ }
391
+
392
+ @classmethod
393
+ def from_dict(cls, data: Dict[str, Any]) -> "GameSettings":
394
+ """Create from dictionary."""
395
+ return cls(
396
+ game_mode=data.get("game_mode", "classic"),
397
+ wordlist_source=data.get("wordlist_source", "classic.txt"),
398
+ show_incorrect_guesses=data.get("show_incorrect_guesses", True),
399
+ enable_free_letters=data.get("enable_free_letters", True),
400
+ puzzle_options=data.get("puzzle_options", {"spacer": 0, "may_overlap": False}),
401
+ )
402
+
403
+ @classmethod
404
+ def from_leaderboard(cls, leaderboard: "LeaderboardSettings") -> "GameSettings":
405
+ """Extract settings from a leaderboard."""
406
+ return cls(
407
+ game_mode=leaderboard.game_mode,
408
+ wordlist_source=leaderboard.wordlist_source,
409
+ show_incorrect_guesses=leaderboard.show_incorrect_guesses,
410
+ enable_free_letters=leaderboard.enable_free_letters,
411
+ puzzle_options=leaderboard.puzzle_options,
412
+ )
413
+
414
+
415
+ @dataclass
416
+ class UserEntry:
417
+ """Single user entry in a leaderboard (matches challenge user format)."""
418
+ uid: str
419
+ username: str
420
+ word_list: List[str]
421
+ score: int
422
+ time: int # seconds (matches existing 'time' field, not 'time_seconds')
423
+ timestamp: str
424
+ word_list_difficulty: Optional[float] = None
425
+ source_challenge_id: Optional[str] = None # If entry came from a challenge
426
+
427
+ def to_dict(self) -> Dict[str, Any]:
428
+ """Convert to dictionary for JSON serialization."""
429
+ result = {
430
+ "uid": self.uid,
431
+ "username": self.username,
432
+ "word_list": self.word_list,
433
+ "score": self.score,
434
+ "time": self.time,
435
+ "timestamp": self.timestamp,
436
+ }
437
+ if self.word_list_difficulty is not None:
438
+ result["word_list_difficulty"] = self.word_list_difficulty
439
+ if self.source_challenge_id is not None:
440
+ result["source_challenge_id"] = self.source_challenge_id
441
+ return result
442
+
443
+ @classmethod
444
+ def from_dict(cls, data: Dict[str, Any]) -> "UserEntry":
445
+ """Create from dictionary."""
446
+ return cls(
447
+ uid=data["uid"],
448
+ username=data["username"],
449
+ word_list=data["word_list"],
450
+ score=data["score"],
451
+ time=data.get("time", data.get("time_seconds", 0)), # Handle both field names
452
+ timestamp=data["timestamp"],
453
+ word_list_difficulty=data.get("word_list_difficulty"),
454
+ source_challenge_id=data.get("source_challenge_id"),
455
+ )
456
+
457
+
458
+ @dataclass
459
+ class LeaderboardSettings:
460
+ """
461
+ Unified leaderboard/challenge settings format.
462
+
463
+ This matches the existing challenge settings.json structure with added
464
+ entry_type field to distinguish between daily, weekly, and challenge entries.
465
+ The settings fields define what makes this leaderboard unique.
466
+ """
467
+ challenge_id: str # Date-fileId for daily, week-fileId for weekly, UID for challenge
468
+ entry_type: EntryType # "daily", "weekly", or "challenge"
469
+ game_mode: str = "classic"
470
+ grid_size: int = 8
471
+ puzzle_options: Dict[str, Any] = field(default_factory=lambda: {"spacer": 0, "may_overlap": False})
472
+ users: List[UserEntry] = field(default_factory=list)
473
+ created_at: str = ""
474
+ version: str = __version__
475
+ show_incorrect_guesses: bool = True
476
+ enable_free_letters: bool = True
477
+ wordlist_source: str = "classic.txt"
478
+ game_title: str = "Wrdler"
479
+ max_display_entries: int = MAX_DISPLAY_ENTRIES
480
+
481
+ def __post_init__(self):
482
+ if not self.created_at:
483
+ self.created_at = datetime.now(timezone.utc).isoformat()
484
+ if not self.game_title:
485
+ self.game_title = APP_SETTINGS.get("game_title", "Wrdler")
486
+
487
+ def to_dict(self) -> Dict[str, Any]:
488
+ """Convert to dictionary for JSON serialization."""
489
+ return {
490
+ "challenge_id": self.challenge_id,
491
+ "entry_type": self.entry_type,
492
+ "game_mode": self.game_mode,
493
+ "grid_size": self.grid_size,
494
+ "puzzle_options": self.puzzle_options,
495
+ "users": [u.to_dict() for u in self.users],
496
+ "created_at": self.created_at,
497
+ "version": self.version,
498
+ "show_incorrect_guesses": self.show_incorrect_guesses,
499
+ "enable_free_letters": self.enable_free_letters,
500
+ "wordlist_source": self.wordlist_source,
501
+ "game_title": self.game_title,
502
+ "max_display_entries": self.max_display_entries,
503
+ }
504
+
505
+ @classmethod
506
+ def from_dict(cls, data: Dict[str, Any]) -> "LeaderboardSettings":
507
+ """Create from dictionary."""
508
+ users = [UserEntry.from_dict(u) for u in data.get("users", [])]
509
+ return cls(
510
+ challenge_id=data["challenge_id"],
511
+ entry_type=data.get("entry_type", "challenge"), # Default for legacy
512
+ game_mode=data.get("game_mode", "classic"),
513
+ grid_size=data.get("grid_size", 8),
514
+ puzzle_options=data.get("puzzle_options", {"spacer": 0, "may_overlap": False}),
515
+ users=users,
516
+ created_at=data.get("created_at", ""),
517
+ version=data.get("version", "0.1.0"),
518
+ show_incorrect_guesses=data.get("show_incorrect_guesses", True),
519
+ enable_free_letters=data.get("enable_free_letters", True),
520
+ wordlist_source=data.get("wordlist_source", "classic.txt"),
521
+ game_title=data.get("game_title", "Wrdler"),
522
+ max_display_entries=data.get("max_display_entries", MAX_DISPLAY_ENTRIES),
523
+ )
524
+
525
+ def get_display_users(self) -> List[UserEntry]:
526
+ """Get users limited to max_display_entries."""
527
+ return self.users[:self.max_display_entries]
528
+
529
+ def get_settings(self) -> GameSettings:
530
+ """Extract game settings from this leaderboard."""
531
+ return GameSettings.from_leaderboard(self)
532
+
533
+
534
+ def get_current_daily_id() -> str:
535
+ """Get the date portion of the leaderboard ID for today (UTC)."""
536
+ return datetime.now(timezone.utc).strftime("%Y-%m-%d")
537
+
538
+
539
+ def get_current_weekly_id() -> str:
540
+ """Get the week portion of the leaderboard ID for the current ISO week."""
541
+ now = datetime.now(timezone.utc)
542
+ iso_cal = now.isocalendar()
543
+ return f"{iso_cal.year}-W{iso_cal.week:02d}"
544
+
545
+
546
+ def get_daily_leaderboard_path(date_id: str, file_id: int) -> str:
547
+ """Get the file path for a daily leaderboard."""
548
+ return f"{DAILY_LEADERBOARD_PATH}/{date_id}-{file_id}/settings.json"
549
+
550
+
551
+ def get_weekly_leaderboard_path(week_id: str, file_id: int) -> str:
552
+ """Get the file path for a weekly leaderboard."""
553
+ return f"{WEEKLY_LEADERBOARD_PATH}/{week_id}-{file_id}/settings.json"
554
+
555
+
556
+ def _sort_users(users: List[UserEntry]) -> List[UserEntry]:
557
+ """Sort users by score (desc), time (asc), difficulty (desc)."""
558
+ return sorted(
559
+ users,
560
+ key=lambda u: (
561
+ -u.score,
562
+ u.time,
563
+ -(u.word_list_difficulty or 0)
564
+ )
565
+ )
566
+
567
+
568
+ def _load_index(repo_id: Optional[str] = None) -> Dict[str, Any]:
569
+ """Load the leaderboard index."""
570
+ if repo_id is None:
571
+ repo_id = HF_REPO_ID
572
+
573
+ data = _get_json_from_repo(repo_id, LEADERBOARD_INDEX_PATH, "dataset")
574
+ if not data:
575
+ return {"daily": {}, "weekly": {}}
576
+ return data
577
+
578
+
579
+ def _save_index(index: Dict[str, Any], repo_id: Optional[str] = None) -> bool:
580
+ """Save the leaderboard index."""
581
+ if repo_id is None:
582
+ repo_id = HF_REPO_ID
583
+
584
+ return _upload_json_to_repo(index, repo_id, LEADERBOARD_INDEX_PATH, "dataset")
585
+
586
+
587
+ def find_matching_leaderboard(
588
+ entry_type: EntryType,
589
+ period_id: str,
590
+ settings: GameSettings,
591
+ repo_id: Optional[str] = None
592
+ ) -> Tuple[Optional[int], Optional[LeaderboardSettings]]:
593
+ """
594
+ Find a leaderboard matching the given settings for a period.
595
+
596
+ Args:
597
+ entry_type: "daily" or "weekly"
598
+ period_id: Date string or week identifier
599
+ settings: Game settings to match
600
+ repo_id: Repository ID
601
+
602
+ Returns:
603
+ Tuple of (file_id, leaderboard) or (None, None) if not found
604
+ """
605
+ if repo_id is None:
606
+ repo_id = HF_REPO_ID
607
+
608
+ index = _load_index(repo_id)
609
+ period_entries = index.get(entry_type, {}).get(period_id, [])
610
+
611
+ for entry in period_entries:
612
+ entry_settings = GameSettings.from_dict(entry)
613
+ if settings.matches(entry_settings):
614
+ file_id = entry["file_id"]
615
+ # Load the actual leaderboard
616
+ if entry_type == "daily":
617
+ path = get_daily_leaderboard_path(period_id, file_id)
618
+ else:
619
+ path = get_weekly_leaderboard_path(period_id, file_id)
620
+
621
+ data = _get_json_from_repo(repo_id, path, "dataset")
622
+ if data:
623
+ return file_id, LeaderboardSettings.from_dict(data)
624
+
625
+ return None, None
626
+
627
+
628
+ def create_or_get_leaderboard(
629
+ entry_type: EntryType,
630
+ period_id: str,
631
+ settings: GameSettings,
632
+ repo_id: Optional[str] = None
633
+ ) -> Tuple[int, LeaderboardSettings]:
634
+ """
635
+ Get existing leaderboard or create a new one for the settings.
636
+
637
+ Args:
638
+ entry_type: "daily" or "weekly"
639
+ period_id: Date string or week identifier
640
+ settings: Game settings
641
+ repo_id: Repository ID
642
+
643
+ Returns:
644
+ Tuple of (file_id, leaderboard)
645
+ """
646
+ if repo_id is None:
647
+ repo_id = HF_REPO_ID
648
+
649
+ # Try to find existing
650
+ file_id, leaderboard = find_matching_leaderboard(entry_type, period_id, settings, repo_id)
651
+
652
+ if leaderboard is not None:
653
+ return file_id, leaderboard
654
+
655
+ # Create new leaderboard
656
+ index = _load_index(repo_id)
657
+ if entry_type not in index:
658
+ index[entry_type] = {}
659
+ if period_id not in index[entry_type]:
660
+ index[entry_type][period_id] = []
661
+
662
+ # Get next file_id
663
+ existing_ids = [e["file_id"] for e in index[entry_type][period_id]]
664
+ file_id = max(existing_ids, default=-1) + 1
665
+
666
+ # Create challenge_id
667
+ challenge_id = f"{period_id}-{file_id}"
668
+
669
+ # Create new leaderboard
670
+ leaderboard = LeaderboardSettings(
671
+ challenge_id=challenge_id,
672
+ entry_type=entry_type,
673
+ game_mode=settings.game_mode,
674
+ wordlist_source=settings.wordlist_source,
675
+ show_incorrect_guesses=settings.show_incorrect_guesses,
676
+ enable_free_letters=settings.enable_free_letters,
677
+ puzzle_options=settings.puzzle_options,
678
+ users=[]
679
+ )
680
+
681
+ # Update index
682
+ index_entry = settings.to_dict()
683
+ index_entry["file_id"] = file_id
684
+ index[entry_type][period_id].append(index_entry)
685
+ _save_index(index, repo_id)
686
+
687
+ return file_id, leaderboard
688
+
689
+
690
+ def load_leaderboard(
691
+ entry_type: EntryType,
692
+ period_id: str,
693
+ file_id: int,
694
+ repo_id: Optional[str] = None
695
+ ) -> Optional[LeaderboardSettings]:
696
+ """
697
+ Load a specific leaderboard by file ID.
698
+
699
+ Args:
700
+ entry_type: "daily" or "weekly"
701
+ period_id: Date or week identifier
702
+ file_id: File identifier
703
+ repo_id: Repository ID (uses HF_REPO_ID if None)
704
+
705
+ Returns:
706
+ LeaderboardSettings object or None if not found
707
+ """
708
+ if repo_id is None:
709
+ repo_id = HF_REPO_ID
710
+
711
+ if entry_type == "daily":
712
+ path = get_daily_leaderboard_path(period_id, file_id)
713
+ elif entry_type == "weekly":
714
+ path = get_weekly_leaderboard_path(period_id, file_id)
715
+ else:
716
+ logger.error(f"Invalid entry_type for leaderboard: {entry_type}")
717
+ return None
718
+
719
+ logger.info(f"?? Loading leaderboard: {path}")
720
+ data = _get_json_from_repo(repo_id, path, "dataset")
721
+
722
+ if not data:
723
+ logger.info(f"?? No existing leaderboard found at {path}")
724
+ return None
725
+
726
+ return LeaderboardSettings.from_dict(data)
727
+
728
+
729
+ def save_leaderboard(
730
+ leaderboard: LeaderboardSettings,
731
+ file_id: int,
732
+ repo_id: Optional[str] = None
733
+ ) -> bool:
734
+ """
735
+ Save a leaderboard to the repository.
736
+
737
+ Args:
738
+ leaderboard: LeaderboardSettings object to save
739
+ file_id: File identifier
740
+ repo_id: Repository ID (uses HF_REPO_ID if None)
741
+
742
+ Returns:
743
+ True if saved successfully, False otherwise
744
+ """
745
+ if repo_id is None:
746
+ repo_id = HF_REPO_ID
747
+
748
+ # Extract period_id from challenge_id (format: "2025-01-27-0" or "2025-W04-0")
749
+ parts = leaderboard.challenge_id.rsplit("-", 1)
750
+ period_id = parts[0] if len(parts) > 1 else leaderboard.challenge_id
751
+
752
+ if leaderboard.entry_type == "daily":
753
+ path = get_daily_leaderboard_path(period_id, file_id)
754
+ elif leaderboard.entry_type == "weekly":
755
+ path = get_weekly_leaderboard_path(period_id, file_id)
756
+ else:
757
+ logger.error(f"Cannot save leaderboard with entry_type: {leaderboard.entry_type}")
758
+ return False
759
+
760
+ logger.info(f"?? Saving leaderboard: {path}")
761
+ return _upload_json_to_repo(leaderboard.to_dict(), repo_id, path, "dataset")
762
+
763
+
764
+ def create_user_entry(
765
+ username: str,
766
+ score: int,
767
+ time_seconds: int,
768
+ word_list: List[str],
769
+ word_list_difficulty: Optional[float] = None,
770
+ source_challenge_id: Optional[str] = None
771
+ ) -> UserEntry:
772
+ """Create a new user entry."""
773
+ return UserEntry(
774
+ uid=generate_uid(),
775
+ username=username,
776
+ word_list=word_list,
777
+ score=score,
778
+ time=time_seconds,
779
+ word_list_difficulty=word_list_difficulty,
780
+ source_challenge_id=source_challenge_id,
781
+ timestamp=datetime.now(timezone.utc).isoformat()
782
+ )
783
+
784
+
785
+ def check_qualification(
786
+ leaderboard: Optional[LeaderboardSettings],
787
+ score: int,
788
+ time_seconds: int,
789
+ word_list_difficulty: Optional[float] = None
790
+ ) -> bool:
791
+ """
792
+ Check if a score qualifies for the leaderboard display (top 20).
793
+
794
+ Note: The leaderboard can store more than 20 entries, but only top 20 are displayed.
795
+ This function checks if the score would be in the top 20.
796
+
797
+ Args:
798
+ leaderboard: Existing leaderboard (or None if new)
799
+ score: Score to check
800
+ time_seconds: Time to complete
801
+ word_list_difficulty: Difficulty score
802
+
803
+ Returns:
804
+ True if qualifies for display, False otherwise
805
+ """
806
+ if leaderboard is None or len(leaderboard.users) < MAX_DISPLAY_ENTRIES:
807
+ return True
808
+
809
+ # Get the 20th entry (last displayed)
810
+ display_users = leaderboard.get_display_users()
811
+ if len(display_users) < MAX_DISPLAY_ENTRIES:
812
+ return True
813
+
814
+ lowest = display_users[-1]
815
+
816
+ # Primary: higher score qualifies
817
+ if score > lowest.score:
818
+ return True
819
+ if score < lowest.score:
820
+ return False
821
+
822
+ # Secondary: faster time qualifies (for equal score)
823
+ if time_seconds < lowest.time:
824
+ return True
825
+ if time_seconds > lowest.time:
826
+ return False
827
+
828
+ # Tertiary: higher difficulty qualifies (for equal score and time)
829
+ entry_diff = word_list_difficulty or 0
830
+ lowest_diff = lowest.word_list_difficulty or 0
831
+ return entry_diff > lowest_diff
832
+
833
+
834
+ def submit_to_leaderboard(
835
+ entry_type: EntryType,
836
+ period_id: str,
837
+ user_entry: UserEntry,
838
+ settings: GameSettings,
839
+ repo_id: Optional[str] = None
840
+ ) -> Tuple[bool, Optional[int]]:
841
+ """
842
+ Submit a user entry to a leaderboard if it qualifies.
843
+
844
+ Args:
845
+ entry_type: "daily" or "weekly"
846
+ period_id: Date or week identifier
847
+ user_entry: UserEntry to submit
848
+ settings: Game settings to match leaderboard
849
+ repo_id: Repository ID
850
+
851
+ Returns:
852
+ Tuple of (success, rank) where rank is 1-indexed position or None if didn't qualify
853
+ """
854
+ if repo_id is None:
855
+ repo_id = HF_REPO_ID
856
+
857
+ # Get or create matching leaderboard
858
+ file_id, leaderboard = create_or_get_leaderboard(entry_type, period_id, settings, repo_id)
859
+
860
+ # Check qualification for display
861
+ qualifies = check_qualification(
862
+ leaderboard,
863
+ user_entry.score,
864
+ user_entry.time,
865
+ user_entry.word_list_difficulty
866
+ )
867
+
868
+ if not qualifies:
869
+ logger.info(f"? Score {user_entry.score} did not qualify for top {MAX_DISPLAY_ENTRIES} in {period_id}")
870
+ return False, None
871
+
872
+ # Add entry and sort
873
+ leaderboard.users.append(user_entry)
874
+ leaderboard.users = _sort_users(leaderboard.users)
875
+
876
+ # Find rank (1-indexed) - check if in display range
877
+ rank = None
878
+ for i, u in enumerate(leaderboard.users[:MAX_DISPLAY_ENTRIES]):
879
+ if u.uid == user_entry.uid:
880
+ rank = i + 1
881
+ break
882
+
883
+ if rank is None:
884
+ # Entry was sorted out of top 20
885
+ logger.info(f"? Score {user_entry.score} was sorted out of top {MAX_DISPLAY_ENTRIES}")
886
+ # Still save the entry (stored but not displayed)
887
+ save_leaderboard(leaderboard, file_id, repo_id)
888
+ return False, None
889
+
890
+ # Save leaderboard
891
+ if save_leaderboard(leaderboard, file_id, repo_id):
892
+ logger.info(f"? Added to {entry_type} leaderboard at rank {rank}")
893
+ return True, rank
894
+ else:
895
+ logger.error(f"? Failed to save leaderboard {period_id}")
896
+ return False, None
897
+
898
+
899
+ def submit_score_to_all_leaderboards(
900
+ username: str,
901
+ score: int,
902
+ time_seconds: int,
903
+ word_list: List[str],
904
+ settings: GameSettings,
905
+ word_list_difficulty: Optional[float] = None,
906
+ source_challenge_id: Optional[str] = None,
907
+ repo_id: Optional[str] = None
908
+ ) -> Dict[str, Any]:
909
+ """
910
+ Submit a score to both daily and weekly leaderboards matching the settings.
911
+
912
+ This is the main entry point for game completions.
913
+
914
+ Args:
915
+ username: Player name
916
+ score: Final score
917
+ time_seconds: Time to complete
918
+ word_list: Words played
919
+ settings: Game settings (determines which leaderboard)
920
+ word_list_difficulty: Difficulty score
921
+ source_challenge_id: If from a challenge, the original challenge_id
922
+ repo_id: Repository ID
923
+
924
+ Returns:
925
+ Dict with results:
926
+ {
927
+ "daily": {"qualified": bool, "rank": int|None, "id": str},
928
+ "weekly": {"qualified": bool, "rank": int|None, "id": str},
929
+ "entry_uid": str,
930
+ "settings": {...}
931
+ }
932
+ """
933
+ logger.info(f"?? Submitting score: {score} by {username} with settings: {settings.game_mode}")
934
+
935
+ # Get current period IDs
936
+ daily_id = get_current_daily_id()
937
+ weekly_id = get_current_weekly_id()
938
+
939
+ # Create user entry for daily
940
+ daily_entry = create_user_entry(
941
+ username=username,
942
+ score=score,
943
+ time_seconds=time_seconds,
944
+ word_list=word_list,
945
+ word_list_difficulty=word_list_difficulty,
946
+ source_challenge_id=source_challenge_id
947
+ )
948
+
949
+ # Submit to daily
950
+ daily_qualified, daily_rank = submit_to_leaderboard(
951
+ "daily", daily_id, daily_entry, settings, repo_id
952
+ )
953
+
954
+ # Create separate user entry for weekly (different UID)
955
+ weekly_entry = create_user_entry(
956
+ username=username,
957
+ score=score,
958
+ time_seconds=time_seconds,
959
+ word_list=word_list,
960
+ word_list_difficulty=word_list_difficulty,
961
+ source_challenge_id=source_challenge_id
962
+ )
963
+
964
+ # Submit to weekly
965
+ weekly_qualified, weekly_rank = submit_to_leaderboard(
966
+ "weekly", weekly_id, weekly_entry, settings, repo_id
967
+ )
968
+
969
+ results = {
970
+ "daily": {"qualified": daily_qualified, "rank": daily_rank, "id": daily_id},
971
+ "weekly": {"qualified": weekly_qualified, "rank": weekly_rank, "id": weekly_id},
972
+ "entry_uid": daily_entry.uid,
973
+ "settings": settings.to_dict()
974
+ }
975
+
976
+ logger.info(f"?? Leaderboard results: {results}")
977
+ return results
978
+
979
+
980
+ def get_leaderboards_for_settings(
981
+ entry_type: EntryType,
982
+ period_id: str,
983
+ settings: GameSettings,
984
+ repo_id: Optional[str] = None
985
+ ) -> Optional[LeaderboardSettings]:
986
+ """
987
+ Get leaderboard matching specific settings for a period.
988
+
989
+ Args:
990
+ entry_type: "daily" or "weekly"
991
+ period_id: Date or week identifier
992
+ settings: Game settings to match
993
+ repo_id: Repository ID
994
+
995
+ Returns:
996
+ LeaderboardSettings or None if not found
997
+ """
998
+ _, leaderboard = find_matching_leaderboard(entry_type, period_id, settings, repo_id)
999
+ return leaderboard
1000
+
1001
+
1002
+ def get_last_n_daily_leaderboards(
1003
+ n: int = 7,
1004
+ settings: Optional[GameSettings] = None,
1005
+ repo_id: Optional[str] = None
1006
+ ) -> List[Tuple[str, Optional[LeaderboardSettings]]]:
1007
+ """
1008
+ Get the last N days of daily leaderboards for specific settings.
1009
+
1010
+ Args:
1011
+ n: Number of days to retrieve
1012
+ settings: Game settings to filter by (None = default settings)
1013
+ repo_id: Repository ID
1014
+
1015
+ Returns:
1016
+ List of tuples (date_id, leaderboard) in reverse chronological order
1017
+ """
1018
+ if settings is None:
1019
+ settings = GameSettings()
1020
+
1021
+ results = []
1022
+ today = datetime.now(timezone.utc).date()
1023
+
1024
+ for i in range(n):
1025
+ date = today - timedelta(days=i)
1026
+ date_id = date.strftime("%Y-%m-%d")
1027
+ leaderboard = get_leaderboards_for_settings("daily", date_id, settings, repo_id)
1028
+ results.append((date_id, leaderboard))
1029
+
1030
+ return results
1031
+
1032
+
1033
+ def list_available_periods(
1034
+ entry_type: EntryType,
1035
+ limit: int = 30,
1036
+ repo_id: Optional[str] = None
1037
+ ) -> List[str]:
1038
+ """
1039
+ List available period IDs from the index.
1040
+
1041
+ Args:
1042
+ entry_type: "daily" or "weekly"
1043
+ limit: Maximum number of IDs to return
1044
+ repo_id: Repository ID
1045
+
1046
+ Returns:
1047
+ List of period IDs in reverse chronological order
1048
+ """
1049
+ index = _load_index(repo_id)
1050
+ periods = list(index.get(entry_type, {}).keys())
1051
+ periods.sort(reverse=True)
1052
+ return periods[:limit]
1053
+
1054
+
1055
+ def list_settings_for_period(
1056
+ entry_type: EntryType,
1057
+ period_id: str,
1058
+ repo_id: Optional[str] = None
1059
+ ) -> List[Dict[str, Any]]:
1060
+ """
1061
+ List all settings combinations available for a period.
1062
+
1063
+ Args:
1064
+ entry_type: "daily" or "weekly"
1065
+ period_id: Date or week identifier
1066
+ repo_id: Repository ID
1067
+
1068
+ Returns:
1069
+ List of settings dictionaries with file_id
1070
+ """
1071
+ index = _load_index(repo_id)
1072
+ return index.get(entry_type, {}).get(period_id, [])
1073
+ ```
1074
+
1075
+ ---
1076
+
1077
+ ## 6. Implementation Steps
1078
+
1079
+ ### Phase 1: Core Leaderboard Module (v0.2.0-alpha)
1080
+
1081
+ | Step | Task | Files | Effort |
1082
+ |------|------|-------|--------|
1083
+ | 1.1 | Create `wrdler/leaderboard.py` with `GameSettings` and data models | NEW | 2h |
1084
+ | 1.2 | Implement index management (`_load_index`, `_save_index`) | leaderboard.py | 1h |
1085
+ | 1.3 | Implement `find_matching_leaderboard()` and `create_or_get_leaderboard()` | leaderboard.py | 1.5h |
1086
+ | 1.4 | Implement `check_qualification()` and sorting | leaderboard.py | 1h |
1087
+ | 1.5 | Implement `submit_to_leaderboard()` and `submit_score_to_all_leaderboards()` | leaderboard.py | 1h |
1088
+ | 1.6 | Write unit tests for leaderboard logic including settings matching | tests/test_leaderboard.py | 2h |
1089
+
1090
+ ### Phase 2: UI Integration (v0.2.0-beta)
1091
+
1092
+ | Step | Task | Files | Effort |
1093
+ |------|------|-------|--------|
1094
+ | 2.1 | Create `wrdler/leaderboard_page.py` with settings filtering | NEW | 3h |
1095
+ | 2.2 | Add leaderboard navigation to `ui.py` sidebar | ui.py | 1h |
1096
+ | 2.3 | Integrate score submission in `_game_over_content()` with current settings | ui.py | 2h |
1097
+ | 2.4 | Add leaderboard results display in game over dialog | ui.py | 1h |
1098
+ | 2.5 | Style leaderboard tables to match ocean theme | leaderboard_page.py | 1h |
1099
+
1100
+ ### Phase 3: Challenge Format Migration (v0.2.0-beta)
1101
+
1102
+ | Step | Task | Files | Effort |
1103
+ |------|------|-------|--------|
1104
+ | 3.1 | Add `entry_type` field to existing challenge saves | game_storage.py | 1h |
1105
+ | 3.2 | Update challenge loading to handle `entry_type` | game_storage.py | 0.5h |
1106
+ | 3.3 | Test backward compatibility with old challenges | Manual | 1h |
1107
+
1108
+ ### Phase 4: Testing & Polish (v0.2.0-rc)
1109
+
1110
+ | Step | Task | Files | Effort |
1111
+ |------|------|-------|--------|
1112
+ | 4.1 | Integration testing with HuggingFace | Manual | 2h |
1113
+ | 4.2 | Add caching for leaderboard data | leaderboard.py | 1h |
1114
+ | 4.3 | Add error handling and retry logic | leaderboard.py | 1h |
1115
+ | 4.4 | Update documentation | README.md, specs/ | 1h |
1116
+ | 4.5 | Version bump and release notes | pyproject.toml, __init__.py | 0.5h |
1117
+
1118
+ ---
1119
+
1120
+ ## 7. Version Changes
1121
+
1122
+ ### pyproject.toml
1123
+
1124
+ ```toml
1125
+ [project]
1126
+ name = "wrdler"
1127
+ version = "0.2.0" # Updated from 0.1.0
1128
+ description = "Wrdler vocabulary puzzle game with daily/weekly leaderboards"
1129
+ ```
1130
+
1131
+ ### wrdler/__init__.py
1132
+
1133
+ ```python
1134
+ __version__ = "0.2.0" # Updated from existing version
1135
+ ```
1136
+
1137
+ ### wrdler/game_storage.py
1138
+
1139
+ ```python
1140
+ __version__ = "0.2.0" # Updated from 0.1.5
1141
+ ```
1142
+
1143
+ ### wrdler/leaderboard.py (NEW)
1144
+
1145
+ ```python
1146
+ __version__ = "0.2.0"
1147
+ ```
1148
+
1149
+ ### wrdler/leaderboard_page.py (NEW)
1150
+
1151
+ ```python
1152
+ __version__ = "0.2.0"
1153
+ ```
1154
+
1155
+ ---
1156
+
1157
+ ## 8. File Changes Summary
1158
+
1159
+ ### New Files
1160
+
1161
+ | File | Purpose |
1162
+ |------|---------|
1163
+ | `wrdler/leaderboard.py` | Core leaderboard logic with unified format |
1164
+ | `wrdler/leaderboard_page.py` | Streamlit leaderboard page |
1165
+ | `tests/test_leaderboard.py` | Unit tests for leaderboard |
1166
+ | `specs/leaderboard_spec.md` | This specification |
1167
+
1168
+ ### Modified Files
1169
+
1170
+ | File | Changes |
1171
+ |------|---------|
1172
+ | `pyproject.toml` | Version bump to 0.2.0 |
1173
+ | `wrdler/__init__.py` | Version bump, add leaderboard exports |
1174
+ | `wrdler/game_storage.py` | Version bump, add `entry_type` field, integrate leaderboard submission |
1175
+ | `wrdler/ui.py` | Add leaderboard nav, integrate submission in game over |
1176
+ | `wrdler/modules/__init__.py` | Export new functions if needed |
1177
+
1178
+ ---
1179
+
1180
+ ## 9. API Reference
1181
+
1182
+ ### Public Functions in `wrdler/leaderboard.py`
1183
+
1184
+ ```python
1185
+ def submit_score_to_all_leaderboards(
1186
+ username: str,
1187
+ score: int,
1188
+ time_seconds: int,
1189
+ game_mode: str,
1190
+ wordlist_source: str,
1191
+ word_list: List[str],
1192
+ word_list_difficulty: Optional[float] = None,
1193
+ source_challenge_id: Optional[str] = None,
1194
+ repo_id: Optional[str] = None
1195
+ ) -> Dict[str, Any]:
1196
+ """Main entry point for submitting scores after game completion."""
1197
+
1198
+ def load_leaderboard(
1199
+ entry_type: EntryType,
1200
+ period_id: str,
1201
+ file_id: int,
1202
+ repo_id: Optional[str] = None
1203
+ ) -> Optional[LeaderboardSettings]:
1204
+ """Load a specific leaderboard."""
1205
+
1206
+ def get_last_n_daily_leaderboards(
1207
+ n: int = 7,
1208
+ settings: Optional[GameSettings] = None,
1209
+ repo_id: Optional[str] = None
1210
+ ) -> List[Tuple[str, Optional[LeaderboardSettings]]]:
1211
+ """Get recent daily leaderboards for display."""
1212
+
1213
+ def get_current_daily_id() -> str:
1214
+ """Get today's leaderboard ID (challenge_id)."""
1215
+
1216
+ def get_current_weekly_id() -> str:
1217
+ """Get this week's leaderboard ID (challenge_id)."""
1218
+ ```
1219
+
1220
+ ---
1221
+
1222
+ ## 10. UI Components
1223
+
1224
+ ### 10.1 Sidebar Navigation
1225
+
1226
+ Add to `_render_sidebar()` in `ui.py`:
1227
+
1228
+ ```python
1229
+ st.header("Navigation")
1230
+ if st.button("?? Leaderboards", use_container_width=True):
1231
+ st.session_state["show_leaderboard_page"] = True
1232
+ st.rerun()
1233
+ ```
1234
+
1235
+ ### 10.2 Game Over Integration
1236
+
1237
+ Modify `_game_over_content()` in `ui.py` to:
1238
+
1239
+ 1. Call `submit_score_to_all_leaderboards()` after generating share link
1240
+ 2. Display qualification results:
1241
+
1242
+ ```python
1243
+ # After score submission
1244
+ if results["daily"]["qualified"]:
1245
+ st.success(f"?? You ranked #{results['daily']['rank']} on today's leaderboard!")
1246
+ if results["weekly"]["qualified"]:
1247
+ st.success(f"?? You ranked #{results['weekly']['rank']} on this week's leaderboard!")
1248
+ ```
1249
+
1250
+ ### 10.3 Leaderboard Page Routing
1251
+
1252
+ In `run_app()`:
1253
+
1254
+ ```python
1255
+ # Check if leaderboard page should be shown
1256
+ if st.session_state.get("show_leaderboard_page", False):
1257
+ from wrdler.leaderboard_page import render_leaderboard_page
1258
+ render_leaderboard_page()
1259
+ if st.button("?? Back to Game"):
1260
+ st.session_state["show_leaderboard_page"] = False
1261
+ st.rerun()
1262
+ return # Don't render game UI
1263
+ ```
1264
+
1265
+ ---
1266
+
1267
+ ## 11. Testing Requirements
1268
+
1269
+ ### Unit Tests (`tests/test_leaderboard.py`)
1270
+
1271
+ ```python
1272
+ class TestUserEntry:
1273
+ def test_create_entry(self): ...
1274
+ def test_to_dict_roundtrip(self): ...
1275
+ def test_from_legacy_time_seconds_field(self): ...
1276
+
1277
+ class TestLeaderboardSettings:
1278
+ def test_create_leaderboard(self): ...
1279
+ def test_entry_type_values(self): ...
1280
+ def test_get_display_users_limit(self): ...
1281
+ def test_format_matches_challenge(self): ...
1282
+
1283
+ class TestQualification:
1284
+ def test_qualify_empty_leaderboard(self): ...
1285
+ def test_qualify_not_full(self): ...
1286
+ def test_qualify_by_score(self): ...
1287
+ def test_qualify_by_time_tiebreaker(self): ...
1288
+ def test_qualify_by_difficulty_tiebreaker(self): ...
1289
+ def test_not_qualify_lower_score(self): ...
1290
+
1291
+ class TestDateIds:
1292
+ def test_daily_id_format(self): ...
1293
+ def test_weekly_id_format(self): ...
1294
+
1295
+ class TestUnifiedFormat:
1296
+ def test_leaderboard_matches_challenge_structure(self): ...
1297
+ def test_entry_type_field_present(self): ...
1298
+ def test_challenge_id_as_primary_identifier(self): ...
1299
+ ```
1300
+
1301
+
1302
+ ### Integration Tests
1303
+
1304
+ - Test full flow: game completion ? leaderboard submission ? retrieval
1305
+ - Test with mock HuggingFace repository
1306
+ - Test concurrent submissions (edge case)
1307
+ - Test backward compatibility with legacy challenge files (no entry_type)
1308
+
1309
+ ---
1310
+
1311
+ ## 12. Migration Notes
1312
+
1313
+ ### Backward Compatibility
1314
+
1315
+ - Existing challenges continue to work unchanged (entry_type defaults to "challenge")
1316
+ - No changes to `shortener.json` format
1317
+ - Challenge `settings.json` format is extended (new fields are optional)
1318
+
1319
+ ### Schema Evolution
1320
+
1321
+ | Version | Changes |
1322
+ |---------|---------|
1323
+ | 0.1.x | Original challenge format |
1324
+ | 0.2.0 | Added `entry_type`, `max_display_entries`, `source_challenge_id` fields |
1325
+
1326
+ ### Data Migration
1327
+
1328
+ - No migration required for existing challenges
1329
+ - New leaderboard files use unified format from start
1330
+ - Legacy challenges without `entry_type` default to `"challenge"`
1331
+
1332
+ ### Rollback Plan
1333
+
1334
+ 1. Remove leaderboard imports from `ui.py`
1335
+ 2. Remove sidebar navigation button
1336
+ 3. Remove game over submission calls
1337
+ 4. Optionally: delete `leaderboards/` directory from HF repo
1338
+
1339
+ ---
1340
+
1341
+ ## Appendix A: Example Daily Leaderboard JSON (Settings-Based)
1342
+
1343
+ ```json
1344
+ {
1345
+ "challenge_id": "2025-01-27-0",
1346
+ "entry_type": "daily",
1347
+ "game_mode": "classic",
1348
+ "grid_size": 8,
1349
+ "puzzle_options": {
1350
+ "spacer": 0,
1351
+ "may_overlap": false
1352
+ },
1353
+ "users": [
1354
+ {
1355
+ "uid": "20251130T190249Z-0XLG5O",
1356
+ "username": "Charles",
1357
+ "word_list": ["CHEER", "PLENTY", "REAL", "ARRIVE", "DEAF", "DAILY"],
1358
+ "word_list_difficulty": 117.48,
1359
+ "score": 39,
1360
+ "time": 132,
1361
+ "timestamp": "2025-11-30T19:02:49.544933+00:00",
1362
+ "source_challenge_id": null
1363
+ }
1364
+ ],
1365
+ "created_at": "2025-11-30T19:02:49.544933+00:00",
1366
+ "version": "0.2.0",
1367
+ "show_incorrect_guesses": true,
1368
+ "enable_free_letters": true,
1369
+ "wordlist_source": "classic.txt",
1370
+ "game_title": "Wrdler Gradio AI",
1371
+ "max_display_entries": 20
1372
+ }
1373
+ ```
1374
+
1375
+ ---
1376
+
1377
+ ## Appendix B: Example Index JSON
1378
+
1379
+ ```json
1380
+ {
1381
+ "daily": {
1382
+ "2025-01-27": [
1383
+ {
1384
+ "file_id": 0,
1385
+ "game_mode": "classic",
1386
+ "wordlist_source": "classic.txt",
1387
+ "show_incorrect_guesses": true,
1388
+ "enable_free_letters": true,
1389
+ "puzzle_options": {"spacer": 0, "may_overlap": false}
1390
+ },
1391
+ {
1392
+ "file_id": 1,
1393
+ "game_mode": "easy",
1394
+ "wordlist_source": "easy.txt",
1395
+ "show_incorrect_guesses": true,
1396
+ "enable_free_letters": true,
1397
+ "puzzle_options": {"spacer": 0, "may_overlap": false}
1398
+ }
1399
+ ]
1400
+ },
1401
+ "weekly": {
1402
+ "2025-W04": [
1403
+ {
1404
+ "file_id": 0,
1405
+ "game_mode": "classic",
1406
+ "wordlist_source": "classic.txt",
1407
+ "show_incorrect_guesses": true,
1408
+ "enable_free_letters": true,
1409
+ "puzzle_options": {"spacer": 0, "may_overlap": false}
1410
+ }
1411
+ ]
1412
+ }
1413
+ }
1414
+ ```
1415
+
1416
+ ---
1417
+
1418
+ ## Appendix C: Entry Type Comparison (Updated)
1419
+
1420
+ | Field | daily | weekly | challenge |
1421
+ |-------|-------|--------|-----------|
1422
+ | `challenge_id` format | `"2025-01-27-0"` | `"2025-W04-0"` | `"20251130T190249Z-ABC123"` |
1423
+ | `entry_type` | `"daily"` | `"weekly"` | `"challenge"` |
1424
+ | Storage path | `leaderboards/daily/{date}-{file_id}/settings.json` | `leaderboards/weekly/{week}-{file_id}/settings.json` | `games/{id}/settings.json` |
1425
+ | Reset frequency | Daily (UTC midnight) | Weekly (Monday UTC midnight) | Never (permanent) |
1426
+ | Settings-based | Yes (separate folder per settings) | Yes (separate folder per settings) | N/A (settings fixed per challenge) |
1427
+ | `max_display_entries` | 20 | 20 | N/A (all users shown) |
1428
+
1429
+ ---
1430
+
1431
+ *End of Specification*
tests/test_leaderboard.py ADDED
@@ -0,0 +1,525 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # tests/test_leaderboard.py
2
+ """
3
+ Unit tests for the Wrdler Leaderboard System.
4
+
5
+ Tests cover:
6
+ - UserEntry and LeaderboardSettings dataclasses
7
+ - Qualification logic
8
+ - Sorting functions
9
+ - Date/week ID generation
10
+ """
11
+
12
+ import pytest
13
+ from datetime import datetime, timezone, timedelta
14
+ from unittest.mock import patch, MagicMock
15
+
16
+ from wrdler.leaderboard import (
17
+ UserEntry,
18
+ LeaderboardSettings,
19
+ get_current_daily_id,
20
+ get_current_weekly_id,
21
+ get_daily_leaderboard_path,
22
+ get_weekly_leaderboard_path,
23
+ _sort_users,
24
+ check_qualification,
25
+ create_user_entry,
26
+ MAX_DISPLAY_ENTRIES,
27
+ )
28
+
29
+
30
+ class TestUserEntry:
31
+ """Tests for UserEntry dataclass."""
32
+
33
+ def test_create_entry(self):
34
+ """Test basic UserEntry creation."""
35
+ entry = UserEntry(
36
+ uid="test-uid-123",
37
+ username="TestPlayer",
38
+ word_list=["WORD", "TEST", "GAME", "PLAY", "SCORE", "FINAL"],
39
+ score=42,
40
+ time=180,
41
+ timestamp="2025-01-27T12:00:00+00:00",
42
+ )
43
+ assert entry.uid == "test-uid-123"
44
+ assert entry.username == "TestPlayer"
45
+ assert len(entry.word_list) == 6
46
+ assert entry.score == 42
47
+ assert entry.time == 180
48
+ assert entry.word_list_difficulty is None
49
+ assert entry.source_challenge_id is None
50
+
51
+ def test_to_dict_basic(self):
52
+ """Test to_dict without optional fields."""
53
+ entry = UserEntry(
54
+ uid="test-uid",
55
+ username="Player",
56
+ word_list=["A", "B", "C", "D", "E", "F"],
57
+ score=30,
58
+ time=120,
59
+ timestamp="2025-01-27T12:00:00+00:00",
60
+ )
61
+ d = entry.to_dict()
62
+
63
+ assert d["uid"] == "test-uid"
64
+ assert d["username"] == "Player"
65
+ assert d["score"] == 30
66
+ assert d["time"] == 120
67
+ assert "word_list_difficulty" not in d
68
+ assert "source_challenge_id" not in d
69
+
70
+ def test_to_dict_with_optional_fields(self):
71
+ """Test to_dict with optional fields."""
72
+ entry = UserEntry(
73
+ uid="test-uid",
74
+ username="Player",
75
+ word_list=["A", "B", "C", "D", "E", "F"],
76
+ score=30,
77
+ time=120,
78
+ timestamp="2025-01-27T12:00:00+00:00",
79
+ word_list_difficulty=117.5,
80
+ source_challenge_id="challenge-123",
81
+ )
82
+ d = entry.to_dict()
83
+
84
+ assert d["word_list_difficulty"] == 117.5
85
+ assert d["source_challenge_id"] == "challenge-123"
86
+
87
+ def test_from_dict_roundtrip(self):
88
+ """Test to_dict/from_dict roundtrip."""
89
+ original = UserEntry(
90
+ uid="test-uid",
91
+ username="Player",
92
+ word_list=["A", "B", "C", "D", "E", "F"],
93
+ score=30,
94
+ time=120,
95
+ timestamp="2025-01-27T12:00:00+00:00",
96
+ word_list_difficulty=100.0,
97
+ )
98
+ d = original.to_dict()
99
+ restored = UserEntry.from_dict(d)
100
+
101
+ assert restored.uid == original.uid
102
+ assert restored.username == original.username
103
+ assert restored.score == original.score
104
+ assert restored.time == original.time
105
+ assert restored.word_list_difficulty == original.word_list_difficulty
106
+
107
+ def test_from_dict_legacy_time_seconds(self):
108
+ """Test from_dict handles legacy 'time_seconds' field."""
109
+ data = {
110
+ "uid": "test",
111
+ "username": "Player",
112
+ "word_list": ["A", "B", "C", "D", "E", "F"],
113
+ "score": 30,
114
+ "time_seconds": 150, # Legacy field name
115
+ "timestamp": "2025-01-27T12:00:00+00:00",
116
+ }
117
+ entry = UserEntry.from_dict(data)
118
+ assert entry.time == 150
119
+
120
+
121
+ class TestLeaderboardSettings:
122
+ """Tests for LeaderboardSettings dataclass."""
123
+
124
+ def test_create_leaderboard(self):
125
+ """Test basic LeaderboardSettings creation."""
126
+ lb = LeaderboardSettings(
127
+ challenge_id="2025-01-27",
128
+ entry_type="daily",
129
+ )
130
+ assert lb.challenge_id == "2025-01-27"
131
+ assert lb.entry_type == "daily"
132
+ assert lb.game_mode == "classic"
133
+ assert lb.grid_size == 8
134
+ assert len(lb.users) == 0
135
+ assert lb.max_display_entries == MAX_DISPLAY_ENTRIES
136
+
137
+ def test_entry_type_values(self):
138
+ """Test valid entry_type values."""
139
+ for entry_type in ["daily", "weekly", "challenge"]:
140
+ lb = LeaderboardSettings(
141
+ challenge_id="test",
142
+ entry_type=entry_type,
143
+ )
144
+ assert lb.entry_type == entry_type
145
+
146
+ def test_get_display_users_limit(self):
147
+ """Test get_display_users respects max_display_entries."""
148
+ users = [
149
+ UserEntry(
150
+ uid=f"uid-{i}",
151
+ username=f"Player{i}",
152
+ word_list=["A", "B", "C", "D", "E", "F"],
153
+ score=100 - i,
154
+ time=60 + i,
155
+ timestamp="2025-01-27T12:00:00+00:00",
156
+ )
157
+ for i in range(25) # More than MAX_DISPLAY_ENTRIES
158
+ ]
159
+
160
+ lb = LeaderboardSettings(
161
+ challenge_id="test",
162
+ entry_type="daily",
163
+ users=users,
164
+ )
165
+
166
+ display_users = lb.get_display_users()
167
+ assert len(display_users) == MAX_DISPLAY_ENTRIES
168
+ # Should be first 20 (already sorted by creation)
169
+ assert display_users[0].uid == "uid-0"
170
+
171
+ def test_to_dict_and_from_dict(self):
172
+ """Test LeaderboardSettings serialization roundtrip."""
173
+ user = UserEntry(
174
+ uid="test-uid",
175
+ username="Player",
176
+ word_list=["A", "B", "C", "D", "E", "F"],
177
+ score=50,
178
+ time=100,
179
+ timestamp="2025-01-27T12:00:00+00:00",
180
+ )
181
+
182
+ lb = LeaderboardSettings(
183
+ challenge_id="2025-01-27",
184
+ entry_type="daily",
185
+ game_mode="easy",
186
+ users=[user],
187
+ wordlist_source="test.txt",
188
+ )
189
+
190
+ d = lb.to_dict()
191
+ restored = LeaderboardSettings.from_dict(d)
192
+
193
+ assert restored.challenge_id == lb.challenge_id
194
+ assert restored.entry_type == lb.entry_type
195
+ assert restored.game_mode == lb.game_mode
196
+ assert len(restored.users) == 1
197
+ assert restored.wordlist_source == lb.wordlist_source
198
+
199
+ def test_format_matches_challenge_structure(self):
200
+ """Test that leaderboard format matches challenge settings.json structure."""
201
+ lb = LeaderboardSettings(
202
+ challenge_id="2025-01-27",
203
+ entry_type="daily",
204
+ game_mode="classic",
205
+ grid_size=8,
206
+ wordlist_source="classic.txt",
207
+ )
208
+ d = lb.to_dict()
209
+
210
+ # Key fields that should match challenge format
211
+ assert "challenge_id" in d
212
+ assert "entry_type" in d
213
+ assert "game_mode" in d
214
+ assert "grid_size" in d
215
+ assert "puzzle_options" in d
216
+ assert "users" in d
217
+ assert "created_at" in d
218
+ assert "version" in d
219
+ assert "show_incorrect_guesses" in d
220
+ assert "enable_free_letters" in d
221
+ assert "wordlist_source" in d
222
+
223
+
224
+ class TestQualification:
225
+ """Tests for qualification logic."""
226
+
227
+ def test_qualify_empty_leaderboard(self):
228
+ """Test that any score qualifies for empty leaderboard."""
229
+ assert check_qualification(None, 1, 999) is True
230
+
231
+ def test_qualify_not_full(self):
232
+ """Test qualification when leaderboard is not full."""
233
+ users = [
234
+ UserEntry(
235
+ uid=f"uid-{i}",
236
+ username=f"Player{i}",
237
+ word_list=["A", "B", "C", "D", "E", "F"],
238
+ score=50,
239
+ time=100,
240
+ timestamp="2025-01-27T12:00:00+00:00",
241
+ )
242
+ for i in range(10) # Less than MAX_DISPLAY_ENTRIES
243
+ ]
244
+
245
+ lb = LeaderboardSettings(
246
+ challenge_id="test",
247
+ entry_type="daily",
248
+ users=users,
249
+ )
250
+
251
+ # Any score should qualify
252
+ assert check_qualification(lb, 1, 999) is True
253
+
254
+ def test_qualify_by_score(self):
255
+ """Test qualification by higher score."""
256
+ users = [
257
+ UserEntry(
258
+ uid=f"uid-{i}",
259
+ username=f"Player{i}",
260
+ word_list=["A", "B", "C", "D", "E", "F"],
261
+ score=50 - i, # Scores from 50 down to 31
262
+ time=100,
263
+ timestamp="2025-01-27T12:00:00+00:00",
264
+ )
265
+ for i in range(20)
266
+ ]
267
+
268
+ lb = LeaderboardSettings(
269
+ challenge_id="test",
270
+ entry_type="daily",
271
+ users=users,
272
+ )
273
+
274
+ # Higher than lowest (31) should qualify
275
+ assert check_qualification(lb, 32, 100) is True
276
+ # Equal to lowest but faster time should qualify
277
+ assert check_qualification(lb, 31, 99) is True
278
+ # Lower than lowest should not qualify
279
+ assert check_qualification(lb, 30, 100) is False
280
+
281
+ def test_qualify_by_time_tiebreaker(self):
282
+ """Test qualification using time as tiebreaker."""
283
+ users = [
284
+ UserEntry(
285
+ uid=f"uid-{i}",
286
+ username=f"Player{i}",
287
+ word_list=["A", "B", "C", "D", "E", "F"],
288
+ score=50, # All same score
289
+ time=100 + i, # Times from 100 to 119
290
+ timestamp="2025-01-27T12:00:00+00:00",
291
+ )
292
+ for i in range(20)
293
+ ]
294
+
295
+ lb = LeaderboardSettings(
296
+ challenge_id="test",
297
+ entry_type="daily",
298
+ users=users,
299
+ )
300
+
301
+ # Same score but faster than slowest (119) should qualify
302
+ assert check_qualification(lb, 50, 118) is True
303
+ # Same score and slower should not qualify
304
+ assert check_qualification(lb, 50, 120) is False
305
+
306
+ def test_qualify_by_difficulty_tiebreaker(self):
307
+ """Test qualification using difficulty as final tiebreaker."""
308
+ users = [
309
+ UserEntry(
310
+ uid=f"uid-{i}",
311
+ username=f"Player{i}",
312
+ word_list=["A", "B", "C", "D", "E", "F"],
313
+ score=50, # All same score
314
+ time=100, # All same time
315
+ timestamp="2025-01-27T12:00:00+00:00",
316
+ word_list_difficulty=100.0 - i, # Difficulties from 100 to 81
317
+ )
318
+ for i in range(20)
319
+ ]
320
+
321
+ lb = LeaderboardSettings(
322
+ challenge_id="test",
323
+ entry_type="daily",
324
+ users=users,
325
+ )
326
+
327
+ # Same score/time but higher difficulty than lowest (81) should qualify
328
+ assert check_qualification(lb, 50, 100, 82.0) is True
329
+ # Lower difficulty should not qualify
330
+ assert check_qualification(lb, 50, 100, 80.0) is False
331
+
332
+ def test_not_qualify_lower_score(self):
333
+ """Test that lower score doesn't qualify for full leaderboard."""
334
+ users = [
335
+ UserEntry(
336
+ uid=f"uid-{i}",
337
+ username=f"Player{i}",
338
+ word_list=["A", "B", "C", "D", "E", "F"],
339
+ score=100, # All high scores
340
+ time=60,
341
+ timestamp="2025-01-27T12:00:00+00:00",
342
+ )
343
+ for i in range(20)
344
+ ]
345
+
346
+ lb = LeaderboardSettings(
347
+ challenge_id="test",
348
+ entry_type="daily",
349
+ users=users,
350
+ )
351
+
352
+ # Much lower score should not qualify
353
+ assert check_qualification(lb, 50, 60) is False
354
+
355
+
356
+ class TestDateIds:
357
+ """Tests for date/week ID generation."""
358
+
359
+ def test_daily_id_format(self):
360
+ """Test daily ID format is YYYY-MM-DD."""
361
+ daily_id = get_current_daily_id()
362
+ # Should match pattern YYYY-MM-DD
363
+ assert len(daily_id) == 10
364
+ assert daily_id[4] == "-"
365
+ assert daily_id[7] == "-"
366
+
367
+ # Parse to verify it's a valid date
368
+ date = datetime.strptime(daily_id, "%Y-%m-%d")
369
+ assert date is not None
370
+
371
+ def test_weekly_id_format(self):
372
+ """Test weekly ID format is YYYY-Www."""
373
+ weekly_id = get_current_weekly_id()
374
+ # Should match pattern YYYY-Www
375
+ assert "-W" in weekly_id
376
+ parts = weekly_id.split("-W")
377
+ assert len(parts) == 2
378
+ assert len(parts[0]) == 4 # Year
379
+ assert len(parts[1]) == 2 # Week number with leading zero
380
+
381
+ def test_daily_path(self):
382
+ """Test daily leaderboard path generation."""
383
+ path = get_daily_leaderboard_path("2025-01-27")
384
+ assert path == "leaderboards/daily/2025-01-27.json"
385
+
386
+ def test_weekly_path(self):
387
+ """Test weekly leaderboard path generation."""
388
+ path = get_weekly_leaderboard_path("2025-W04")
389
+ assert path == "leaderboards/weekly/2025-W04.json"
390
+
391
+
392
+ class TestSorting:
393
+ """Tests for user sorting."""
394
+
395
+ def test_sort_by_score_desc(self):
396
+ """Test users are sorted by score descending."""
397
+ users = [
398
+ UserEntry(uid="1", username="A", word_list=[], score=30, time=100, timestamp=""),
399
+ UserEntry(uid="2", username="B", word_list=[], score=50, time=100, timestamp=""),
400
+ UserEntry(uid="3", username="C", word_list=[], score=40, time=100, timestamp=""),
401
+ ]
402
+
403
+ sorted_users = _sort_users(users)
404
+
405
+ assert sorted_users[0].score == 50
406
+ assert sorted_users[1].score == 40
407
+ assert sorted_users[2].score == 30
408
+
409
+ def test_sort_by_time_asc_for_equal_score(self):
410
+ """Test users with equal score are sorted by time ascending."""
411
+ users = [
412
+ UserEntry(uid="1", username="A", word_list=[], score=50, time=120, timestamp=""),
413
+ UserEntry(uid="2", username="B", word_list=[], score=50, time=80, timestamp=""),
414
+ UserEntry(uid="3", username="C", word_list=[], score=50, time=100, timestamp=""),
415
+ ]
416
+
417
+ sorted_users = _sort_users(users)
418
+
419
+ assert sorted_users[0].time == 80
420
+ assert sorted_users[1].time == 100
421
+ assert sorted_users[2].time == 120
422
+
423
+ def test_sort_by_difficulty_desc_for_equal_score_and_time(self):
424
+ """Test users with equal score and time are sorted by difficulty descending."""
425
+ users = [
426
+ UserEntry(uid="1", username="A", word_list=[], score=50, time=100, timestamp="", word_list_difficulty=80.0),
427
+ UserEntry(uid="2", username="B", word_list=[], score=50, time=100, timestamp="", word_list_difficulty=120.0),
428
+ UserEntry(uid="3", username="C", word_list=[], score=50, time=100, timestamp="", word_list_difficulty=100.0),
429
+ ]
430
+
431
+ sorted_users = _sort_users(users)
432
+
433
+ assert sorted_users[0].word_list_difficulty == 120.0
434
+ assert sorted_users[1].word_list_difficulty == 100.0
435
+ assert sorted_users[2].word_list_difficulty == 80.0
436
+
437
+
438
+ class TestCreateUserEntry:
439
+ """Tests for create_user_entry helper."""
440
+
441
+ def test_create_user_entry_basic(self):
442
+ """Test creating a user entry with basic fields."""
443
+ entry = create_user_entry(
444
+ username="TestPlayer",
445
+ score=45,
446
+ time_seconds=150,
447
+ word_list=["A", "B", "C", "D", "E", "F"],
448
+ )
449
+
450
+ assert entry.username == "TestPlayer"
451
+ assert entry.score == 45
452
+ assert entry.time == 150
453
+ assert len(entry.word_list) == 6
454
+ assert entry.uid is not None # Auto-generated
455
+ assert entry.timestamp is not None # Auto-generated
456
+
457
+ def test_create_user_entry_with_optional_fields(self):
458
+ """Test creating a user entry with optional fields."""
459
+ entry = create_user_entry(
460
+ username="TestPlayer",
461
+ score=45,
462
+ time_seconds=150,
463
+ word_list=["A", "B", "C", "D", "E", "F"],
464
+ word_list_difficulty=110.5,
465
+ source_challenge_id="challenge-xyz",
466
+ )
467
+
468
+ assert entry.word_list_difficulty == 110.5
469
+ assert entry.source_challenge_id == "challenge-xyz"
470
+
471
+
472
+ class TestUnifiedFormat:
473
+ """Tests for unified format consistency."""
474
+
475
+ def test_leaderboard_matches_challenge_structure(self):
476
+ """Test leaderboard to_dict matches expected challenge structure."""
477
+ lb = LeaderboardSettings(
478
+ challenge_id="2025-01-27",
479
+ entry_type="daily",
480
+ )
481
+ d = lb.to_dict()
482
+
483
+ # All challenge fields should be present
484
+ required_fields = [
485
+ "challenge_id",
486
+ "entry_type",
487
+ "game_mode",
488
+ "grid_size",
489
+ "puzzle_options",
490
+ "users",
491
+ "created_at",
492
+ "version",
493
+ "show_incorrect_guesses",
494
+ "enable_free_letters",
495
+ "wordlist_source",
496
+ "game_title",
497
+ "max_display_entries",
498
+ ]
499
+
500
+ for field in required_fields:
501
+ assert field in d, f"Missing field: {field}"
502
+
503
+ def test_entry_type_field_present(self):
504
+ """Test entry_type is always present in serialized output."""
505
+ for entry_type in ["daily", "weekly", "challenge"]:
506
+ lb = LeaderboardSettings(
507
+ challenge_id="test",
508
+ entry_type=entry_type,
509
+ )
510
+ d = lb.to_dict()
511
+ assert d["entry_type"] == entry_type
512
+
513
+ def test_challenge_id_as_primary_identifier(self):
514
+ """Test challenge_id serves as primary identifier for all types."""
515
+ # Daily uses date format
516
+ daily = LeaderboardSettings(challenge_id="2025-01-27", entry_type="daily")
517
+ assert daily.challenge_id == "2025-01-27"
518
+
519
+ # Weekly uses week format
520
+ weekly = LeaderboardSettings(challenge_id="2025-W04", entry_type="weekly")
521
+ assert weekly.challenge_id == "2025-W04"
522
+
523
+ # Challenge uses UID format
524
+ challenge = LeaderboardSettings(challenge_id="20251130T190249Z-ABCDEF", entry_type="challenge")
525
+ assert challenge.challenge_id == "20251130T190249Z-ABCDEF"
wrdler/__init__.py CHANGED
@@ -6,7 +6,8 @@ Key differences from BattleWords:
6
  - One word per row, horizontal only (no vertical words)
7
  - No scope/radar visualization
8
  - 2 free letter guesses at game start
 
9
  """
10
 
11
- __version__ = "0.1.2"
12
- __all__ = ["models", "generator", "logic", "ui", "word_loader"]
 
6
  - One word per row, horizontal only (no vertical words)
7
  - No scope/radar visualization
8
  - 2 free letter guesses at game start
9
+ - Daily and weekly leaderboards
10
  """
11
 
12
+ __version__ = "0.2.0"
13
+ __all__ = ["models", "generator", "logic", "ui", "word_loader", "leaderboard", "leaderboard_page"]
wrdler/game_storage.py CHANGED
@@ -12,7 +12,7 @@ Wrdler Specifications:
12
  - No word overlaps
13
  - 2 free letter guesses at game start
14
  """
15
- __version__ = "0.1.5"
16
 
17
  import json
18
  import tempfile
@@ -69,7 +69,8 @@ def serialize_game_settings(
69
  challenge_id: Optional[str] = None,
70
  game_title: Optional[str] = None,
71
  show_incorrect_guesses: bool = True,
72
- enable_free_letters: bool = True
 
73
  ) -> Dict[str, Any]:
74
  """
75
  Serialize game settings into a JSON-compatible dictionary.
@@ -96,6 +97,7 @@ def serialize_game_settings(
96
  game_title: Game title (e.g., "Wrdler Gradio AI")
97
  show_incorrect_guesses: Whether to show incorrect guesses
98
  enable_free_letters: Whether free letters feature is enabled
 
99
 
100
  Returns:
101
  dict: Serialized game settings with users array
@@ -130,6 +132,7 @@ def serialize_game_settings(
130
 
131
  settings = {
132
  "challenge_id": challenge_id,
 
133
  "game_mode": game_mode,
134
  "grid_size": grid_size,
135
  "puzzle_options": {
 
12
  - No word overlaps
13
  - 2 free letter guesses at game start
14
  """
15
+ __version__ = "0.2.0"
16
 
17
  import json
18
  import tempfile
 
69
  challenge_id: Optional[str] = None,
70
  game_title: Optional[str] = None,
71
  show_incorrect_guesses: bool = True,
72
+ enable_free_letters: bool = True,
73
+ entry_type: str = "challenge"
74
  ) -> Dict[str, Any]:
75
  """
76
  Serialize game settings into a JSON-compatible dictionary.
 
97
  game_title: Game title (e.g., "Wrdler Gradio AI")
98
  show_incorrect_guesses: Whether to show incorrect guesses
99
  enable_free_letters: Whether free letters feature is enabled
100
+ entry_type: Type of entry ("challenge", "daily", or "weekly")
101
 
102
  Returns:
103
  dict: Serialized game settings with users array
 
132
 
133
  settings = {
134
  "challenge_id": challenge_id,
135
+ "entry_type": entry_type,
136
  "game_mode": game_mode,
137
  "grid_size": grid_size,
138
  "puzzle_options": {
wrdler/leaderboard.py ADDED
@@ -0,0 +1,758 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # wrdler/leaderboard.py
2
+ """
3
+ Wrdler Leaderboard System
4
+
5
+ Manages daily and weekly leaderboards with automatic score submission,
6
+ qualification checking, and historical lookup.
7
+
8
+ Leaderboard Configuration:
9
+ - Max display entries: 20 per leaderboard (can store more)
10
+ - Daily reset: UTC midnight
11
+ - Weekly reset: Monday UTC 00:00 (ISO week)
12
+ - Sorting: score (desc), time (asc), difficulty (desc)
13
+ - File format: Unified with challenge settings.json
14
+ - Settings-based separation: Each unique settings combination gets its own leaderboard folder
15
+ """
16
+ __version__ = "0.2.0"
17
+
18
+ from dataclasses import dataclass, field
19
+ from datetime import datetime, timezone, timedelta
20
+ from typing import Dict, Any, List, Optional, Tuple, Literal
21
+ import logging
22
+
23
+ from wrdler.modules.storage import (
24
+ _get_json_from_repo,
25
+ _upload_json_to_repo
26
+ )
27
+ from wrdler.modules.constants import HF_REPO_ID, APP_SETTINGS
28
+ from wrdler.game_storage import generate_uid
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ # Configuration
33
+ MAX_DISPLAY_ENTRIES = 20
34
+ DAILY_LEADERBOARD_PATH = "leaderboards/daily"
35
+ WEEKLY_LEADERBOARD_PATH = "leaderboards/weekly"
36
+ LEADERBOARD_INDEX_PATH = "leaderboards/index.json"
37
+
38
+ # Entry types
39
+ EntryType = Literal["daily", "weekly", "challenge"]
40
+
41
+
42
+ @dataclass
43
+ class GameSettings:
44
+ """Settings that define a unique leaderboard."""
45
+ game_mode: str = "classic"
46
+ wordlist_source: str = "classic.txt"
47
+ show_incorrect_guesses: bool = True
48
+ enable_free_letters: bool = True
49
+ puzzle_options: Dict[str, Any] = field(default_factory=lambda: {"spacer": 0, "may_overlap": False})
50
+
51
+ def matches(self, other: "GameSettings") -> bool:
52
+ """Check if two settings are equivalent (same leaderboard)."""
53
+ return (
54
+ self.game_mode == other.game_mode and
55
+ self.wordlist_source == other.wordlist_source and
56
+ self.show_incorrect_guesses == other.show_incorrect_guesses and
57
+ self.enable_free_letters == other.enable_free_letters and
58
+ self.puzzle_options.get("spacer", 0) == other.puzzle_options.get("spacer", 0) and
59
+ self.puzzle_options.get("may_overlap", False) == other.puzzle_options.get("may_overlap", False)
60
+ )
61
+
62
+ def to_dict(self) -> Dict[str, Any]:
63
+ """Convert to dictionary."""
64
+ return {
65
+ "game_mode": self.game_mode,
66
+ "wordlist_source": self.wordlist_source,
67
+ "show_incorrect_guesses": self.show_incorrect_guesses,
68
+ "enable_free_letters": self.enable_free_letters,
69
+ "puzzle_options": self.puzzle_options,
70
+ }
71
+
72
+ @classmethod
73
+ def from_dict(cls, data: Dict[str, Any]) -> "GameSettings":
74
+ """Create from dictionary."""
75
+ return cls(
76
+ game_mode=data.get("game_mode", "classic"),
77
+ wordlist_source=data.get("wordlist_source", "classic.txt"),
78
+ show_incorrect_guesses=data.get("show_incorrect_guesses", True),
79
+ enable_free_letters=data.get("enable_free_letters", True),
80
+ puzzle_options=data.get("puzzle_options", {"spacer": 0, "may_overlap": False}),
81
+ )
82
+
83
+ @classmethod
84
+ def from_leaderboard(cls, leaderboard: "LeaderboardSettings") -> "GameSettings":
85
+ """Extract settings from a leaderboard."""
86
+ return cls(
87
+ game_mode=leaderboard.game_mode,
88
+ wordlist_source=leaderboard.wordlist_source,
89
+ show_incorrect_guesses=leaderboard.show_incorrect_guesses,
90
+ enable_free_letters=leaderboard.enable_free_letters,
91
+ puzzle_options=leaderboard.puzzle_options,
92
+ )
93
+
94
+
95
+ @dataclass
96
+ class UserEntry:
97
+ """Single user entry in a leaderboard (matches challenge user format)."""
98
+ uid: str
99
+ username: str
100
+ word_list: List[str]
101
+ score: int
102
+ time: int # seconds (matches existing 'time' field, not 'time_seconds')
103
+ timestamp: str
104
+ word_list_difficulty: Optional[float] = None
105
+ source_challenge_id: Optional[str] = None # If entry came from a challenge
106
+
107
+ def to_dict(self) -> Dict[str, Any]:
108
+ """Convert to dictionary for JSON serialization."""
109
+ result = {
110
+ "uid": self.uid,
111
+ "username": self.username,
112
+ "word_list": self.word_list,
113
+ "score": self.score,
114
+ "time": self.time,
115
+ "timestamp": self.timestamp,
116
+ }
117
+ if self.word_list_difficulty is not None:
118
+ result["word_list_difficulty"] = self.word_list_difficulty
119
+ if self.source_challenge_id is not None:
120
+ result["source_challenge_id"] = self.source_challenge_id
121
+ return result
122
+
123
+ @classmethod
124
+ def from_dict(cls, data: Dict[str, Any]) -> "UserEntry":
125
+ """Create from dictionary."""
126
+ return cls(
127
+ uid=data["uid"],
128
+ username=data["username"],
129
+ word_list=data["word_list"],
130
+ score=data["score"],
131
+ time=data.get("time", data.get("time_seconds", 0)), # Handle both field names
132
+ timestamp=data["timestamp"],
133
+ word_list_difficulty=data.get("word_list_difficulty"),
134
+ source_challenge_id=data.get("source_challenge_id"),
135
+ )
136
+
137
+
138
+ @dataclass
139
+ class LeaderboardSettings:
140
+ """
141
+ Unified leaderboard/challenge settings format.
142
+
143
+ This matches the existing challenge settings.json structure with added
144
+ entry_type field to distinguish between daily, weekly, and challenge entries.
145
+ The settings fields define what makes this leaderboard unique.
146
+ """
147
+ challenge_id: str # Date-fileId for daily, week-fileId for weekly, UID for challenge
148
+ entry_type: EntryType # "daily", "weekly", or "challenge"
149
+ game_mode: str = "classic"
150
+ grid_size: int = 8
151
+ puzzle_options: Dict[str, Any] = field(default_factory=lambda: {"spacer": 0, "may_overlap": False})
152
+ users: List[UserEntry] = field(default_factory=list)
153
+ created_at: str = ""
154
+ version: str = __version__
155
+ show_incorrect_guesses: bool = True
156
+ enable_free_letters: bool = True
157
+ wordlist_source: str = "classic.txt"
158
+ game_title: str = "Wrdler"
159
+ max_display_entries: int = MAX_DISPLAY_ENTRIES
160
+
161
+ def __post_init__(self):
162
+ if not self.created_at:
163
+ self.created_at = datetime.now(timezone.utc).isoformat()
164
+ if not self.game_title:
165
+ self.game_title = APP_SETTINGS.get("game_title", "Wrdler")
166
+
167
+ def to_dict(self) -> Dict[str, Any]:
168
+ """Convert to dictionary for JSON serialization."""
169
+ return {
170
+ "challenge_id": self.challenge_id,
171
+ "entry_type": self.entry_type,
172
+ "game_mode": self.game_mode,
173
+ "grid_size": self.grid_size,
174
+ "puzzle_options": self.puzzle_options,
175
+ "users": [u.to_dict() for u in self.users],
176
+ "created_at": self.created_at,
177
+ "version": self.version,
178
+ "show_incorrect_guesses": self.show_incorrect_guesses,
179
+ "enable_free_letters": self.enable_free_letters,
180
+ "wordlist_source": self.wordlist_source,
181
+ "game_title": self.game_title,
182
+ "max_display_entries": self.max_display_entries,
183
+ }
184
+
185
+ @classmethod
186
+ def from_dict(cls, data: Dict[str, Any]) -> "LeaderboardSettings":
187
+ """Create from dictionary."""
188
+ users = [UserEntry.from_dict(u) for u in data.get("users", [])]
189
+ return cls(
190
+ challenge_id=data["challenge_id"],
191
+ entry_type=data.get("entry_type", "challenge"), # Default for legacy
192
+ game_mode=data.get("game_mode", "classic"),
193
+ grid_size=data.get("grid_size", 8),
194
+ puzzle_options=data.get("puzzle_options", {"spacer": 0, "may_overlap": False}),
195
+ users=users,
196
+ created_at=data.get("created_at", ""),
197
+ version=data.get("version", "0.1.0"),
198
+ show_incorrect_guesses=data.get("show_incorrect_guesses", True),
199
+ enable_free_letters=data.get("enable_free_letters", True),
200
+ wordlist_source=data.get("wordlist_source", "classic.txt"),
201
+ game_title=data.get("game_title", "Wrdler"),
202
+ max_display_entries=data.get("max_display_entries", MAX_DISPLAY_ENTRIES),
203
+ )
204
+
205
+ def get_display_users(self) -> List[UserEntry]:
206
+ """Get users limited to max_display_entries."""
207
+ return self.users[:self.max_display_entries]
208
+
209
+ def get_settings(self) -> GameSettings:
210
+ """Extract game settings from this leaderboard."""
211
+ return GameSettings.from_leaderboard(self)
212
+
213
+
214
+ def get_current_daily_id() -> str:
215
+ """Get the date portion of the leaderboard ID for today (UTC)."""
216
+ return datetime.now(timezone.utc).strftime("%Y-%m-%d")
217
+
218
+
219
+ def get_current_weekly_id() -> str:
220
+ """Get the week portion of the leaderboard ID for the current ISO week."""
221
+ now = datetime.now(timezone.utc)
222
+ iso_cal = now.isocalendar()
223
+ return f"{iso_cal.year}-W{iso_cal.week:02d}"
224
+
225
+
226
+ def get_daily_leaderboard_path(date_id: str, file_id: int) -> str:
227
+ """Get the file path for a daily leaderboard (folder-based with settings.json)."""
228
+ return f"{DAILY_LEADERBOARD_PATH}/{date_id}-{file_id}/settings.json"
229
+
230
+
231
+ def get_weekly_leaderboard_path(week_id: str, file_id: int) -> str:
232
+ """Get the file path for a weekly leaderboard (folder-based with settings.json)."""
233
+ return f"{WEEKLY_LEADERBOARD_PATH}/{week_id}-{file_id}/settings.json"
234
+
235
+
236
+ def _sort_users(users: List[UserEntry]) -> List[UserEntry]:
237
+ """Sort users by score (desc), time (asc), difficulty (desc)."""
238
+ return sorted(
239
+ users,
240
+ key=lambda u: (
241
+ -u.score,
242
+ u.time,
243
+ -(u.word_list_difficulty or 0)
244
+ )
245
+ )
246
+
247
+
248
+ def _load_index(repo_id: Optional[str] = None) -> Dict[str, Any]:
249
+ """Load the leaderboard index."""
250
+ if repo_id is None:
251
+ repo_id = HF_REPO_ID
252
+
253
+ data = _get_json_from_repo(repo_id, LEADERBOARD_INDEX_PATH, "dataset")
254
+ if not data:
255
+ return {"daily": {}, "weekly": {}}
256
+ return data
257
+
258
+
259
+ def _save_index(index: Dict[str, Any], repo_id: Optional[str] = None) -> bool:
260
+ """Save the leaderboard index."""
261
+ if repo_id is None:
262
+ repo_id = HF_REPO_ID
263
+
264
+ return _upload_json_to_repo(index, repo_id, LEADERBOARD_INDEX_PATH, "dataset")
265
+
266
+
267
+ def find_matching_leaderboard(
268
+ entry_type: EntryType,
269
+ period_id: str,
270
+ settings: GameSettings,
271
+ repo_id: Optional[str] = None
272
+ ) -> Tuple[Optional[int], Optional[LeaderboardSettings]]:
273
+ """
274
+ Find a leaderboard matching the given settings for a period.
275
+
276
+ Args:
277
+ entry_type: "daily" or "weekly"
278
+ period_id: Date string or week identifier
279
+ settings: Game settings to match
280
+ repo_id: Repository ID
281
+
282
+ Returns:
283
+ Tuple of (file_id, leaderboard) or (None, None) if not found
284
+ """
285
+ if repo_id is None:
286
+ repo_id = HF_REPO_ID
287
+
288
+ index = _load_index(repo_id)
289
+ period_entries = index.get(entry_type, {}).get(period_id, [])
290
+
291
+ for entry in period_entries:
292
+ entry_settings = GameSettings.from_dict(entry)
293
+ if settings.matches(entry_settings):
294
+ file_id = entry["file_id"]
295
+ # Load the actual leaderboard
296
+ if entry_type == "daily":
297
+ path = get_daily_leaderboard_path(period_id, file_id)
298
+ else:
299
+ path = get_weekly_leaderboard_path(period_id, file_id)
300
+
301
+ data = _get_json_from_repo(repo_id, path, "dataset")
302
+ if data:
303
+ return file_id, LeaderboardSettings.from_dict(data)
304
+
305
+ return None, None
306
+
307
+
308
+ def create_or_get_leaderboard(
309
+ entry_type: EntryType,
310
+ period_id: str,
311
+ settings: GameSettings,
312
+ repo_id: Optional[str] = None
313
+ ) -> Tuple[int, LeaderboardSettings]:
314
+ """
315
+ Get existing leaderboard or create a new one for the settings.
316
+
317
+ Args:
318
+ entry_type: "daily" or "weekly"
319
+ period_id: Date string or week identifier
320
+ settings: Game settings
321
+ repo_id: Repository ID
322
+
323
+ Returns:
324
+ Tuple of (file_id, leaderboard)
325
+ """
326
+ if repo_id is None:
327
+ repo_id = HF_REPO_ID
328
+
329
+ # Try to find existing
330
+ file_id, leaderboard = find_matching_leaderboard(entry_type, period_id, settings, repo_id)
331
+
332
+ if leaderboard is not None:
333
+ return file_id, leaderboard
334
+
335
+ # Create new leaderboard
336
+ index = _load_index(repo_id)
337
+ if entry_type not in index:
338
+ index[entry_type] = {}
339
+ if period_id not in index[entry_type]:
340
+ index[entry_type][period_id] = []
341
+
342
+ # Get next file_id
343
+ existing_ids = [e["file_id"] for e in index[entry_type][period_id]]
344
+ file_id = max(existing_ids, default=-1) + 1
345
+
346
+ # Create challenge_id (folder name)
347
+ challenge_id = f"{period_id}-{file_id}"
348
+
349
+ # Create new leaderboard
350
+ leaderboard = LeaderboardSettings(
351
+ challenge_id=challenge_id,
352
+ entry_type=entry_type,
353
+ game_mode=settings.game_mode,
354
+ wordlist_source=settings.wordlist_source,
355
+ show_incorrect_guesses=settings.show_incorrect_guesses,
356
+ enable_free_letters=settings.enable_free_letters,
357
+ puzzle_options=settings.puzzle_options,
358
+ users=[]
359
+ )
360
+
361
+ # Update index
362
+ index_entry = settings.to_dict()
363
+ index_entry["file_id"] = file_id
364
+ index[entry_type][period_id].append(index_entry)
365
+ _save_index(index, repo_id)
366
+
367
+ return file_id, leaderboard
368
+
369
+
370
+ def load_leaderboard(
371
+ entry_type: EntryType,
372
+ period_id: str,
373
+ file_id: int,
374
+ repo_id: Optional[str] = None
375
+ ) -> Optional[LeaderboardSettings]:
376
+ """
377
+ Load a specific leaderboard by file ID.
378
+
379
+ Args:
380
+ entry_type: "daily" or "weekly"
381
+ period_id: Date string or week identifier
382
+ file_id: File identifier
383
+ repo_id: Repository ID (uses HF_REPO_ID if None)
384
+
385
+ Returns:
386
+ LeaderboardSettings object or None if not found
387
+ """
388
+ if repo_id is None:
389
+ repo_id = HF_REPO_ID
390
+
391
+ if entry_type == "daily":
392
+ path = get_daily_leaderboard_path(period_id, file_id)
393
+ elif entry_type == "weekly":
394
+ path = get_weekly_leaderboard_path(period_id, file_id)
395
+ else:
396
+ logger.error(f"Invalid entry_type for leaderboard: {entry_type}")
397
+ return None
398
+
399
+ logger.info(f"?? Loading leaderboard: {path}")
400
+ data = _get_json_from_repo(repo_id, path, "dataset")
401
+
402
+ if not data:
403
+ logger.info(f"?? No existing leaderboard found at {path}")
404
+ return None
405
+
406
+ return LeaderboardSettings.from_dict(data)
407
+
408
+
409
+ def save_leaderboard(
410
+ leaderboard: LeaderboardSettings,
411
+ file_id: int,
412
+ repo_id: Optional[str] = None
413
+ ) -> bool:
414
+ """
415
+ Save a leaderboard to the repository.
416
+
417
+ Args:
418
+ leaderboard: LeaderboardSettings object to save
419
+ file_id: File identifier
420
+ repo_id: Repository ID (uses HF_REPO_ID if None)
421
+
422
+ Returns:
423
+ True if saved successfully, False otherwise
424
+ """
425
+ if repo_id is None:
426
+ repo_id = HF_REPO_ID
427
+
428
+ # Extract period_id from challenge_id (format: "2025-01-27-0" or "2025-W04-0")
429
+ # The challenge_id is the folder name, which includes the file_id
430
+ parts = leaderboard.challenge_id.rsplit("-", 1)
431
+ if len(parts) == 2 and parts[1].isdigit():
432
+ period_id = parts[0]
433
+ else:
434
+ # Handle weekly format like "2025-W04-0"
435
+ parts = leaderboard.challenge_id.rsplit("-", 1)
436
+ period_id = parts[0] if len(parts) > 1 else leaderboard.challenge_id
437
+
438
+ if leaderboard.entry_type == "daily":
439
+ path = get_daily_leaderboard_path(period_id, file_id)
440
+ elif leaderboard.entry_type == "weekly":
441
+ path = get_weekly_leaderboard_path(period_id, file_id)
442
+ else:
443
+ logger.error(f"Cannot save leaderboard with entry_type: {leaderboard.entry_type}")
444
+ return False
445
+
446
+ logger.info(f"?? Saving leaderboard: {path}")
447
+ return _upload_json_to_repo(leaderboard.to_dict(), repo_id, path, "dataset")
448
+
449
+
450
+ def create_user_entry(
451
+ username: str,
452
+ score: int,
453
+ time_seconds: int,
454
+ word_list: List[str],
455
+ word_list_difficulty: Optional[float] = None,
456
+ source_challenge_id: Optional[str] = None
457
+ ) -> UserEntry:
458
+ """Create a new user entry."""
459
+ return UserEntry(
460
+ uid=generate_uid(),
461
+ username=username,
462
+ word_list=word_list,
463
+ score=score,
464
+ time=time_seconds,
465
+ word_list_difficulty=word_list_difficulty,
466
+ source_challenge_id=source_challenge_id,
467
+ timestamp=datetime.now(timezone.utc).isoformat()
468
+ )
469
+
470
+
471
+ def check_qualification(
472
+ leaderboard: Optional[LeaderboardSettings],
473
+ score: int,
474
+ time_seconds: int,
475
+ word_list_difficulty: Optional[float] = None
476
+ ) -> bool:
477
+ """
478
+ Check if a score qualifies for the leaderboard display (top 20).
479
+
480
+ Note: The leaderboard can store more than 20 entries, but only top 20 are displayed.
481
+ This function checks if the score would be in the top 20.
482
+
483
+ Args:
484
+ leaderboard: Existing leaderboard (or None if new)
485
+ score: Score to check
486
+ time_seconds: Time to complete
487
+ word_list_difficulty: Difficulty score
488
+
489
+ Returns:
490
+ True if qualifies for display, False otherwise
491
+ """
492
+ if leaderboard is None or len(leaderboard.users) < MAX_DISPLAY_ENTRIES:
493
+ return True
494
+
495
+ # Get the 20th entry (last displayed)
496
+ display_users = leaderboard.get_display_users()
497
+ if len(display_users) < MAX_DISPLAY_ENTRIES:
498
+ return True
499
+
500
+ lowest = display_users[-1]
501
+
502
+ # Primary: higher score qualifies
503
+ if score > lowest.score:
504
+ return True
505
+ if score < lowest.score:
506
+ return False
507
+
508
+ # Secondary: faster time qualifies (for equal score)
509
+ if time_seconds < lowest.time:
510
+ return True
511
+ if time_seconds > lowest.time:
512
+ return False
513
+
514
+ # Tertiary: higher difficulty qualifies (for equal score and time)
515
+ entry_diff = word_list_difficulty or 0
516
+ lowest_diff = lowest.word_list_difficulty or 0
517
+ return entry_diff > lowest_diff
518
+
519
+
520
+ def submit_to_leaderboard(
521
+ entry_type: EntryType,
522
+ period_id: str,
523
+ user_entry: UserEntry,
524
+ settings: GameSettings,
525
+ repo_id: Optional[str] = None
526
+ ) -> Tuple[bool, Optional[int]]:
527
+ """
528
+ Submit a user entry to a leaderboard if it qualifies.
529
+
530
+ Args:
531
+ entry_type: "daily" or "weekly"
532
+ period_id: Date or week identifier
533
+ user_entry: UserEntry to submit
534
+ settings: Game settings to match leaderboard
535
+ repo_id: Repository ID
536
+
537
+ Returns:
538
+ Tuple of (success, rank) where rank is 1-indexed position or None if didn't qualify
539
+ """
540
+ if repo_id is None:
541
+ repo_id = HF_REPO_ID
542
+
543
+ # Get or create matching leaderboard
544
+ file_id, leaderboard = create_or_get_leaderboard(entry_type, period_id, settings, repo_id)
545
+
546
+ # Check qualification for display
547
+ qualifies = check_qualification(
548
+ leaderboard,
549
+ user_entry.score,
550
+ user_entry.time,
551
+ user_entry.word_list_difficulty
552
+ )
553
+
554
+ if not qualifies:
555
+ logger.info(f"? Score {user_entry.score} did not qualify for top {MAX_DISPLAY_ENTRIES} in {period_id}")
556
+ return False, None
557
+
558
+ # Add entry and sort
559
+ leaderboard.users.append(user_entry)
560
+ leaderboard.users = _sort_users(leaderboard.users)
561
+
562
+ # Find rank (1-indexed) - check if in display range
563
+ rank = None
564
+ for i, u in enumerate(leaderboard.users[:MAX_DISPLAY_ENTRIES]):
565
+ if u.uid == user_entry.uid:
566
+ rank = i + 1
567
+ break
568
+
569
+ if rank is None:
570
+ # Entry was sorted out of top 20
571
+ logger.info(f"? Score {user_entry.score} was sorted out of top {MAX_DISPLAY_ENTRIES}")
572
+ # Still save the entry (stored but not displayed)
573
+ save_leaderboard(leaderboard, file_id, repo_id)
574
+ return False, None
575
+
576
+ # Save leaderboard
577
+ if save_leaderboard(leaderboard, file_id, repo_id):
578
+ logger.info(f"? Added to {entry_type} leaderboard at rank {rank}")
579
+ return True, rank
580
+ else:
581
+ logger.error(f"? Failed to save leaderboard {period_id}")
582
+ return False, None
583
+
584
+
585
+ def submit_score_to_all_leaderboards(
586
+ username: str,
587
+ score: int,
588
+ time_seconds: int,
589
+ word_list: List[str],
590
+ settings: GameSettings,
591
+ word_list_difficulty: Optional[float] = None,
592
+ source_challenge_id: Optional[str] = None,
593
+ repo_id: Optional[str] = None
594
+ ) -> Dict[str, Any]:
595
+ """
596
+ Submit a score to both daily and weekly leaderboards matching the settings.
597
+
598
+ This is the main entry point for game completions.
599
+
600
+ Args:
601
+ username: Player name
602
+ score: Final score
603
+ time_seconds: Time to complete
604
+ word_list: Words played
605
+ settings: Game settings (determines which leaderboard)
606
+ word_list_difficulty: Difficulty score
607
+ source_challenge_id: If from a challenge, the original challenge_id
608
+ repo_id: Repository ID
609
+
610
+ Returns:
611
+ Dict with results:
612
+ {
613
+ "daily": {"qualified": bool, "rank": int|None, "id": str},
614
+ "weekly": {"qualified": bool, "rank": int|None, "id": str},
615
+ "entry_uid": str,
616
+ "settings": {...}
617
+ }
618
+ """
619
+ logger.info(f"?? Submitting score: {score} by {username} with settings: {settings.game_mode}")
620
+
621
+ # Get current period IDs
622
+ daily_id = get_current_daily_id()
623
+ weekly_id = get_current_weekly_id()
624
+
625
+ # Create user entry for daily
626
+ daily_entry = create_user_entry(
627
+ username=username,
628
+ score=score,
629
+ time_seconds=time_seconds,
630
+ word_list=word_list,
631
+ word_list_difficulty=word_list_difficulty,
632
+ source_challenge_id=source_challenge_id
633
+ )
634
+
635
+ # Submit to daily
636
+ daily_qualified, daily_rank = submit_to_leaderboard(
637
+ "daily", daily_id, daily_entry, settings, repo_id
638
+ )
639
+
640
+ # Create separate user entry for weekly (different UID)
641
+ weekly_entry = create_user_entry(
642
+ username=username,
643
+ score=score,
644
+ time_seconds=time_seconds,
645
+ word_list=word_list,
646
+ word_list_difficulty=word_list_difficulty,
647
+ source_challenge_id=source_challenge_id
648
+ )
649
+
650
+ # Submit to weekly
651
+ weekly_qualified, weekly_rank = submit_to_leaderboard(
652
+ "weekly", weekly_id, weekly_entry, settings, repo_id
653
+ )
654
+
655
+ results = {
656
+ "daily": {"qualified": daily_qualified, "rank": daily_rank, "id": daily_id},
657
+ "weekly": {"qualified": weekly_qualified, "rank": weekly_rank, "id": weekly_id},
658
+ "entry_uid": daily_entry.uid,
659
+ "settings": settings.to_dict()
660
+ }
661
+
662
+ logger.info(f"?? Leaderboard results: {results}")
663
+ return results
664
+
665
+
666
+ def get_leaderboards_for_settings(
667
+ entry_type: EntryType,
668
+ period_id: str,
669
+ settings: GameSettings,
670
+ repo_id: Optional[str] = None
671
+ ) -> Optional[LeaderboardSettings]:
672
+ """
673
+ Get leaderboard matching specific settings for a period.
674
+
675
+ Args:
676
+ entry_type: "daily" or "weekly"
677
+ period_id: Date or week identifier
678
+ settings: Game settings to match
679
+ repo_id: Repository ID
680
+
681
+ Returns:
682
+ LeaderboardSettings or None if not found
683
+ """
684
+ _, leaderboard = find_matching_leaderboard(entry_type, period_id, settings, repo_id)
685
+ return leaderboard
686
+
687
+
688
+ def get_last_n_daily_leaderboards(
689
+ n: int = 7,
690
+ settings: Optional[GameSettings] = None,
691
+ repo_id: Optional[str] = None
692
+ ) -> List[Tuple[str, Optional[LeaderboardSettings]]]:
693
+ """
694
+ Get the last N days of daily leaderboards for specific settings.
695
+
696
+ Args:
697
+ n: Number of days to retrieve
698
+ settings: Game settings to filter by (None = default settings)
699
+ repo_id: Repository ID
700
+
701
+ Returns:
702
+ List of tuples (date_id, leaderboard) in reverse chronological order
703
+ """
704
+ if settings is None:
705
+ settings = GameSettings()
706
+
707
+ results = []
708
+ today = datetime.now(timezone.utc).date()
709
+
710
+ for i in range(n):
711
+ date = today - timedelta(days=i)
712
+ date_id = date.strftime("%Y-%m-%d")
713
+ leaderboard = get_leaderboards_for_settings("daily", date_id, settings, repo_id)
714
+ results.append((date_id, leaderboard))
715
+
716
+ return results
717
+
718
+
719
+ def list_available_periods(
720
+ entry_type: EntryType,
721
+ limit: int = 30,
722
+ repo_id: Optional[str] = None
723
+ ) -> List[str]:
724
+ """
725
+ List available period IDs from the index.
726
+
727
+ Args:
728
+ entry_type: "daily" or "weekly"
729
+ limit: Maximum number of IDs to return
730
+ repo_id: Repository ID
731
+
732
+ Returns:
733
+ List of period IDs in reverse chronological order
734
+ """
735
+ index = _load_index(repo_id)
736
+ periods = list(index.get(entry_type, {}).keys())
737
+ periods.sort(reverse=True)
738
+ return periods[:limit]
739
+
740
+
741
+ def list_settings_for_period(
742
+ entry_type: EntryType,
743
+ period_id: str,
744
+ repo_id: Optional[str] = None
745
+ ) -> List[Dict[str, Any]]:
746
+ """
747
+ List all settings combinations available for a period.
748
+
749
+ Args:
750
+ entry_type: "daily" or "weekly"
751
+ period_id: Date or week identifier
752
+ repo_id: Repository ID
753
+
754
+ Returns:
755
+ List of settings dictionaries with file_id
756
+ """
757
+ index = _load_index(repo_id)
758
+ return index.get(entry_type, {}).get(period_id, [])
wrdler/leaderboard_page.py ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # wrdler/leaderboard_page.py
2
+ """
3
+ Wrdler Leaderboard Page
4
+
5
+ Streamlit page component for displaying daily and weekly leaderboards
6
+ with historical lookup capabilities.
7
+ """
8
+ __version__ = "0.2.0"
9
+
10
+ import streamlit as st
11
+ from datetime import datetime
12
+ from typing import Optional
13
+
14
+ from wrdler.leaderboard import (
15
+ load_leaderboard,
16
+ get_last_n_daily_leaderboards,
17
+ get_current_daily_id,
18
+ get_current_weekly_id,
19
+ list_available_leaderboards,
20
+ LeaderboardSettings,
21
+ MAX_DISPLAY_ENTRIES
22
+ )
23
+ from wrdler.modules.constants import APP_SETTINGS
24
+
25
+
26
+ def _format_time(seconds: int) -> str:
27
+ """Format seconds as MM:SS."""
28
+ mins, secs = divmod(seconds, 60)
29
+ return f"{mins:02d}:{secs:02d}"
30
+
31
+
32
+ def _get_rank_emoji(rank: int) -> str:
33
+ """Get emoji for rank."""
34
+ if rank == 1:
35
+ return "??"
36
+ elif rank == 2:
37
+ return "??"
38
+ elif rank == 3:
39
+ return "??"
40
+ return f"{rank}."
41
+
42
+
43
+ def _render_leaderboard_table(leaderboard: Optional[LeaderboardSettings], title: str):
44
+ """Render a leaderboard as a styled table."""
45
+ if title:
46
+ st.subheader(title)
47
+
48
+ if leaderboard is None or not leaderboard.users:
49
+ st.info("No entries yet. Be the first to set a high score!")
50
+ return
51
+
52
+ # Get display users (limited to max_display_entries)
53
+ display_users = leaderboard.get_display_users()
54
+
55
+ # Build table HTML
56
+ rows = []
57
+ for i, user in enumerate(display_users, 1):
58
+ rank_display = _get_rank_emoji(i)
59
+ time_display = _format_time(user.time)
60
+ difficulty = f"{user.word_list_difficulty:.2f}" if user.word_list_difficulty else "-"
61
+
62
+ # Show challenge indicator if from a challenge
63
+ challenge_badge = " ??" if user.source_challenge_id else ""
64
+
65
+ rows.append(f"""
66
+ <tr>
67
+ <td style="text-align:center;font-size:1.2rem;">{rank_display}</td>
68
+ <td><strong>{user.username}</strong>{challenge_badge}</td>
69
+ <td style="text-align:center;color:#20d46c;font-weight:bold;">{user.score}</td>
70
+ <td style="text-align:center;">{time_display}</td>
71
+ <td style="text-align:center;">{difficulty}</td>
72
+ </tr>
73
+ """)
74
+
75
+ table_html = f"""
76
+ <style>
77
+ .lb-table {{
78
+ width: 100%;
79
+ border-collapse: collapse;
80
+ background: rgba(29, 100, 200, 0.2);
81
+ border-radius: 0.5rem;
82
+ overflow: hidden;
83
+ }}
84
+ .lb-table th {{
85
+ background: #1d64c8;
86
+ color: white;
87
+ padding: 0.75rem;
88
+ text-align: center;
89
+ }}
90
+ .lb-table td {{
91
+ padding: 0.5rem 0.75rem;
92
+ border-bottom: 1px solid rgba(255,255,255,0.1);
93
+ color: white;
94
+ }}
95
+ .lb-table tr:hover {{
96
+ background: rgba(29, 100, 200, 0.3);
97
+ }}
98
+ </style>
99
+ <table class="lb-table">
100
+ <thead>
101
+ <tr>
102
+ <th>Rank</th>
103
+ <th>Player</th>
104
+ <th>Score</th>
105
+ <th>Time</th>
106
+ <th>Difficulty</th>
107
+ </tr>
108
+ </thead>
109
+ <tbody>
110
+ {''.join(rows)}
111
+ </tbody>
112
+ </table>
113
+ """
114
+
115
+ st.markdown(table_html, unsafe_allow_html=True)
116
+
117
+ # Show entry count and last updated
118
+ total_entries = len(leaderboard.users)
119
+ if total_entries > MAX_DISPLAY_ENTRIES:
120
+ st.caption(f"Showing top {MAX_DISPLAY_ENTRIES} of {total_entries} entries οΏ½ Last updated: {leaderboard.created_at}")
121
+ else:
122
+ st.caption(f"{total_entries} entries οΏ½ Last updated: {leaderboard.created_at}")
123
+
124
+
125
+ def render_leaderboard_page():
126
+ """Render the full leaderboard page."""
127
+ game_title = APP_SETTINGS.get("game_title", "Wrdler")
128
+ st.title(f"?? {game_title} Leaderboards")
129
+
130
+ # Tab selection
131
+ tab1, tab2, tab3 = st.tabs(["?? Daily", "?? Weekly", "?? History"])
132
+
133
+ with tab1:
134
+ _render_daily_tab()
135
+
136
+ with tab2:
137
+ _render_weekly_tab()
138
+
139
+ with tab3:
140
+ _render_history_tab()
141
+
142
+
143
+ def _render_daily_tab():
144
+ """Render daily leaderboards tab."""
145
+ st.header("?? Daily Leaderboards")
146
+ st.write(f"Top {MAX_DISPLAY_ENTRIES} scores for each day. Resets at UTC midnight.")
147
+
148
+ # Get last 7 days
149
+ daily_boards = get_last_n_daily_leaderboards(7)
150
+
151
+ for date_id, leaderboard in daily_boards:
152
+ # Format date nicely
153
+ try:
154
+ date_obj = datetime.strptime(date_id, "%Y-%m-%d")
155
+ if date_id == get_current_daily_id():
156
+ title = f"?? Today ({date_obj.strftime('%B %d, %Y')})"
157
+ else:
158
+ title = date_obj.strftime("%A, %B %d, %Y")
159
+ except ValueError:
160
+ title = date_id
161
+
162
+ with st.expander(title, expanded=(date_id == get_current_daily_id())):
163
+ _render_leaderboard_table(leaderboard, "")
164
+
165
+
166
+ def _render_weekly_tab():
167
+ """Render weekly leaderboard tab."""
168
+ st.header("?? Weekly Leaderboard")
169
+ st.write(f"Top {MAX_DISPLAY_ENTRIES} scores for the current week. Resets Monday at UTC midnight.")
170
+
171
+ weekly_id = get_current_weekly_id()
172
+ leaderboard = load_leaderboard("weekly", weekly_id)
173
+
174
+ # Parse week for display
175
+ try:
176
+ year, week = weekly_id.split("-W")
177
+ title = f"Week {int(week)}, {year}"
178
+ except ValueError:
179
+ title = weekly_id
180
+
181
+ _render_leaderboard_table(leaderboard, f"?? {title}")
182
+
183
+
184
+ def _render_history_tab():
185
+ """Render historical leaderboards tab."""
186
+ st.header("?? Historical Leaderboards")
187
+ st.write("Look up past leaderboards.")
188
+
189
+ col1, col2 = st.columns(2)
190
+
191
+ with col1:
192
+ st.subheader("Daily History")
193
+ daily_ids = list_available_leaderboards("daily", limit=30)
194
+ selected_daily = st.selectbox(
195
+ "Select a date",
196
+ options=daily_ids,
197
+ key="history_daily_select"
198
+ )
199
+
200
+ if st.button("Load Daily", key="load_daily"):
201
+ leaderboard = load_leaderboard("daily", selected_daily)
202
+ _render_leaderboard_table(leaderboard, f"Daily: {selected_daily}")
203
+
204
+ with col2:
205
+ st.subheader("Weekly History")
206
+ weekly_ids = list_available_leaderboards("weekly", limit=20)
207
+ selected_weekly = st.selectbox(
208
+ "Select a week",
209
+ options=weekly_ids,
210
+ key="history_weekly_select"
211
+ )
212
+
213
+ if st.button("Load Weekly", key="load_weekly"):
214
+ leaderboard = load_leaderboard("weekly", selected_weekly)
215
+ _render_leaderboard_table(leaderboard, f"Weekly: {selected_weekly}")
216
+
217
+
218
+ # Entry point for standalone testing
219
+ if __name__ == "__main__":
220
+ st.set_page_config(
221
+ page_title="Wrdler Leaderboards",
222
+ page_icon="??",
223
+ layout="wide"
224
+ )
225
+ render_leaderboard_page()
wrdler/modules/constants.py CHANGED
@@ -1,12 +1,14 @@
1
- # battlewords/modules/constants.py
2
  """
3
  Storage-related constants for BattleWords.
4
  Trimmed version of OpenBadge constants - only includes what's needed for storage.py
5
  """
6
  import os
 
7
  import tempfile
8
  import logging
9
  from pathlib import Path
 
10
  from dotenv import load_dotenv
11
 
12
  # Load environment variables from .env file
@@ -22,7 +24,7 @@ HF_REPO_ID = os.getenv("HF_REPO_ID", "Surn/Storage")
22
  SPACE_NAME = os.getenv('SPACE_NAME', 'Surn/Wrdler')
23
  SHORTENER_JSON_FILE = "shortener.json"
24
  USE_HF_WORDS = os.getenv("USE_HF_WORDS", "false").lower() == "true"
25
- HF_WORD_LIST_REPO_ID= os.getenv("HF_WORD_LIST_REPO_ID", "ysharma/Chat_with_Meta_llama3_1_8b")
26
 
27
  # List of smaller, faster fallback models if the primary one fails
28
  AI_MODELS = [
@@ -34,7 +36,72 @@ AI_MODELS = [
34
  "NousResearch/Hermes-2-Pro-Llama-3-8B"
35
  ]
36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  # Temporary Directory Configuration
 
 
38
  try:
39
  if os.environ.get('TMPDIR'):
40
  TMPDIR = os.environ['TMPDIR']
 
1
+ # wrdler/modules/constants.py
2
  """
3
  Storage-related constants for BattleWords.
4
  Trimmed version of OpenBadge constants - only includes what's needed for storage.py
5
  """
6
  import os
7
+ import json
8
  import tempfile
9
  import logging
10
  from pathlib import Path
11
+ from typing import Dict, Any
12
  from dotenv import load_dotenv
13
 
14
  # Load environment variables from .env file
 
24
  SPACE_NAME = os.getenv('SPACE_NAME', 'Surn/Wrdler')
25
  SHORTENER_JSON_FILE = "shortener.json"
26
  USE_HF_WORDS = os.getenv("USE_HF_WORDS", "false").lower() == "true"
27
+ HF_WORD_LIST_REPO_ID = os.getenv("HF_WORD_LIST_REPO_ID", "ysharma/Chat_with_Meta_llama3_1_8b")
28
 
29
  # List of smaller, faster fallback models if the primary one fails
30
  AI_MODELS = [
 
36
  "NousResearch/Hermes-2-Pro-Llama-3-8B"
37
  ]
38
 
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Application Settings
42
+ # ---------------------------------------------------------------------------
43
+
44
+ def load_settings() -> Dict[str, Any]:
45
+ """
46
+ Load settings from settings.json file.
47
+
48
+ Looks for settings.json in the project root directory.
49
+ Returns merged defaults with any overrides from the file.
50
+ Environment variables can override specific settings (e.g., GAME_TITLE).
51
+
52
+ Returns:
53
+ Dict containing all application settings
54
+ """
55
+ # Path to settings.json in project root
56
+ settings_path = Path(__file__).parent.parent.parent / "settings.json"
57
+
58
+ default_settings = {
59
+ # Game identity
60
+ "game_title": os.getenv("GAME_TITLE", "Wrdler"),
61
+
62
+ # Display settings
63
+ "show_incorrect_guesses": True,
64
+ "enable_free_letters": False,
65
+ "show_challenge_links": True,
66
+
67
+ # Audio settings
68
+ "sound_effects_enabled": True,
69
+ "sound_effects_volume": 10,
70
+ "music_enabled": False,
71
+ "music_volume": 10,
72
+
73
+ # Game defaults
74
+ "default_wordlist": "classic.txt",
75
+ "default_game_mode": "classic",
76
+
77
+ # Grid configuration (Wrdler: 8x6)
78
+ "grid_rows": 6,
79
+ "grid_cols": 8,
80
+ "words_per_puzzle": 6,
81
+ "free_letters_count": 2,
82
+ "max_incorrect_guesses": 10,
83
+ }
84
+
85
+ try:
86
+ if settings_path.exists():
87
+ with open(settings_path, "r", encoding="utf-8") as f:
88
+ loaded = json.load(f)
89
+ # Merge with defaults (loaded settings override defaults)
90
+ return {**default_settings, **loaded}
91
+ except Exception:
92
+ pass
93
+
94
+ return default_settings
95
+
96
+
97
+ # Load settings at module level for easy import
98
+ APP_SETTINGS = load_settings()
99
+
100
+
101
+ # ---------------------------------------------------------------------------
102
  # Temporary Directory Configuration
103
+ # ---------------------------------------------------------------------------
104
+
105
  try:
106
  if os.environ.get('TMPDIR'):
107
  TMPDIR = os.environ['TMPDIR']
wrdler/ui.py CHANGED
@@ -28,6 +28,8 @@ from .audio import (
28
  play_sound_effect,
29
  )
30
  from .game_storage import load_game_from_sid, save_game_to_hf, get_shareable_url, add_user_result_to_game
 
 
31
 
32
  st.set_page_config(initial_sidebar_state="collapsed")
33
 
@@ -160,6 +162,49 @@ window.addEventListener('appinstalled', () => {
160
 
161
  CoordLike = Tuple[int, int]
162
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  def fig_to_pil_rgba(fig):
164
  canvas = FigureCanvas(fig)
165
  canvas.draw()
@@ -499,11 +544,18 @@ border-radius: 50% !important;
499
  position:relative;
500
  z-index: 1200;
501
  }
 
502
  .st-emotion-cache-18kf3ut, .stColumn.st-emotion-cache-116javk {padding-bottom:3px;}
503
 
504
  /* grid adjustments */
 
 
 
 
 
 
505
  @media (min-width: 560px){
506
- div[data-testid="stButton"] button { max-width: 100%; aspect-ratio: 16 / 11; min-height: 1.75rem; display: flex;;}
507
  .st-key-new_game_btn > div[data-testid="stButton"] button, .st-key-sort_wordlist_btn > div[data-testid="stButton"] button { min-height: calc(100% + 20px) !important;}
508
  .st-emotion-cache-ckafi0 { gap:0.1rem !important; min-height: calc(100% + 20px) !important; aspect-ratio: 16 / 11; width: auto !important; flex: 1 1 auto !important; min-width: 100% !important; max-width: 100% !important;}
509
  /*.st-emotion-cache-1n6tfoc { aspect-ratio: 16 / 11; min-height: calc(100% + 20px) !important;}*/
@@ -513,9 +565,8 @@ border-radius: 50% !important;
513
  position:relative;
514
  z-index: 1200;
515
  }
516
-
517
-
518
  }
 
519
  div[data-testid="stElementToolbarButtonContainer"], button[data-testid="stBaseButton-elementToolbar"], button[data-testid="stBaseButton-elementToolbar"]:hover {
520
  display: none;
521
  }
@@ -604,6 +655,11 @@ def _init_session() -> None:
604
  # Check if we're loading a shared game
605
  shared_settings = st.session_state.get("shared_game_settings")
606
 
 
 
 
 
 
607
  # Ensure a default selection exists before creating the puzzle
608
  files = get_wordlist_files()
609
  if "selected_wordlist" not in st.session_state and files:
@@ -736,6 +792,13 @@ def _new_game() -> None:
736
  st.session_state.incorrect_guesses = []
737
  st.session_state.free_letters = set()
738
  st.session_state.free_letters_used = 0
 
 
 
 
 
 
 
739
 
740
  # Preserve preferences - but do NOT set widget-bound keys like game_mode
741
  # game_mode is managed by the selectbox widget in _render_sidebar()
@@ -776,7 +839,9 @@ def _sync_back(state: GameState) -> None:
776
 
777
 
778
  def _render_header():
779
- st.title(f"Wrdler v{version}")
 
 
780
 
781
  # Update subtitle based on free letters setting
782
  if st.session_state.get("enable_free_letters", False):
@@ -1740,7 +1805,7 @@ def _game_over_content(state: GameState) -> None:
1740
  /*filter: invert(1);*/
1741
  }
1742
  .st-bb {background-color: rgba(29, 100, 200, 0.5);}
1743
- .st-key-generate_share_link div[data-testid="stButton"] button { aspect-ratio: auto;}
1744
  .st-key-generate_share_link div[data-testid="stButton"] button:hover { color: #1d64c8;}
1745
  </style>
1746
  """,
@@ -1845,6 +1910,33 @@ def _game_over_content(state: GameState) -> None:
1845
  else:
1846
  username = "Anonymous"
1847
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1848
  # Check if share URL already generated
1849
  if "share_url" not in st.session_state or st.session_state.get("share_url") is None:
1850
  button_text = "πŸ“Š Submit Your Result" if is_shared_game else "πŸ”— Generate Share Link"
@@ -1872,6 +1964,17 @@ def _game_over_content(state: GameState) -> None:
1872
  st.session_state["share_url"] = share_url
1873
  st.session_state["share_sid"] = existing_sid
1874
  st.session_state["show_challenge_share_links"] = True
 
 
 
 
 
 
 
 
 
 
 
1875
  st.success(f"βœ… Result submitted for {username}!")
1876
  st.rerun()
1877
  else:
@@ -1886,7 +1989,7 @@ def _game_over_content(state: GameState) -> None:
1886
  score=state.score,
1887
  time_seconds=elapsed_seconds,
1888
  game_mode=state.game_mode,
1889
- grid_size=6, # Wrdler:6 rows (8 columns)
1890
  spacer=spacer,
1891
  may_overlap=may_overlap,
1892
  wordlist_source=wordlist_source
@@ -1896,13 +1999,54 @@ def _game_over_content(state: GameState) -> None:
1896
  share_url = get_shareable_url(sid)
1897
  st.session_state["share_url"] = share_url
1898
  st.session_state["share_sid"] = sid
1899
- st.session_state["show_challenge_share_links"] = True
 
 
 
 
 
 
 
 
 
 
 
1900
  st.rerun()
1901
  else:
1902
  st.error("Failed to generate short URL")
1903
-
1904
  except Exception as e:
1905
- st.error(f"Failed to save game: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1906
  else:
1907
  # Conditionally display the generated share URL
1908
  if st.session_state.get("show_challenge_share_links", False):
@@ -1929,9 +2073,9 @@ def _game_over_content(state: GameState) -> None:
1929
  margin-top:6px;
1930
  justify-content:center;
1931
  ">
1932
-
1933
  <strong><a href="{_share_url_attr}"
1934
- target="_blank"
1935
  rel="noopener noreferrer"
1936
  style="text-decoration: underline; color: #fff; word-break: break-all; filter: drop-shadow(1px 1px 2px #003);">
1937
  {_share_url_text}
@@ -1941,17 +2085,35 @@ def _game_over_content(state: GameState) -> None:
1941
  height=80
1942
  )
1943
  else:
1944
- # Do not display the share URL, but confirm it’s saved/submitted
1945
  st.success("βœ… Your result has been saved.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1946
 
1947
  st.markdown("---")
1948
 
1949
  # Dialog actions
1950
  if st.button("Close", key="close_game_over"):
1951
- st.session_state["hide_gameover_overlay"] = True # Changed from show_gameover_overlay = False
1952
  st.session_state["remount_background_audio"] = True
1953
  st.rerun()
1954
 
 
1955
  # Prefer st.dialog/experimental_dialog; fallback to st.modal if unavailable
1956
  _Dialog = getattr(st, "dialog", getattr(st, "experimental_dialog", None))
1957
  if _Dialog:
@@ -2029,6 +2191,9 @@ def run_app():
2029
  st.session_state["loaded_game_sid"] = sid # Store sid for adding results later
2030
  st.session_state["shared_game_loaded"] = True
2031
 
 
 
 
2032
  # Get best score and time from users array
2033
  users = settings.get("users", [])
2034
  if users:
@@ -2097,7 +2262,7 @@ def _on_game_option_change() -> None:
2097
  # st.query_params may be a Mapping; pop safely if supported
2098
  try:
2099
  if "game_id" in qp:
2100
- qp.pop("game_id")
2101
  except Exception:
2102
  # Fallback: clear all params if pop not supported
2103
  try:
@@ -2107,7 +2272,6 @@ def _on_game_option_change() -> None:
2107
  except Exception:
2108
  pass
2109
 
2110
-
2111
  # Clear challenge session flags and links
2112
  if st.session_state.get("loaded_game_sid") is not None:
2113
  st.session_state.loaded_game_sid = None
 
28
  play_sound_effect,
29
  )
30
  from .game_storage import load_game_from_sid, save_game_to_hf, get_shareable_url, add_user_result_to_game
31
+ from .modules.constants import APP_SETTINGS
32
+ from .leaderboard import submit_score_to_all_leaderboards
33
 
34
  st.set_page_config(initial_sidebar_state="collapsed")
35
 
 
162
 
163
  CoordLike = Tuple[int, int]
164
 
165
+ def _get_effective_game_title() -> str:
166
+ """
167
+ Get the effective game title, prioritizing:
168
+ 1. Challenge-specific game_title from shared_game_settings
169
+ 2. Session state game_title (if set)
170
+ 3. APP_SETTINGS default
171
+ 4. Fallback to "Wrdler"
172
+ """
173
+ # First check shared game settings (challenge mode)
174
+ shared_settings = st.session_state.get("shared_game_settings")
175
+ if shared_settings and shared_settings.get("game_title"):
176
+ return shared_settings["game_title"]
177
+
178
+ # Then check session state
179
+ if st.session_state.get("game_title"):
180
+ return st.session_state["game_title"]
181
+
182
+ # Fall back to APP_SETTINGS
183
+ return APP_SETTINGS.get("game_title", "Wrdler")
184
+
185
+
186
+ def _apply_challenge_settings(settings: dict) -> None:
187
+ """
188
+ Apply challenge-specific settings from loaded shared game to session state.
189
+ This ensures the UI respects settings like show_incorrect_guesses,
190
+ enable_free_letters, and game_title from the challenge creator.
191
+
192
+ Args:
193
+ settings: Dictionary of settings from load_game_from_sid()
194
+ """
195
+ # Apply show_incorrect_guesses if present in challenge
196
+ if "show_incorrect_guesses" in settings:
197
+ st.session_state.show_incorrect_guesses = settings["show_incorrect_guesses"]
198
+
199
+ # Apply enable_free_letters if present in challenge
200
+ if "enable_free_letters" in settings:
201
+ st.session_state.enable_free_letters = settings["enable_free_letters"]
202
+
203
+ # Store game_title in session state if present
204
+ if "game_title" in settings:
205
+ st.session_state.game_title = settings["game_title"]
206
+
207
+
208
  def fig_to_pil_rgba(fig):
209
  canvas = FigureCanvas(fig)
210
  canvas.draw()
 
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 */
551
+ # @media (max-width: 705px){
552
+ # .bw-cell {
553
+ # min-height: 2.5rem;
554
+ # min-width: 1.75rem;
555
+ # }
556
+ # }
557
  @media (min-width: 560px){
558
+ div[data-testid="stButton"] button { max-width: 100%; aspect-ratio: 16 / 11; min-height: 1.75rem; display: flex;}
559
  .st-key-new_game_btn > div[data-testid="stButton"] button, .st-key-sort_wordlist_btn > div[data-testid="stButton"] button { min-height: calc(100% + 20px) !important;}
560
  .st-emotion-cache-ckafi0 { gap:0.1rem !important; min-height: calc(100% + 20px) !important; aspect-ratio: 16 / 11; width: auto !important; flex: 1 1 auto !important; min-width: 100% !important; max-width: 100% !important;}
561
  /*.st-emotion-cache-1n6tfoc { aspect-ratio: 16 / 11; min-height: calc(100% + 20px) !important;}*/
 
565
  position:relative;
566
  z-index: 1200;
567
  }
 
 
568
  }
569
+
570
  div[data-testid="stElementToolbarButtonContainer"], button[data-testid="stBaseButton-elementToolbar"], button[data-testid="stBaseButton-elementToolbar"]:hover {
571
  display: none;
572
  }
 
655
  # Check if we're loading a shared game
656
  shared_settings = st.session_state.get("shared_game_settings")
657
 
658
+ # Apply challenge-specific settings before initializing defaults
659
+ # This ensures show_incorrect_guesses, enable_free_letters, game_title are set from challenge
660
+ if shared_settings:
661
+ _apply_challenge_settings(shared_settings)
662
+
663
  # Ensure a default selection exists before creating the puzzle
664
  files = get_wordlist_files()
665
  if "selected_wordlist" not in st.session_state and files:
 
792
  st.session_state.incorrect_guesses = []
793
  st.session_state.free_letters = set()
794
  st.session_state.free_letters_used = 0
795
+
796
+ # Reset share/leaderboard state for new game
797
+ st.session_state.pop("share_url", None)
798
+ st.session_state.pop("share_sid", None)
799
+ st.session_state.pop("leaderboard_submitted", None)
800
+ st.session_state.pop("leaderboard_results", None)
801
+ st.session_state.pop("hide_gameover_overlay", None)
802
 
803
  # Preserve preferences - but do NOT set widget-bound keys like game_mode
804
  # game_mode is managed by the selectbox widget in _render_sidebar()
 
839
 
840
 
841
  def _render_header():
842
+ # Use dynamic game title (from challenge settings or APP_SETTINGS)
843
+ game_title = _get_effective_game_title()
844
+ st.title(f"{game_title} v{version}")
845
 
846
  # Update subtitle based on free letters setting
847
  if st.session_state.get("enable_free_letters", False):
 
1805
  /*filter: invert(1);*/
1806
  }
1807
  .st-bb {background-color: rgba(29, 100, 200, 0.5);}
1808
+ .st-key-generate_share_link div[data-testid="stButton"] button, .st-key-submit_leaderboard_only div[data-testid="stButton"] button { aspect-ratio: auto;}
1809
  .st-key-generate_share_link div[data-testid="stButton"] button:hover { color: #1d64c8;}
1810
  </style>
1811
  """,
 
1910
  else:
1911
  username = "Anonymous"
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
+ from .leaderboard import GameSettings, submit_score_to_all_leaderboards
1917
+
1918
+ settings = GameSettings(
1919
+ game_mode=state.game_mode,
1920
+ wordlist_source=st.session_state.get("selected_wordlist", "classic.txt"),
1921
+ show_incorrect_guesses=st.session_state.get("show_incorrect_guesses", True),
1922
+ enable_free_letters=st.session_state.get("enable_free_letters", False),
1923
+ puzzle_options={
1924
+ "spacer": getattr(state.puzzle, "spacer", 1),
1925
+ "may_overlap": getattr(state.puzzle, "may_overlap", False)
1926
+ }
1927
+ )
1928
+
1929
+ results = submit_score_to_all_leaderboards(
1930
+ username=username,
1931
+ score=score,
1932
+ time_seconds=time_secs,
1933
+ word_list=word_list,
1934
+ settings=settings,
1935
+ word_list_difficulty=difficulty_value,
1936
+ source_challenge_id=challenge_id
1937
+ )
1938
+ return results
1939
+
1940
  # Check if share URL already generated
1941
  if "share_url" not in st.session_state or st.session_state.get("share_url") is None:
1942
  button_text = "πŸ“Š Submit Your Result" if is_shared_game else "πŸ”— Generate Share Link"
 
1964
  st.session_state["share_url"] = share_url
1965
  st.session_state["share_sid"] = existing_sid
1966
  st.session_state["show_challenge_share_links"] = True
1967
+
1968
+ # Also submit to daily/weekly leaderboards
1969
+ try:
1970
+ lb_results = _submit_to_leaderboards(
1971
+ username, state.score, elapsed_seconds, word_list, existing_sid
1972
+ )
1973
+ st.session_state["leaderboard_submitted"] = True
1974
+ st.session_state["leaderboard_results"] = lb_results
1975
+ except Exception as lb_err:
1976
+ st.warning(f"Challenge submitted but leaderboard update failed: {lb_err}")
1977
+
1978
  st.success(f"βœ… Result submitted for {username}!")
1979
  st.rerun()
1980
  else:
 
1989
  score=state.score,
1990
  time_seconds=elapsed_seconds,
1991
  game_mode=state.game_mode,
1992
+ grid_size=6, # Wrdler: 6 rows (8 columns)
1993
  spacer=spacer,
1994
  may_overlap=may_overlap,
1995
  wordlist_source=wordlist_source
 
1999
  share_url = get_shareable_url(sid)
2000
  st.session_state["share_url"] = share_url
2001
  st.session_state["share_sid"] = sid
2002
+ st.session_state["show_challenge_share_links"] = True
2003
+
2004
+ # Also submit to daily/weekly leaderboards
2005
+ try:
2006
+ lb_results = _submit_to_leaderboards(
2007
+ username, state.score, elapsed_seconds, word_list, sid
2008
+ )
2009
+ st.session_state["leaderboard_submitted"] = True
2010
+ st.session_state["leaderboard_results"] = lb_results
2011
+ except Exception as lb_err:
2012
+ st.warning(f"Challenge created but leaderboard update failed: {lb_err}")
2013
+
2014
  st.rerun()
2015
  else:
2016
  st.error("Failed to generate short URL")
2017
+
2018
  except Exception as e:
2019
+ st.error(f"Failed to save game: {e}")
2020
+
2021
+ # Show separate "Submit to Leaderboard" button when NOT in a challenge
2022
+ if not is_shared_game and not st.session_state.get("leaderboard_submitted", False):
2023
+ st.markdown("---")
2024
+ st.markdown("##### πŸ† Or just submit to leaderboards")
2025
+ if st.button("πŸ† Submit to Leaderboards", key="submit_leaderboard_only", use_container_width=True):
2026
+ try:
2027
+ word_list = [w.text for w in state.puzzle.words]
2028
+ lb_results = _submit_to_leaderboards(
2029
+ username, state.score, elapsed_seconds, word_list
2030
+ )
2031
+ st.session_state["leaderboard_submitted"] = True
2032
+ st.session_state["leaderboard_results"] = lb_results
2033
+
2034
+ # Show results
2035
+ daily_info = lb_results.get("daily", {})
2036
+ weekly_info = lb_results.get("weekly", {})
2037
+
2038
+ if daily_info.get("qualified") or weekly_info.get("qualified"):
2039
+ msg_parts = []
2040
+ if daily_info.get("qualified"):
2041
+ msg_parts.append(f"Daily #{daily_info['rank']}")
2042
+ if weekly_info.get("qualified"):
2043
+ msg_parts.append(f"Weekly #{weekly_info['rank']}")
2044
+ st.success(f"βœ… Submitted! Ranked: {', '.join(msg_parts)}")
2045
+ else:
2046
+ st.info("βœ… Score submitted (not in top 20)")
2047
+ st.rerun()
2048
+ except Exception as e:
2049
+ st.error(f"Failed to submit to leaderboards: {e}")
2050
  else:
2051
  # Conditionally display the generated share URL
2052
  if st.session_state.get("show_challenge_share_links", False):
 
2073
  margin-top:6px;
2074
  justify-content:center;
2075
  ">
2076
+
2077
  <strong><a href="{_share_url_attr}"
2078
+ target="_blank"
2079
  rel="noopener noreferrer"
2080
  style="text-decoration: underline; color: #fff; word-break: break-all; filter: drop-shadow(1px 1px 2px #003);">
2081
  {_share_url_text}
 
2085
  height=80
2086
  )
2087
  else:
2088
+ # Do not display the share URL, but confirm it's saved/submitted
2089
  st.success("βœ… Your result has been saved.")
2090
+
2091
+ # Show leaderboard submission status if already submitted
2092
+ if st.session_state.get("leaderboard_submitted", False):
2093
+ lb_results = st.session_state.get("leaderboard_results", {})
2094
+ daily_info = lb_results.get("daily", {})
2095
+ weekly_info = lb_results.get("weekly", {})
2096
+
2097
+ status_parts = []
2098
+ if daily_info.get("qualified"):
2099
+ status_parts.append(f"Daily #{daily_info['rank']}")
2100
+ if weekly_info.get("qualified"):
2101
+ status_parts.append(f"Weekly #{weekly_info['rank']}")
2102
+
2103
+ if status_parts:
2104
+ st.info(f"πŸ† Leaderboard: {', '.join(status_parts)}")
2105
+ else:
2106
+ st.info("πŸ† Submitted to leaderboards")
2107
 
2108
  st.markdown("---")
2109
 
2110
  # Dialog actions
2111
  if st.button("Close", key="close_game_over"):
2112
+ st.session_state["hide_gameover_overlay"] = True
2113
  st.session_state["remount_background_audio"] = True
2114
  st.rerun()
2115
 
2116
+
2117
  # Prefer st.dialog/experimental_dialog; fallback to st.modal if unavailable
2118
  _Dialog = getattr(st, "dialog", getattr(st, "experimental_dialog", None))
2119
  if _Dialog:
 
2191
  st.session_state["loaded_game_sid"] = sid # Store sid for adding results later
2192
  st.session_state["shared_game_loaded"] = True
2193
 
2194
+ # Apply challenge-specific settings (show_incorrect_guesses, enable_free_letters, game_title)
2195
+ _apply_challenge_settings(settings)
2196
+
2197
  # Get best score and time from users array
2198
  users = settings.get("users", [])
2199
  if users:
 
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:
 
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