Leaderboard - Storage strategy Simplification
Browse files- specs/leaderboard_spec.md +176 -894
- tests/test_leaderboard.py +119 -23
- wrdler/leaderboard.py +245 -72
- wrdler/leaderboard_page.py +94 -37
- wrdler/modules/storage.md +46 -5
- wrdler/modules/storage.py +103 -12
specs/leaderboard_spec.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
# Wrdler Leaderboard System Specification
|
| 2 |
|
| 3 |
-
**Document Version:** 1.
|
| 4 |
**Target Project Version:** 0.2.0
|
| 5 |
**Author:** GitHub Copilot
|
| 6 |
**Date:** 2025-01-27
|
|
@@ -35,6 +35,7 @@ This specification describes the implementation of a **Daily and Weekly Leaderbo
|
|
| 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 |
---
|
|
@@ -45,9 +46,9 @@ This specification describes the implementation of a **Daily and Weekly Leaderbo
|
|
| 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 |
|
|
@@ -56,7 +57,9 @@ This specification describes the implementation of a **Daily and Weekly Leaderbo
|
|
| 56 |
- Current weekly leaderboard (filtered by current settings)
|
| 57 |
- Historical lookup via dropdown
|
| 58 |
|
| 59 |
-
6. **
|
|
|
|
|
|
|
| 60 |
|
| 61 |
### Secondary Goals
|
| 62 |
|
|
@@ -85,74 +88,69 @@ The following settings define a unique leaderboard:
|
|
| 85 |
|
| 86 |
### 3.1 Storage Structure
|
| 87 |
|
| 88 |
-
Each date/week
|
| 89 |
|
| 90 |
```
|
| 91 |
HF_REPO_ID/
|
| 92 |
-
??? games/ #
|
| 93 |
-
? ??? {challenge_id}/
|
| 94 |
-
?
|
| 95 |
-
??? leaderboards/
|
| 96 |
-
?
|
| 97 |
-
?
|
| 98 |
-
?
|
| 99 |
-
? ? ???
|
| 100 |
-
?
|
| 101 |
-
? ?
|
| 102 |
-
?
|
| 103 |
-
?
|
| 104 |
-
?
|
| 105 |
-
?
|
| 106 |
-
?
|
| 107 |
-
?
|
| 108 |
-
? ? ???
|
| 109 |
-
?
|
| 110 |
-
?
|
|
|
|
|
|
|
|
|
|
| 111 |
??? shortener.json # Existing URL shortener
|
| 112 |
```
|
| 113 |
|
| 114 |
-
### 3.2
|
| 115 |
|
| 116 |
-
The `
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
|
| 157 |
```
|
| 158 |
??????????????????????
|
|
@@ -167,33 +165,40 @@ The `index.json` file maps settings combinations to folder identifiers for fast
|
|
| 167 |
?
|
| 168 |
?
|
| 169 |
??????????????????????
|
| 170 |
-
?
|
| 171 |
-
?
|
| 172 |
-
?
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
---
|
|
@@ -207,8 +212,8 @@ The `entry_type` field distinguishes between different types of game entries:
|
|
| 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}
|
| 211 |
-
| `"weekly"` | Weekly leaderboard entry | `leaderboards/weekly/{week}
|
| 212 |
|
| 213 |
### 4.2 Unified File Schema (Consistent with Challenge settings.json)
|
| 214 |
|
|
@@ -216,7 +221,7 @@ Both leaderboard files and challenge files use the **same base structure**. The
|
|
| 216 |
|
| 217 |
```json
|
| 218 |
{
|
| 219 |
-
"challenge_id": "2025-01-27-0",
|
| 220 |
"entry_type": "daily",
|
| 221 |
"game_mode": "classic",
|
| 222 |
"grid_size": 8,
|
|
@@ -250,7 +255,7 @@ Both leaderboard files and challenge files use the **same base structure**. The
|
|
| 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) |
|
|
@@ -296,7 +301,7 @@ Each user entry in the `users` array:
|
|
| 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`
|
|
@@ -315,762 +320,26 @@ Uses ISO 8601 week numbering:
|
|
| 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 |
-
|
| 1034 |
-
|
| 1035 |
-
|
| 1036 |
-
|
| 1037 |
-
)
|
| 1038 |
-
|
| 1039 |
-
|
| 1040 |
|
| 1041 |
-
|
| 1042 |
-
entry_type: "daily" or "weekly"
|
| 1043 |
-
limit: Maximum number of IDs to return
|
| 1044 |
-
repo_id: Repository ID
|
| 1045 |
|
| 1046 |
-
|
| 1047 |
-
|
| 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 |
|
|
@@ -1081,8 +350,8 @@ def list_settings_for_period(
|
|
| 1081 |
| Step | Task | Files | Effort |
|
| 1082 |
|------|------|-------|--------|
|
| 1083 |
| 1.1 | Create `wrdler/leaderboard.py` with `GameSettings` and data models | NEW | 2h |
|
| 1084 |
-
| 1.2 | Implement
|
| 1085 |
-
| 1.3 | Implement `find_matching_leaderboard()`
|
| 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 |
|
|
@@ -1152,6 +421,12 @@ __version__ = "0.2.0"
|
|
| 1152 |
__version__ = "0.2.0"
|
| 1153 |
```
|
| 1154 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1155 |
---
|
| 1156 |
|
| 1157 |
## 8. File Changes Summary
|
|
@@ -1160,7 +435,7 @@ __version__ = "0.2.0"
|
|
| 1160 |
|
| 1161 |
| File | Purpose |
|
| 1162 |
|------|---------|
|
| 1163 |
-
| `wrdler/leaderboard.py` | Core leaderboard logic with
|
| 1164 |
| `wrdler/leaderboard_page.py` | Streamlit leaderboard page |
|
| 1165 |
| `tests/test_leaderboard.py` | Unit tests for leaderboard |
|
| 1166 |
| `specs/leaderboard_spec.md` | This specification |
|
|
@@ -1171,6 +446,7 @@ __version__ = "0.2.0"
|
|
| 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 |
|
|
@@ -1186,9 +462,8 @@ 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
|
|
@@ -1198,10 +473,18 @@ def submit_score_to_all_leaderboards(
|
|
| 1198 |
def load_leaderboard(
|
| 1199 |
entry_type: EntryType,
|
| 1200 |
period_id: str,
|
| 1201 |
-
file_id:
|
| 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,
|
|
@@ -1210,11 +493,25 @@ def get_last_n_daily_leaderboards(
|
|
| 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
|
| 1215 |
|
| 1216 |
def get_current_weekly_id() -> str:
|
| 1217 |
-
"""Get this week's
|
| 1218 |
```
|
| 1219 |
|
| 1220 |
---
|
|
@@ -1256,7 +553,7 @@ In `run_app()`:
|
|
| 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("
|
| 1260 |
st.session_state["show_leaderboard_page"] = False
|
| 1261 |
st.rerun()
|
| 1262 |
return # Don't render game UI
|
|
@@ -1280,6 +577,17 @@ class TestLeaderboardSettings:
|
|
| 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): ...
|
|
@@ -1291,11 +599,8 @@ class TestQualification:
|
|
| 1291 |
class TestDateIds:
|
| 1292 |
def test_daily_id_format(self): ...
|
| 1293 |
def test_weekly_id_format(self): ...
|
| 1294 |
-
|
| 1295 |
-
|
| 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 |
|
|
@@ -1303,6 +608,7 @@ class TestUnifiedFormat:
|
|
| 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 |
|
|
@@ -1315,6 +621,7 @@ class TestUnifiedFormat:
|
|
| 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 |
|
|
@@ -1322,11 +629,13 @@ class TestUnifiedFormat:
|
|
| 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
|
| 1330 |
- Legacy challenges without `entry_type` default to `"challenge"`
|
| 1331 |
|
| 1332 |
### Rollback Plan
|
|
@@ -1334,15 +643,17 @@ class TestUnifiedFormat:
|
|
| 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 (
|
|
|
|
|
|
|
| 1342 |
|
| 1343 |
```json
|
| 1344 |
{
|
| 1345 |
-
"challenge_id": "2025-01-27-0",
|
| 1346 |
"entry_type": "daily",
|
| 1347 |
"game_mode": "classic",
|
| 1348 |
"grid_size": 8,
|
|
@@ -1374,44 +685,14 @@ class TestUnifiedFormat:
|
|
| 1374 |
|
| 1375 |
---
|
| 1376 |
|
| 1377 |
-
## Appendix B:
|
| 1378 |
|
| 1379 |
-
|
| 1380 |
-
|
| 1381 |
-
|
| 1382 |
-
|
| 1383 |
-
|
| 1384 |
-
|
| 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 |
|
|
@@ -1419,12 +700,13 @@ class TestUnifiedFormat:
|
|
| 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}
|
| 1425 |
| Reset frequency | Daily (UTC midnight) | Weekly (Monday UTC midnight) | Never (permanent) |
|
| 1426 |
-
| Settings-based | Yes (
|
| 1427 |
| `max_display_entries` | 20 | 20 | N/A (all users shown) |
|
|
|
|
| 1428 |
|
| 1429 |
---
|
| 1430 |
|
|
|
|
| 1 |
# Wrdler Leaderboard System Specification
|
| 2 |
|
| 3 |
+
**Document Version:** 1.3.0
|
| 4 |
**Target Project Version:** 0.2.0
|
| 5 |
**Author:** GitHub Copilot
|
| 6 |
**Date:** 2025-01-27
|
|
|
|
| 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 folder-based discovery (no index.json) with descriptive folder names**
|
| 39 |
- **Use a unified JSON format consistent with existing challenge settings.json files**
|
| 40 |
|
| 41 |
---
|
|
|
|
| 46 |
|
| 47 |
1. **Settings-Based Leaderboards**: Each unique combination of game-affecting settings creates a separate leaderboard. Players using different settings compete on different leaderboards.
|
| 48 |
|
| 49 |
+
2. **Daily Leaderboards**: Create and maintain daily leaderboards with top 20 entries displayed (can store more), organized by date folders (e.g., `games/leaderboards/daily/2025-01-27/`)
|
| 50 |
|
| 51 |
+
3. **Weekly Leaderboards**: Create and maintain weekly leaderboards with top 20 entries displayed (can store more), organized by ISO week folders (e.g., `games/leaderboards/weekly/2025-W04/`)
|
| 52 |
|
| 53 |
4. **Automatic Qualification**: Every game completion (challenge or solo) checks if score qualifies for the matching daily/weekly leaderboard based on current settings
|
| 54 |
|
|
|
|
| 57 |
- Current weekly leaderboard (filtered by current settings)
|
| 58 |
- Historical lookup via dropdown
|
| 59 |
|
| 60 |
+
6. **Folder-Based Discovery**: No index.json file. Leaderboards are discovered by scanning folder names. Folder names include settings info for fast filtering.
|
| 61 |
+
|
| 62 |
+
7. **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
|
| 63 |
|
| 64 |
### Secondary Goals
|
| 65 |
|
|
|
|
| 88 |
|
| 89 |
### 3.1 Storage Structure
|
| 90 |
|
| 91 |
+
Each date/week has settings-based subfolders. The folder name (file_id) encodes the settings for fast discovery. All leaderboards use `settings.json` as the filename (consistent with challenges):
|
| 92 |
|
| 93 |
```
|
| 94 |
HF_REPO_ID/
|
| 95 |
+
??? games/ # All game-related storage
|
| 96 |
+
? ??? {challenge_id}/ # Existing challenge storage
|
| 97 |
+
? ? ??? settings.json # entry_type: "challenge"
|
| 98 |
+
? ??? leaderboards/
|
| 99 |
+
? ??? daily/
|
| 100 |
+
? ? ??? 2025-01-27/
|
| 101 |
+
? ? ? ??? classic-classic-0/
|
| 102 |
+
? ? ? ? ??? settings.json
|
| 103 |
+
? ? ? ??? easy-easy-0/
|
| 104 |
+
? ? ? ??? settings.json
|
| 105 |
+
? ? ??? 2025-01-26/
|
| 106 |
+
? ? ??? classic-classic-0/
|
| 107 |
+
? ? ??? settings.json
|
| 108 |
+
? ??? weekly/
|
| 109 |
+
? ??? 2025-W04/
|
| 110 |
+
? ? ??? classic-classic-0/
|
| 111 |
+
? ? ? ??? settings.json
|
| 112 |
+
? ? ??? easy-too_easy-0/
|
| 113 |
+
? ? ??? settings.json
|
| 114 |
+
? ??? 2025-W03/
|
| 115 |
+
? ??? classic-classic-0/
|
| 116 |
+
? ??? settings.json
|
| 117 |
??? shortener.json # Existing URL shortener
|
| 118 |
```
|
| 119 |
|
| 120 |
+
### 3.2 File ID Format
|
| 121 |
|
| 122 |
+
The `file_id` (folder name) encodes settings for discovery without an index:
|
| 123 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
```
|
| 125 |
+
{wordlist_source}-{game_mode}-{sequence}
|
| 126 |
+
```
|
| 127 |
+
|
| 128 |
+
**Examples:**
|
| 129 |
+
- `classic-classic-0` - Classic wordlist, classic mode, first instance
|
| 130 |
+
- `easy-easy-0` - Easy wordlist, easy mode, first instance
|
| 131 |
+
- `classic-too_easy-1` - Classic wordlist, "too easy" mode, second instance
|
| 132 |
+
|
| 133 |
+
**Sanitization Rules:**
|
| 134 |
+
- `.txt` extension is removed from wordlist_source
|
| 135 |
+
- Spaces are replaced with underscores
|
| 136 |
+
- All lowercase
|
| 137 |
+
|
| 138 |
+
### 3.3 Folder-Based Discovery
|
| 139 |
|
| 140 |
+
Instead of maintaining an `index.json` file, leaderboards are discovered by:
|
| 141 |
+
|
| 142 |
+
1. **List period folders**: Scan `games/leaderboards/daily/` or `games/leaderboards/weekly/` for date/week folders
|
| 143 |
+
2. **List file_id folders**: For each period, scan for settings folders
|
| 144 |
+
3. **Filter by prefix**: Match file_ids that start with `{wordlist_source}-{game_mode}-`
|
| 145 |
+
4. **Load and verify**: Load `settings.json` to verify full settings match
|
| 146 |
+
|
| 147 |
+
**Benefits:**
|
| 148 |
+
- No index synchronization issues
|
| 149 |
+
- Self-documenting folder structure
|
| 150 |
+
- Can browse folders directly
|
| 151 |
+
- Reduced write operations (no index updates)
|
| 152 |
+
|
| 153 |
+
### 3.4 Data Flow
|
| 154 |
|
| 155 |
```
|
| 156 |
??????????????????????
|
|
|
|
| 165 |
?
|
| 166 |
?
|
| 167 |
??????????????????????
|
| 168 |
+
? Build file_id ?
|
| 169 |
+
? prefix from ?
|
| 170 |
+
? settings ?
|
| 171 |
+
??????????????????????
|
| 172 |
+
?
|
| 173 |
+
?
|
| 174 |
+
??????????????????????
|
| 175 |
+
? Scan folders for ?
|
| 176 |
+
? matching file_id ?
|
| 177 |
+
? or create new ?
|
| 178 |
??????????????????????
|
| 179 |
?
|
| 180 |
?????????????
|
| 181 |
? ?
|
| 182 |
? ?
|
| 183 |
+
????????? ?????????????
|
| 184 |
+
? Daily ? ? Weekly ?
|
| 185 |
+
? LB ? ? LB ?
|
| 186 |
+
????????? ?????????????
|
| 187 |
? ?
|
| 188 |
????????????
|
| 189 |
?
|
| 190 |
?
|
| 191 |
+
???????????????????????
|
| 192 |
+
? Check if score ?
|
| 193 |
+
? qualifies (top ?
|
| 194 |
+
? 20 displayed) ?
|
| 195 |
+
???????????????????????
|
| 196 |
?
|
| 197 |
?
|
| 198 |
+
???????????????????????
|
| 199 |
+
? Update & Upload ?
|
| 200 |
+
? to HF repo ?
|
| 201 |
+
???????????????????????
|
| 202 |
```
|
| 203 |
|
| 204 |
---
|
|
|
|
| 212 |
| entry_type | Description | Storage Location |
|
| 213 |
|------------|-------------|------------------|
|
| 214 |
| `"challenge"` | Player-created challenge for others to compete | `games/{challenge_id}/settings.json` |
|
| 215 |
+
| `"daily"` | Daily leaderboard entry | `games/leaderboards/daily/{date}/{file_id}/settings.json` |
|
| 216 |
+
| `"weekly"` | Weekly leaderboard entry | `games/leaderboards/weekly/{week}/{file_id}/settings.json` |
|
| 217 |
|
| 218 |
### 4.2 Unified File Schema (Consistent with Challenge settings.json)
|
| 219 |
|
|
|
|
| 221 |
|
| 222 |
```json
|
| 223 |
{
|
| 224 |
+
"challenge_id": "2025-01-27/classic-classic-0",
|
| 225 |
"entry_type": "daily",
|
| 226 |
"game_mode": "classic",
|
| 227 |
"grid_size": 8,
|
|
|
|
| 255 |
|
| 256 |
| Field | Type | Description |
|
| 257 |
|-------|------|-------------|
|
| 258 |
+
| `challenge_id` | string | Unique identifier. For daily: `"2025-01-27/classic-classic-0"`, weekly: `"2025-W04/easy-easy-0"`, challenge: `"20251130T190249Z-ABCDEF"` |
|
| 259 |
| `entry_type` | string | One of: `"daily"`, `"weekly"`, `"challenge"` |
|
| 260 |
| `game_mode` | string | Game difficulty: `"classic"`, `"easy"`, `"too easy"` |
|
| 261 |
| `grid_size` | int | Grid width (8 for Wrdler) |
|
|
|
|
| 301 |
|
| 302 |
Two leaderboards are considered the same if ALL of the following match:
|
| 303 |
- `game_mode`
|
| 304 |
+
- `wordlist_source` (after sanitization - .txt removed, lowercase)
|
| 305 |
- `show_incorrect_guesses`
|
| 306 |
- `enable_free_letters`
|
| 307 |
- `puzzle_options.spacer`
|
|
|
|
| 320 |
|
| 321 |
### 5.1 `wrdler/leaderboard.py` (NEW FILE)
|
| 322 |
|
| 323 |
+
**Purpose:** Core leaderboard logic for managing daily and weekly leaderboards with settings-based separation and folder-based discovery.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 324 |
|
| 325 |
+
Key classes:
|
| 326 |
+
- `GameSettings` - Settings that define a unique leaderboard
|
| 327 |
+
- `UserEntry` - Single user entry in a leaderboard
|
| 328 |
+
- `LeaderboardSettings` - Unified leaderboard/challenge settings format
|
| 329 |
|
| 330 |
+
Key functions:
|
| 331 |
+
- `_sanitize_wordlist_source()` - Remove .txt extension and normalize
|
| 332 |
+
- `_build_file_id()` - Create file_id from settings
|
| 333 |
+
- `_parse_file_id()` - Parse file_id into components
|
| 334 |
+
- `find_matching_leaderboard()` - Find leaderboard by scanning folders
|
| 335 |
+
- `create_or_get_leaderboard()` - Get or create a leaderboard
|
| 336 |
+
- `submit_score_to_all_leaderboards()` - Main entry point for submissions
|
| 337 |
|
| 338 |
+
### 5.2 `wrdler/modules/storage.py` (UPDATED)
|
|
|
|
|
|
|
|
|
|
| 339 |
|
| 340 |
+
Added functions:
|
| 341 |
+
- `_list_repo_folders()` - List folder names under a path in HuggingFace repo
|
| 342 |
+
- `_list_repo_files_in_folder()` - List files in a folder
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 343 |
|
| 344 |
---
|
| 345 |
|
|
|
|
| 350 |
| Step | Task | Files | Effort |
|
| 351 |
|------|------|-------|--------|
|
| 352 |
| 1.1 | Create `wrdler/leaderboard.py` with `GameSettings` and data models | NEW | 2h |
|
| 353 |
+
| 1.2 | Implement folder listing in storage.py | storage.py | 1h |
|
| 354 |
+
| 1.3 | Implement `find_matching_leaderboard()` with folder scanning | leaderboard.py | 1.5h |
|
| 355 |
| 1.4 | Implement `check_qualification()` and sorting | leaderboard.py | 1h |
|
| 356 |
| 1.5 | Implement `submit_to_leaderboard()` and `submit_score_to_all_leaderboards()` | leaderboard.py | 1h |
|
| 357 |
| 1.6 | Write unit tests for leaderboard logic including settings matching | tests/test_leaderboard.py | 2h |
|
|
|
|
| 421 |
__version__ = "0.2.0"
|
| 422 |
```
|
| 423 |
|
| 424 |
+
### wrdler/modules/storage.py
|
| 425 |
+
|
| 426 |
+
```python
|
| 427 |
+
__version__ = "0.1.6" # Updated to add folder listing functions
|
| 428 |
+
```
|
| 429 |
+
|
| 430 |
---
|
| 431 |
|
| 432 |
## 8. File Changes Summary
|
|
|
|
| 435 |
|
| 436 |
| File | Purpose |
|
| 437 |
|------|---------|
|
| 438 |
+
| `wrdler/leaderboard.py` | Core leaderboard logic with folder-based discovery |
|
| 439 |
| `wrdler/leaderboard_page.py` | Streamlit leaderboard page |
|
| 440 |
| `tests/test_leaderboard.py` | Unit tests for leaderboard |
|
| 441 |
| `specs/leaderboard_spec.md` | This specification |
|
|
|
|
| 446 |
|------|---------|
|
| 447 |
| `pyproject.toml` | Version bump to 0.2.0 |
|
| 448 |
| `wrdler/__init__.py` | Version bump, add leaderboard exports |
|
| 449 |
+
| `wrdler/modules/storage.py` | Add `_list_repo_folders()` and `_list_repo_files_in_folder()` |
|
| 450 |
| `wrdler/game_storage.py` | Version bump, add `entry_type` field, integrate leaderboard submission |
|
| 451 |
| `wrdler/ui.py` | Add leaderboard nav, integrate submission in game over |
|
| 452 |
| `wrdler/modules/__init__.py` | Export new functions if needed |
|
|
|
|
| 462 |
username: str,
|
| 463 |
score: int,
|
| 464 |
time_seconds: int,
|
|
|
|
|
|
|
| 465 |
word_list: List[str],
|
| 466 |
+
settings: GameSettings,
|
| 467 |
word_list_difficulty: Optional[float] = None,
|
| 468 |
source_challenge_id: Optional[str] = None,
|
| 469 |
repo_id: Optional[str] = None
|
|
|
|
| 473 |
def load_leaderboard(
|
| 474 |
entry_type: EntryType,
|
| 475 |
period_id: str,
|
| 476 |
+
file_id: str,
|
| 477 |
repo_id: Optional[str] = None
|
| 478 |
) -> Optional[LeaderboardSettings]:
|
| 479 |
+
"""Load a specific leaderboard by file ID."""
|
| 480 |
+
|
| 481 |
+
def find_matching_leaderboard(
|
| 482 |
+
entry_type: EntryType,
|
| 483 |
+
period_id: str,
|
| 484 |
+
settings: GameSettings,
|
| 485 |
+
repo_id: Optional[str] = None
|
| 486 |
+
) -> Tuple[Optional[str], Optional[LeaderboardSettings]]:
|
| 487 |
+
"""Find a leaderboard matching given settings."""
|
| 488 |
|
| 489 |
def get_last_n_daily_leaderboards(
|
| 490 |
n: int = 7,
|
|
|
|
| 493 |
) -> List[Tuple[str, Optional[LeaderboardSettings]]]:
|
| 494 |
"""Get recent daily leaderboards for display."""
|
| 495 |
|
| 496 |
+
def list_available_periods(
|
| 497 |
+
entry_type: EntryType,
|
| 498 |
+
limit: int = 30,
|
| 499 |
+
repo_id: Optional[str] = None
|
| 500 |
+
) -> List[str]:
|
| 501 |
+
"""List available period IDs from folder structure."""
|
| 502 |
+
|
| 503 |
+
def list_settings_for_period(
|
| 504 |
+
entry_type: EntryType,
|
| 505 |
+
period_id: str,
|
| 506 |
+
repo_id: Optional[str] = None
|
| 507 |
+
) -> List[Dict[str, Any]]:
|
| 508 |
+
"""List all settings combinations for a period."""
|
| 509 |
+
|
| 510 |
def get_current_daily_id() -> str:
|
| 511 |
+
"""Get today's period ID."""
|
| 512 |
|
| 513 |
def get_current_weekly_id() -> str:
|
| 514 |
+
"""Get this week's period ID."""
|
| 515 |
```
|
| 516 |
|
| 517 |
---
|
|
|
|
| 553 |
if st.session_state.get("show_leaderboard_page", False):
|
| 554 |
from wrdler.leaderboard_page import render_leaderboard_page
|
| 555 |
render_leaderboard_page()
|
| 556 |
+
if st.button("? Back to Game"):
|
| 557 |
st.session_state["show_leaderboard_page"] = False
|
| 558 |
st.rerun()
|
| 559 |
return # Don't render game UI
|
|
|
|
| 577 |
def test_get_display_users_limit(self): ...
|
| 578 |
def test_format_matches_challenge(self): ...
|
| 579 |
|
| 580 |
+
class TestGameSettings:
|
| 581 |
+
def test_settings_matching_same(self): ...
|
| 582 |
+
def test_settings_matching_different_mode(self): ...
|
| 583 |
+
def test_settings_matching_txt_extension_ignored(self): ...
|
| 584 |
+
def test_get_file_id_prefix(self): ...
|
| 585 |
+
|
| 586 |
+
class TestFileIdFunctions:
|
| 587 |
+
def test_sanitize_wordlist_source_removes_txt(self): ...
|
| 588 |
+
def test_build_file_id(self): ...
|
| 589 |
+
def test_parse_file_id(self): ...
|
| 590 |
+
|
| 591 |
class TestQualification:
|
| 592 |
def test_qualify_empty_leaderboard(self): ...
|
| 593 |
def test_qualify_not_full(self): ...
|
|
|
|
| 599 |
class TestDateIds:
|
| 600 |
def test_daily_id_format(self): ...
|
| 601 |
def test_weekly_id_format(self): ...
|
| 602 |
+
def test_daily_path(self): ... # Tests new folder structure
|
| 603 |
+
def test_weekly_path(self): ... # Tests new folder structure
|
|
|
|
|
|
|
|
|
|
| 604 |
```
|
| 605 |
|
| 606 |
|
|
|
|
| 608 |
|
| 609 |
- Test full flow: game completion ? leaderboard submission ? retrieval
|
| 610 |
- Test with mock HuggingFace repository
|
| 611 |
+
- Test folder-based discovery logic
|
| 612 |
- Test concurrent submissions (edge case)
|
| 613 |
- Test backward compatibility with legacy challenge files (no entry_type)
|
| 614 |
|
|
|
|
| 621 |
- Existing challenges continue to work unchanged (entry_type defaults to "challenge")
|
| 622 |
- No changes to `shortener.json` format
|
| 623 |
- Challenge `settings.json` format is extended (new fields are optional)
|
| 624 |
+
- **No index.json migration needed** - folder-based discovery is self-contained
|
| 625 |
|
| 626 |
### Schema Evolution
|
| 627 |
|
|
|
|
| 629 |
|---------|---------|
|
| 630 |
| 0.1.x | Original challenge format |
|
| 631 |
| 0.2.0 | Added `entry_type`, `max_display_entries`, `source_challenge_id` fields |
|
| 632 |
+
| 0.2.0 | Changed to folder-based discovery (no index.json) |
|
| 633 |
+
| 0.2.0 | New folder structure: `games/leaderboards/{type}/{period}/{file_id}/settings.json` |
|
| 634 |
|
| 635 |
### Data Migration
|
| 636 |
|
| 637 |
- No migration required for existing challenges
|
| 638 |
+
- New leaderboard files use folder-based storage from start
|
| 639 |
- Legacy challenges without `entry_type` default to `"challenge"`
|
| 640 |
|
| 641 |
### Rollback Plan
|
|
|
|
| 643 |
1. Remove leaderboard imports from `ui.py`
|
| 644 |
2. Remove sidebar navigation button
|
| 645 |
3. Remove game over submission calls
|
| 646 |
+
4. Optionally: delete `games/leaderboards/` directory from HF repo
|
| 647 |
|
| 648 |
---
|
| 649 |
|
| 650 |
+
## Appendix A: Example Daily Leaderboard JSON (Folder-Based)
|
| 651 |
+
|
| 652 |
+
**Path:** `games/leaderboards/daily/2025-01-27/classic-classic-0/settings.json`
|
| 653 |
|
| 654 |
```json
|
| 655 |
{
|
| 656 |
+
"challenge_id": "2025-01-27/classic-classic-0",
|
| 657 |
"entry_type": "daily",
|
| 658 |
"game_mode": "classic",
|
| 659 |
"grid_size": 8,
|
|
|
|
| 685 |
|
| 686 |
---
|
| 687 |
|
| 688 |
+
## Appendix B: File ID Examples
|
| 689 |
|
| 690 |
+
| Wordlist Source | Game Mode | Sequence | File ID |
|
| 691 |
+
|-----------------|-----------|----------|---------|
|
| 692 |
+
| `classic.txt` | `classic` | 0 | `classic-classic-0` |
|
| 693 |
+
| `easy.txt` | `easy` | 0 | `easy-easy-0` |
|
| 694 |
+
| `classic.txt` | `too easy` | 1 | `classic-too_easy-1` |
|
| 695 |
+
| `fourth_grade.txt` | `classic` | 0 | `fourth_grade-classic-0` |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 696 |
|
| 697 |
---
|
| 698 |
|
|
|
|
| 700 |
|
| 701 |
| Field | daily | weekly | challenge |
|
| 702 |
|-------|-------|--------|-----------|
|
| 703 |
+
| `challenge_id` format | `"2025-01-27/classic-classic-0"` | `"2025-W04/easy-easy-0"` | `"20251130T190249Z-ABC123"` |
|
| 704 |
| `entry_type` | `"daily"` | `"weekly"` | `"challenge"` |
|
| 705 |
+
| Storage path | `games/leaderboards/daily/{date}/{file_id}/settings.json` | `games/leaderboards/weekly/{week}/{file_id}/settings.json` | `games/{id}/settings.json` |
|
| 706 |
| Reset frequency | Daily (UTC midnight) | Weekly (Monday UTC midnight) | Never (permanent) |
|
| 707 |
+
| Settings-based | Yes (file_id encodes settings) | Yes (file_id encodes settings) | N/A (settings fixed per challenge) |
|
| 708 |
| `max_display_entries` | 20 | 20 | N/A (all users shown) |
|
| 709 |
+
| Discovery method | Folder scan + prefix match | Folder scan + prefix match | Direct by ID |
|
| 710 |
|
| 711 |
---
|
| 712 |
|
tests/test_leaderboard.py
CHANGED
|
@@ -7,6 +7,8 @@ Tests cover:
|
|
| 7 |
- Qualification logic
|
| 8 |
- Sorting functions
|
| 9 |
- Date/week ID generation
|
|
|
|
|
|
|
| 10 |
"""
|
| 11 |
|
| 12 |
import pytest
|
|
@@ -16,6 +18,7 @@ from unittest.mock import patch, MagicMock
|
|
| 16 |
from wrdler.leaderboard import (
|
| 17 |
UserEntry,
|
| 18 |
LeaderboardSettings,
|
|
|
|
| 19 |
get_current_daily_id,
|
| 20 |
get_current_weekly_id,
|
| 21 |
get_daily_leaderboard_path,
|
|
@@ -23,6 +26,9 @@ from wrdler.leaderboard import (
|
|
| 23 |
_sort_users,
|
| 24 |
check_qualification,
|
| 25 |
create_user_entry,
|
|
|
|
|
|
|
|
|
|
| 26 |
MAX_DISPLAY_ENTRIES,
|
| 27 |
)
|
| 28 |
|
|
@@ -124,10 +130,10 @@ class TestLeaderboardSettings:
|
|
| 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
|
|
@@ -180,7 +186,7 @@ class TestLeaderboardSettings:
|
|
| 180 |
)
|
| 181 |
|
| 182 |
lb = LeaderboardSettings(
|
| 183 |
-
challenge_id="2025-01-27",
|
| 184 |
entry_type="daily",
|
| 185 |
game_mode="easy",
|
| 186 |
users=[user],
|
|
@@ -199,7 +205,7 @@ class TestLeaderboardSettings:
|
|
| 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,
|
|
@@ -221,6 +227,96 @@ class TestLeaderboardSettings:
|
|
| 221 |
assert "wordlist_source" in d
|
| 222 |
|
| 223 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
class TestQualification:
|
| 225 |
"""Tests for qualification logic."""
|
| 226 |
|
|
@@ -379,14 +475,14 @@ class TestDateIds:
|
|
| 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:
|
|
@@ -395,9 +491,9 @@ class TestSorting:
|
|
| 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)
|
|
@@ -409,9 +505,9 @@ class TestSorting:
|
|
| 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)
|
|
@@ -475,7 +571,7 @@ class TestUnifiedFormat:
|
|
| 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()
|
|
@@ -512,13 +608,13 @@ class TestUnifiedFormat:
|
|
| 512 |
|
| 513 |
def test_challenge_id_as_primary_identifier(self):
|
| 514 |
"""Test challenge_id serves as primary identifier for all types."""
|
| 515 |
-
# Daily uses
|
| 516 |
-
daily = LeaderboardSettings(challenge_id="2025-01-27", entry_type="daily")
|
| 517 |
-
assert daily.challenge_id == "2025-01-27"
|
| 518 |
|
| 519 |
-
# Weekly uses
|
| 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")
|
|
|
|
| 7 |
- Qualification logic
|
| 8 |
- Sorting functions
|
| 9 |
- Date/week ID generation
|
| 10 |
+
- File ID generation and parsing
|
| 11 |
+
- GameSettings matching
|
| 12 |
"""
|
| 13 |
|
| 14 |
import pytest
|
|
|
|
| 18 |
from wrdler.leaderboard import (
|
| 19 |
UserEntry,
|
| 20 |
LeaderboardSettings,
|
| 21 |
+
GameSettings,
|
| 22 |
get_current_daily_id,
|
| 23 |
get_current_weekly_id,
|
| 24 |
get_daily_leaderboard_path,
|
|
|
|
| 26 |
_sort_users,
|
| 27 |
check_qualification,
|
| 28 |
create_user_entry,
|
| 29 |
+
_sanitize_wordlist_source,
|
| 30 |
+
_build_file_id,
|
| 31 |
+
_parse_file_id,
|
| 32 |
MAX_DISPLAY_ENTRIES,
|
| 33 |
)
|
| 34 |
|
|
|
|
| 130 |
def test_create_leaderboard(self):
|
| 131 |
"""Test basic LeaderboardSettings creation."""
|
| 132 |
lb = LeaderboardSettings(
|
| 133 |
+
challenge_id="2025-01-27/classic-classic-0",
|
| 134 |
entry_type="daily",
|
| 135 |
)
|
| 136 |
+
assert lb.challenge_id == "2025-01-27/classic-classic-0"
|
| 137 |
assert lb.entry_type == "daily"
|
| 138 |
assert lb.game_mode == "classic"
|
| 139 |
assert lb.grid_size == 8
|
|
|
|
| 186 |
)
|
| 187 |
|
| 188 |
lb = LeaderboardSettings(
|
| 189 |
+
challenge_id="2025-01-27/easy-easy-0",
|
| 190 |
entry_type="daily",
|
| 191 |
game_mode="easy",
|
| 192 |
users=[user],
|
|
|
|
| 205 |
def test_format_matches_challenge_structure(self):
|
| 206 |
"""Test that leaderboard format matches challenge settings.json structure."""
|
| 207 |
lb = LeaderboardSettings(
|
| 208 |
+
challenge_id="2025-01-27/classic-classic-0",
|
| 209 |
entry_type="daily",
|
| 210 |
game_mode="classic",
|
| 211 |
grid_size=8,
|
|
|
|
| 227 |
assert "wordlist_source" in d
|
| 228 |
|
| 229 |
|
| 230 |
+
class TestGameSettings:
|
| 231 |
+
"""Tests for GameSettings dataclass."""
|
| 232 |
+
|
| 233 |
+
def test_create_default_settings(self):
|
| 234 |
+
"""Test default GameSettings creation."""
|
| 235 |
+
settings = GameSettings()
|
| 236 |
+
assert settings.game_mode == "classic"
|
| 237 |
+
assert settings.wordlist_source == "classic.txt"
|
| 238 |
+
assert settings.show_incorrect_guesses is True
|
| 239 |
+
assert settings.enable_free_letters is True
|
| 240 |
+
|
| 241 |
+
def test_settings_matching_same(self):
|
| 242 |
+
"""Test that identical settings match."""
|
| 243 |
+
s1 = GameSettings(game_mode="classic", wordlist_source="classic.txt")
|
| 244 |
+
s2 = GameSettings(game_mode="classic", wordlist_source="classic.txt")
|
| 245 |
+
assert s1.matches(s2) is True
|
| 246 |
+
|
| 247 |
+
def test_settings_matching_different_mode(self):
|
| 248 |
+
"""Test that different game modes don't match."""
|
| 249 |
+
s1 = GameSettings(game_mode="classic", wordlist_source="classic.txt")
|
| 250 |
+
s2 = GameSettings(game_mode="easy", wordlist_source="classic.txt")
|
| 251 |
+
assert s1.matches(s2) is False
|
| 252 |
+
|
| 253 |
+
def test_settings_matching_different_wordlist(self):
|
| 254 |
+
"""Test that different wordlists don't match."""
|
| 255 |
+
s1 = GameSettings(game_mode="classic", wordlist_source="classic.txt")
|
| 256 |
+
s2 = GameSettings(game_mode="classic", wordlist_source="easy.txt")
|
| 257 |
+
assert s1.matches(s2) is False
|
| 258 |
+
|
| 259 |
+
def test_settings_matching_txt_extension_ignored(self):
|
| 260 |
+
"""Test that .txt extension is ignored in comparison."""
|
| 261 |
+
s1 = GameSettings(game_mode="classic", wordlist_source="classic.txt")
|
| 262 |
+
s2 = GameSettings(game_mode="classic", wordlist_source="classic")
|
| 263 |
+
# Both should have same sanitized source
|
| 264 |
+
assert s1._get_sanitized_source() == s2._get_sanitized_source()
|
| 265 |
+
|
| 266 |
+
def test_get_file_id_prefix(self):
|
| 267 |
+
"""Test file_id prefix generation."""
|
| 268 |
+
settings = GameSettings(game_mode="classic", wordlist_source="classic.txt")
|
| 269 |
+
assert settings.get_file_id_prefix() == "classic-classic"
|
| 270 |
+
|
| 271 |
+
settings2 = GameSettings(game_mode="too easy", wordlist_source="easy.txt")
|
| 272 |
+
assert settings2.get_file_id_prefix() == "easy-too_easy"
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
class TestFileIdFunctions:
|
| 276 |
+
"""Tests for file ID generation and parsing."""
|
| 277 |
+
|
| 278 |
+
def test_sanitize_wordlist_source_removes_txt(self):
|
| 279 |
+
"""Test that .txt extension is removed."""
|
| 280 |
+
assert _sanitize_wordlist_source("classic.txt") == "classic"
|
| 281 |
+
assert _sanitize_wordlist_source("easy.txt") == "easy"
|
| 282 |
+
assert _sanitize_wordlist_source("my_words.txt") == "my_words"
|
| 283 |
+
|
| 284 |
+
def test_sanitize_wordlist_source_lowercase(self):
|
| 285 |
+
"""Test that output is lowercase."""
|
| 286 |
+
assert _sanitize_wordlist_source("CLASSIC.txt") == "classic"
|
| 287 |
+
assert _sanitize_wordlist_source("MyWords.TXT") == "mywords"
|
| 288 |
+
|
| 289 |
+
def test_sanitize_wordlist_source_no_extension(self):
|
| 290 |
+
"""Test sources without .txt extension."""
|
| 291 |
+
assert _sanitize_wordlist_source("classic") == "classic"
|
| 292 |
+
|
| 293 |
+
def test_build_file_id(self):
|
| 294 |
+
"""Test file_id building."""
|
| 295 |
+
assert _build_file_id("classic.txt", "classic", 0) == "classic-classic-0"
|
| 296 |
+
assert _build_file_id("easy.txt", "easy", 1) == "easy-easy-1"
|
| 297 |
+
assert _build_file_id("classic.txt", "too easy", 2) == "classic-too_easy-2"
|
| 298 |
+
|
| 299 |
+
def test_parse_file_id(self):
|
| 300 |
+
"""Test file_id parsing."""
|
| 301 |
+
source, mode, seq = _parse_file_id("classic-classic-0")
|
| 302 |
+
assert source == "classic"
|
| 303 |
+
assert mode == "classic"
|
| 304 |
+
assert seq == 0
|
| 305 |
+
|
| 306 |
+
source, mode, seq = _parse_file_id("easy-too_easy-5")
|
| 307 |
+
assert source == "easy"
|
| 308 |
+
assert mode == "too easy"
|
| 309 |
+
assert seq == 5
|
| 310 |
+
|
| 311 |
+
def test_parse_file_id_invalid(self):
|
| 312 |
+
"""Test file_id parsing with invalid format."""
|
| 313 |
+
with pytest.raises(ValueError):
|
| 314 |
+
_parse_file_id("invalid")
|
| 315 |
+
|
| 316 |
+
with pytest.raises(ValueError):
|
| 317 |
+
_parse_file_id("classic-classic-notanumber")
|
| 318 |
+
|
| 319 |
+
|
| 320 |
class TestQualification:
|
| 321 |
"""Tests for qualification logic."""
|
| 322 |
|
|
|
|
| 475 |
assert len(parts[1]) == 2 # Week number with leading zero
|
| 476 |
|
| 477 |
def test_daily_path(self):
|
| 478 |
+
"""Test daily leaderboard path generation with new folder structure."""
|
| 479 |
+
path = get_daily_leaderboard_path("2025-01-27", "classic-classic-0")
|
| 480 |
+
assert path == "games/leaderboards/daily/2025-01-27/classic-classic-0/settings.json"
|
| 481 |
|
| 482 |
def test_weekly_path(self):
|
| 483 |
+
"""Test weekly leaderboard path generation with new folder structure."""
|
| 484 |
+
path = get_weekly_leaderboard_path("2025-W04", "easy-easy-1")
|
| 485 |
+
assert path == "games/leaderboards/weekly/2025-W04/easy-easy-1/settings.json"
|
| 486 |
|
| 487 |
|
| 488 |
class TestSorting:
|
|
|
|
| 491 |
def test_sort_by_score_desc(self):
|
| 492 |
"""Test users are sorted by score descending."""
|
| 493 |
users = [
|
| 494 |
+
UserEntry(uid="1", username="A", word_list=[], score=30, time=100, timestamp="" ),
|
| 495 |
+
UserEntry(uid="2", username="B", word_list=[], score=50, time=100, timestamp="" ),
|
| 496 |
+
UserEntry(uid="3", username="C", word_list=[], score=40, time=100, timestamp="" ),
|
| 497 |
]
|
| 498 |
|
| 499 |
sorted_users = _sort_users(users)
|
|
|
|
| 505 |
def test_sort_by_time_asc_for_equal_score(self):
|
| 506 |
"""Test users with equal score are sorted by time ascending."""
|
| 507 |
users = [
|
| 508 |
+
UserEntry(uid="1", username="A", word_list=[], score=50, time=120, timestamp="" ),
|
| 509 |
+
UserEntry(uid="2", username="B", word_list=[], score=50, time=80, timestamp="" ),
|
| 510 |
+
UserEntry(uid="3", username="C", word_list=[], score=50, time=100, timestamp="" ),
|
| 511 |
]
|
| 512 |
|
| 513 |
sorted_users = _sort_users(users)
|
|
|
|
| 571 |
def test_leaderboard_matches_challenge_structure(self):
|
| 572 |
"""Test leaderboard to_dict matches expected challenge structure."""
|
| 573 |
lb = LeaderboardSettings(
|
| 574 |
+
challenge_id="2025-01-27/classic-classic-0",
|
| 575 |
entry_type="daily",
|
| 576 |
)
|
| 577 |
d = lb.to_dict()
|
|
|
|
| 608 |
|
| 609 |
def test_challenge_id_as_primary_identifier(self):
|
| 610 |
"""Test challenge_id serves as primary identifier for all types."""
|
| 611 |
+
# Daily uses new folder format
|
| 612 |
+
daily = LeaderboardSettings(challenge_id="2025-01-27/classic-classic-0", entry_type="daily")
|
| 613 |
+
assert daily.challenge_id == "2025-01-27/classic-classic-0"
|
| 614 |
|
| 615 |
+
# Weekly uses new folder format
|
| 616 |
+
weekly = LeaderboardSettings(challenge_id="2025-W04/easy-easy-0", entry_type="weekly")
|
| 617 |
+
assert weekly.challenge_id == "2025-W04/easy-easy-0"
|
| 618 |
|
| 619 |
# Challenge uses UID format
|
| 620 |
challenge = LeaderboardSettings(challenge_id="20251130T190249Z-ABCDEF", entry_type="challenge")
|
wrdler/leaderboard.py
CHANGED
|
@@ -12,6 +12,16 @@ Leaderboard Configuration:
|
|
| 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 |
|
|
@@ -19,10 +29,12 @@ 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
|
|
@@ -31,14 +43,91 @@ logger = logging.getLogger(__name__)
|
|
| 31 |
|
| 32 |
# Configuration
|
| 33 |
MAX_DISPLAY_ENTRIES = 20
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
|
| 38 |
# Entry types
|
| 39 |
EntryType = Literal["daily", "weekly", "challenge"]
|
| 40 |
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
@dataclass
|
| 43 |
class GameSettings:
|
| 44 |
"""Settings that define a unique leaderboard."""
|
|
@@ -52,13 +141,23 @@ class GameSettings:
|
|
| 52 |
"""Check if two settings are equivalent (same leaderboard)."""
|
| 53 |
return (
|
| 54 |
self.game_mode == other.game_mode and
|
| 55 |
-
self.
|
| 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 {
|
|
@@ -144,7 +243,7 @@ class LeaderboardSettings:
|
|
| 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 #
|
| 148 |
entry_type: EntryType # "daily", "weekly", or "challenge"
|
| 149 |
game_mode: str = "classic"
|
| 150 |
grid_size: int = 8
|
|
@@ -223,14 +322,14 @@ def get_current_weekly_id() -> str:
|
|
| 223 |
return f"{iso_cal.year}-W{iso_cal.week:02d}"
|
| 224 |
|
| 225 |
|
| 226 |
-
def get_daily_leaderboard_path(
|
| 227 |
"""Get the file path for a daily leaderboard (folder-based with settings.json)."""
|
| 228 |
-
return f"{DAILY_LEADERBOARD_PATH}/{
|
| 229 |
|
| 230 |
|
| 231 |
-
def get_weekly_leaderboard_path(
|
| 232 |
"""Get the file path for a weekly leaderboard (folder-based with settings.json)."""
|
| 233 |
-
return f"{WEEKLY_LEADERBOARD_PATH}/{
|
| 234 |
|
| 235 |
|
| 236 |
def _sort_users(users: List[UserEntry]) -> List[UserEntry]:
|
|
@@ -245,23 +344,56 @@ def _sort_users(users: List[UserEntry]) -> List[UserEntry]:
|
|
| 245 |
)
|
| 246 |
|
| 247 |
|
| 248 |
-
def
|
| 249 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
if repo_id is None:
|
| 251 |
repo_id = HF_REPO_ID
|
| 252 |
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
|
| 258 |
|
| 259 |
-
def
|
| 260 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
if repo_id is None:
|
| 262 |
repo_id = HF_REPO_ID
|
| 263 |
|
| 264 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
|
| 266 |
|
| 267 |
def find_matching_leaderboard(
|
|
@@ -269,9 +401,10 @@ def find_matching_leaderboard(
|
|
| 269 |
period_id: str,
|
| 270 |
settings: GameSettings,
|
| 271 |
repo_id: Optional[str] = None
|
| 272 |
-
) -> Tuple[Optional[
|
| 273 |
"""
|
| 274 |
Find a leaderboard matching the given settings for a period.
|
|
|
|
| 275 |
|
| 276 |
Args:
|
| 277 |
entry_type: "daily" or "weekly"
|
|
@@ -285,34 +418,78 @@ def find_matching_leaderboard(
|
|
| 285 |
if repo_id is None:
|
| 286 |
repo_id = HF_REPO_ID
|
| 287 |
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
for
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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[
|
| 314 |
"""
|
| 315 |
Get existing leaderboard or create a new one for the settings.
|
|
|
|
| 316 |
|
| 317 |
Args:
|
| 318 |
entry_type: "daily" or "weekly"
|
|
@@ -333,18 +510,11 @@ def create_or_get_leaderboard(
|
|
| 333 |
return file_id, leaderboard
|
| 334 |
|
| 335 |
# Create new leaderboard
|
| 336 |
-
|
| 337 |
-
|
| 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 (
|
| 347 |
-
challenge_id = f"{period_id}
|
| 348 |
|
| 349 |
# Create new leaderboard
|
| 350 |
leaderboard = LeaderboardSettings(
|
|
@@ -358,19 +528,13 @@ def create_or_get_leaderboard(
|
|
| 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:
|
| 374 |
repo_id: Optional[str] = None
|
| 375 |
) -> Optional[LeaderboardSettings]:
|
| 376 |
"""
|
|
@@ -379,7 +543,7 @@ def load_leaderboard(
|
|
| 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:
|
|
@@ -408,7 +572,7 @@ def load_leaderboard(
|
|
| 408 |
|
| 409 |
def save_leaderboard(
|
| 410 |
leaderboard: LeaderboardSettings,
|
| 411 |
-
file_id:
|
| 412 |
repo_id: Optional[str] = None
|
| 413 |
) -> bool:
|
| 414 |
"""
|
|
@@ -416,7 +580,7 @@ def save_leaderboard(
|
|
| 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:
|
|
@@ -425,15 +589,12 @@ def save_leaderboard(
|
|
| 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 |
-
|
| 430 |
-
|
| 431 |
-
if len(parts) == 2 and parts[1].isdigit():
|
| 432 |
-
period_id = parts[0]
|
| 433 |
else:
|
| 434 |
-
#
|
| 435 |
-
|
| 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)
|
|
@@ -722,7 +883,7 @@ def list_available_periods(
|
|
| 722 |
repo_id: Optional[str] = None
|
| 723 |
) -> List[str]:
|
| 724 |
"""
|
| 725 |
-
List available period IDs from
|
| 726 |
|
| 727 |
Args:
|
| 728 |
entry_type: "daily" or "weekly"
|
|
@@ -732,9 +893,7 @@ def list_available_periods(
|
|
| 732 |
Returns:
|
| 733 |
List of period IDs in reverse chronological order
|
| 734 |
"""
|
| 735 |
-
|
| 736 |
-
periods = list(index.get(entry_type, {}).keys())
|
| 737 |
-
periods.sort(reverse=True)
|
| 738 |
return periods[:limit]
|
| 739 |
|
| 740 |
|
|
@@ -754,5 +913,19 @@ def list_settings_for_period(
|
|
| 754 |
Returns:
|
| 755 |
List of settings dictionaries with file_id
|
| 756 |
"""
|
| 757 |
-
|
| 758 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
- Folder-based discovery: No index.json, folder names contain settings info
|
| 16 |
+
|
| 17 |
+
Folder Structure:
|
| 18 |
+
games/leaderboards/daily/{date}/{wordlist_source}-{game_mode}-{sequence}/settings.json
|
| 19 |
+
games/leaderboards/weekly/{week}/{wordlist_source}-{game_mode}-{sequence}/settings.json
|
| 20 |
+
games/{challenge_id}/settings.json
|
| 21 |
+
|
| 22 |
+
File ID Format:
|
| 23 |
+
{wordlist_source}-{game_mode}-{sequence}
|
| 24 |
+
Example: classic-classic-0, easy-easy-1
|
| 25 |
"""
|
| 26 |
__version__ = "0.2.0"
|
| 27 |
|
|
|
|
| 29 |
from datetime import datetime, timezone, timedelta
|
| 30 |
from typing import Dict, Any, List, Optional, Tuple, Literal
|
| 31 |
import logging
|
| 32 |
+
import re
|
| 33 |
|
| 34 |
from wrdler.modules.storage import (
|
| 35 |
_get_json_from_repo,
|
| 36 |
+
_upload_json_to_repo,
|
| 37 |
+
_list_repo_folders
|
| 38 |
)
|
| 39 |
from wrdler.modules.constants import HF_REPO_ID, APP_SETTINGS
|
| 40 |
from wrdler.game_storage import generate_uid
|
|
|
|
| 43 |
|
| 44 |
# Configuration
|
| 45 |
MAX_DISPLAY_ENTRIES = 20
|
| 46 |
+
LEADERBOARD_BASE_PATH = "games/leaderboards"
|
| 47 |
+
DAILY_LEADERBOARD_PATH = f"{LEADERBOARD_BASE_PATH}/daily"
|
| 48 |
+
WEEKLY_LEADERBOARD_PATH = f"{LEADERBOARD_BASE_PATH}/weekly"
|
| 49 |
|
| 50 |
# Entry types
|
| 51 |
EntryType = Literal["daily", "weekly", "challenge"]
|
| 52 |
|
| 53 |
|
| 54 |
+
def _sanitize_wordlist_source(wordlist_source: str) -> str:
|
| 55 |
+
"""
|
| 56 |
+
Sanitize wordlist source for use in folder names.
|
| 57 |
+
Removes .txt extension and any problematic characters.
|
| 58 |
+
|
| 59 |
+
Args:
|
| 60 |
+
wordlist_source: Original wordlist source (e.g., "classic.txt")
|
| 61 |
+
|
| 62 |
+
Returns:
|
| 63 |
+
Sanitized string (e.g., "classic")
|
| 64 |
+
"""
|
| 65 |
+
# Remove .txt extension
|
| 66 |
+
name = wordlist_source
|
| 67 |
+
if name.endswith(".txt"):
|
| 68 |
+
name = name[:-4]
|
| 69 |
+
# Replace any problematic characters with underscores
|
| 70 |
+
name = re.sub(r'[^\w\-]', '_', name)
|
| 71 |
+
return name.lower()
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _build_file_id(wordlist_source: str, game_mode: str, sequence: int) -> str:
|
| 75 |
+
"""
|
| 76 |
+
Build a file_id from settings components.
|
| 77 |
+
|
| 78 |
+
Format: {wordlist_source}-{game_mode}-{sequence}
|
| 79 |
+
Example: classic-classic-0, easy-easy-1
|
| 80 |
+
|
| 81 |
+
Args:
|
| 82 |
+
wordlist_source: Wordlist source file (will be sanitized)
|
| 83 |
+
game_mode: Game mode string
|
| 84 |
+
sequence: Sequence number for this settings combination
|
| 85 |
+
|
| 86 |
+
Returns:
|
| 87 |
+
File ID string
|
| 88 |
+
"""
|
| 89 |
+
sanitized_source = _sanitize_wordlist_source(wordlist_source)
|
| 90 |
+
sanitized_mode = game_mode.lower().replace(" ", "_")
|
| 91 |
+
return f"{sanitized_source}-{sanitized_mode}-{sequence}"
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _parse_file_id(file_id: str) -> Tuple[str, str, int]:
|
| 95 |
+
"""
|
| 96 |
+
Parse a file_id into its components.
|
| 97 |
+
|
| 98 |
+
Args:
|
| 99 |
+
file_id: File ID string (e.g., "classic-classic-0")
|
| 100 |
+
|
| 101 |
+
Returns:
|
| 102 |
+
Tuple of (wordlist_source, game_mode, sequence)
|
| 103 |
+
|
| 104 |
+
Raises:
|
| 105 |
+
ValueError: If file_id format is invalid
|
| 106 |
+
"""
|
| 107 |
+
parts = file_id.rsplit("-", 1)
|
| 108 |
+
if len(parts) != 2:
|
| 109 |
+
raise ValueError(f"Invalid file_id format: {file_id}")
|
| 110 |
+
|
| 111 |
+
prefix = parts[0]
|
| 112 |
+
try:
|
| 113 |
+
sequence = int(parts[1])
|
| 114 |
+
except ValueError:
|
| 115 |
+
raise ValueError(f"Invalid sequence in file_id: {file_id}")
|
| 116 |
+
|
| 117 |
+
# Split prefix into wordlist_source and game_mode
|
| 118 |
+
# Format is {wordlist_source}-{game_mode}
|
| 119 |
+
prefix_parts = prefix.rsplit("-", 1)
|
| 120 |
+
if len(prefix_parts) == 2:
|
| 121 |
+
wordlist_source = prefix_parts[0]
|
| 122 |
+
game_mode = prefix_parts[1].replace("_", " ")
|
| 123 |
+
else:
|
| 124 |
+
# Fallback: treat entire prefix as wordlist_source
|
| 125 |
+
wordlist_source = prefix
|
| 126 |
+
game_mode = "classic"
|
| 127 |
+
|
| 128 |
+
return wordlist_source, game_mode, sequence
|
| 129 |
+
|
| 130 |
+
|
| 131 |
@dataclass
|
| 132 |
class GameSettings:
|
| 133 |
"""Settings that define a unique leaderboard."""
|
|
|
|
| 141 |
"""Check if two settings are equivalent (same leaderboard)."""
|
| 142 |
return (
|
| 143 |
self.game_mode == other.game_mode and
|
| 144 |
+
self._get_sanitized_source() == other._get_sanitized_source() and
|
| 145 |
self.show_incorrect_guesses == other.show_incorrect_guesses and
|
| 146 |
self.enable_free_letters == other.enable_free_letters and
|
| 147 |
self.puzzle_options.get("spacer", 0) == other.puzzle_options.get("spacer", 0) and
|
| 148 |
self.puzzle_options.get("may_overlap", False) == other.puzzle_options.get("may_overlap", False)
|
| 149 |
)
|
| 150 |
|
| 151 |
+
def _get_sanitized_source(self) -> str:
|
| 152 |
+
"""Get sanitized wordlist source for comparison."""
|
| 153 |
+
return _sanitize_wordlist_source(self.wordlist_source)
|
| 154 |
+
|
| 155 |
+
def get_file_id_prefix(self) -> str:
|
| 156 |
+
"""Get the file_id prefix (without sequence) for this settings combination."""
|
| 157 |
+
sanitized_source = _sanitize_wordlist_source(self.wordlist_source)
|
| 158 |
+
sanitized_mode = self.game_mode.lower().replace(" ", "_")
|
| 159 |
+
return f"{sanitized_source}-{sanitized_mode}"
|
| 160 |
+
|
| 161 |
def to_dict(self) -> Dict[str, Any]:
|
| 162 |
"""Convert to dictionary."""
|
| 163 |
return {
|
|
|
|
| 243 |
entry_type field to distinguish between daily, weekly, and challenge entries.
|
| 244 |
The settings fields define what makes this leaderboard unique.
|
| 245 |
"""
|
| 246 |
+
challenge_id: str # {period_id}/{file_id} for daily/weekly, UID for challenge
|
| 247 |
entry_type: EntryType # "daily", "weekly", or "challenge"
|
| 248 |
game_mode: str = "classic"
|
| 249 |
grid_size: int = 8
|
|
|
|
| 322 |
return f"{iso_cal.year}-W{iso_cal.week:02d}"
|
| 323 |
|
| 324 |
|
| 325 |
+
def get_daily_leaderboard_path(period_id: str, file_id: str) -> str:
|
| 326 |
"""Get the file path for a daily leaderboard (folder-based with settings.json)."""
|
| 327 |
+
return f"{DAILY_LEADERBOARD_PATH}/{period_id}/{file_id}/settings.json"
|
| 328 |
|
| 329 |
|
| 330 |
+
def get_weekly_leaderboard_path(period_id: str, file_id: str) -> str:
|
| 331 |
"""Get the file path for a weekly leaderboard (folder-based with settings.json)."""
|
| 332 |
+
return f"{WEEKLY_LEADERBOARD_PATH}/{period_id}/{file_id}/settings.json"
|
| 333 |
|
| 334 |
|
| 335 |
def _sort_users(users: List[UserEntry]) -> List[UserEntry]:
|
|
|
|
| 344 |
)
|
| 345 |
|
| 346 |
|
| 347 |
+
def _list_period_folders(entry_type: EntryType, repo_id: Optional[str] = None) -> List[str]:
|
| 348 |
+
"""
|
| 349 |
+
List all period folders (dates for daily, weeks for weekly) in a leaderboard type.
|
| 350 |
+
|
| 351 |
+
Args:
|
| 352 |
+
entry_type: "daily" or "weekly"
|
| 353 |
+
repo_id: Repository ID
|
| 354 |
+
|
| 355 |
+
Returns:
|
| 356 |
+
List of period IDs in reverse chronological order
|
| 357 |
+
"""
|
| 358 |
if repo_id is None:
|
| 359 |
repo_id = HF_REPO_ID
|
| 360 |
|
| 361 |
+
if entry_type == "daily":
|
| 362 |
+
base_path = DAILY_LEADERBOARD_PATH
|
| 363 |
+
elif entry_type == "weekly":
|
| 364 |
+
base_path = WEEKLY_LEADERBOARD_PATH
|
| 365 |
+
else:
|
| 366 |
+
return []
|
| 367 |
+
|
| 368 |
+
folders = _list_repo_folders(repo_id, base_path, "dataset")
|
| 369 |
+
# Sort in reverse chronological order
|
| 370 |
+
folders.sort(reverse=True)
|
| 371 |
+
return folders
|
| 372 |
|
| 373 |
|
| 374 |
+
def _list_file_ids_for_period(entry_type: EntryType, period_id: str, repo_id: Optional[str] = None) -> List[str]:
|
| 375 |
+
"""
|
| 376 |
+
List all file_ids (settings combinations) for a given period.
|
| 377 |
+
|
| 378 |
+
Args:
|
| 379 |
+
entry_type: "daily" or "weekly"
|
| 380 |
+
period_id: Date or week identifier
|
| 381 |
+
repo_id: Repository ID
|
| 382 |
+
|
| 383 |
+
Returns:
|
| 384 |
+
List of file_id strings
|
| 385 |
+
"""
|
| 386 |
if repo_id is None:
|
| 387 |
repo_id = HF_REPO_ID
|
| 388 |
|
| 389 |
+
if entry_type == "daily":
|
| 390 |
+
base_path = f"{DAILY_LEADERBOARD_PATH}/{period_id}"
|
| 391 |
+
elif entry_type == "weekly":
|
| 392 |
+
base_path = f"{WEEKLY_LEADERBOARD_PATH}/{period_id}"
|
| 393 |
+
else:
|
| 394 |
+
return []
|
| 395 |
+
|
| 396 |
+
return _list_repo_folders(repo_id, base_path, "dataset")
|
| 397 |
|
| 398 |
|
| 399 |
def find_matching_leaderboard(
|
|
|
|
| 401 |
period_id: str,
|
| 402 |
settings: GameSettings,
|
| 403 |
repo_id: Optional[str] = None
|
| 404 |
+
) -> Tuple[Optional[str], Optional[LeaderboardSettings]]:
|
| 405 |
"""
|
| 406 |
Find a leaderboard matching the given settings for a period.
|
| 407 |
+
Uses folder-based discovery instead of index.json.
|
| 408 |
|
| 409 |
Args:
|
| 410 |
entry_type: "daily" or "weekly"
|
|
|
|
| 418 |
if repo_id is None:
|
| 419 |
repo_id = HF_REPO_ID
|
| 420 |
|
| 421 |
+
# Get the file_id prefix for this settings combination
|
| 422 |
+
prefix = settings.get_file_id_prefix()
|
| 423 |
+
|
| 424 |
+
# List all file_ids for this period
|
| 425 |
+
file_ids = _list_file_ids_for_period(entry_type, period_id, repo_id)
|
| 426 |
+
|
| 427 |
+
# Find matching file_ids by prefix
|
| 428 |
+
matching_file_ids = [fid for fid in file_ids if fid.startswith(prefix + "-")]
|
| 429 |
+
|
| 430 |
+
for file_id in matching_file_ids:
|
| 431 |
+
# Load the leaderboard and verify settings match
|
| 432 |
+
if entry_type == "daily":
|
| 433 |
+
path = get_daily_leaderboard_path(period_id, file_id)
|
| 434 |
+
else:
|
| 435 |
+
path = get_weekly_leaderboard_path(period_id, file_id)
|
| 436 |
+
|
| 437 |
+
data = _get_json_from_repo(repo_id, path, "dataset")
|
| 438 |
+
if data:
|
| 439 |
+
leaderboard = LeaderboardSettings.from_dict(data)
|
| 440 |
+
lb_settings = leaderboard.get_settings()
|
| 441 |
+
if settings.matches(lb_settings):
|
| 442 |
+
return file_id, leaderboard
|
| 443 |
|
| 444 |
return None, None
|
| 445 |
|
| 446 |
|
| 447 |
+
def _get_next_sequence(entry_type: EntryType, period_id: str, settings: GameSettings, repo_id: Optional[str] = None) -> int:
|
| 448 |
+
"""
|
| 449 |
+
Get the next sequence number for a new leaderboard with given settings.
|
| 450 |
+
|
| 451 |
+
Args:
|
| 452 |
+
entry_type: "daily" or "weekly"
|
| 453 |
+
period_id: Date or week identifier
|
| 454 |
+
settings: Game settings
|
| 455 |
+
repo_id: Repository ID
|
| 456 |
+
|
| 457 |
+
Returns:
|
| 458 |
+
Next sequence number (0 if none exist)
|
| 459 |
+
"""
|
| 460 |
+
if repo_id is None:
|
| 461 |
+
repo_id = HF_REPO_ID
|
| 462 |
+
|
| 463 |
+
prefix = settings.get_file_id_prefix()
|
| 464 |
+
file_ids = _list_file_ids_for_period(entry_type, period_id, repo_id)
|
| 465 |
+
|
| 466 |
+
# Find all file_ids with matching prefix
|
| 467 |
+
matching = [fid for fid in file_ids if fid.startswith(prefix + "-")]
|
| 468 |
+
|
| 469 |
+
if not matching:
|
| 470 |
+
return 0
|
| 471 |
+
|
| 472 |
+
# Extract sequence numbers
|
| 473 |
+
sequences = []
|
| 474 |
+
for fid in matching:
|
| 475 |
+
try:
|
| 476 |
+
_, _, seq = _parse_file_id(fid)
|
| 477 |
+
sequences.append(seq)
|
| 478 |
+
except ValueError:
|
| 479 |
+
continue
|
| 480 |
+
|
| 481 |
+
return max(sequences, default=-1) + 1
|
| 482 |
+
|
| 483 |
+
|
| 484 |
def create_or_get_leaderboard(
|
| 485 |
entry_type: EntryType,
|
| 486 |
period_id: str,
|
| 487 |
settings: GameSettings,
|
| 488 |
repo_id: Optional[str] = None
|
| 489 |
+
) -> Tuple[str, LeaderboardSettings]:
|
| 490 |
"""
|
| 491 |
Get existing leaderboard or create a new one for the settings.
|
| 492 |
+
Uses folder-based storage without index.json.
|
| 493 |
|
| 494 |
Args:
|
| 495 |
entry_type: "daily" or "weekly"
|
|
|
|
| 510 |
return file_id, leaderboard
|
| 511 |
|
| 512 |
# Create new leaderboard
|
| 513 |
+
sequence = _get_next_sequence(entry_type, period_id, settings, repo_id)
|
| 514 |
+
file_id = _build_file_id(settings.wordlist_source, settings.game_mode, sequence)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 515 |
|
| 516 |
+
# Create challenge_id (identifies this leaderboard)
|
| 517 |
+
challenge_id = f"{period_id}/{file_id}"
|
| 518 |
|
| 519 |
# Create new leaderboard
|
| 520 |
leaderboard = LeaderboardSettings(
|
|
|
|
| 528 |
users=[]
|
| 529 |
)
|
| 530 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 531 |
return file_id, leaderboard
|
| 532 |
|
| 533 |
|
| 534 |
def load_leaderboard(
|
| 535 |
entry_type: EntryType,
|
| 536 |
period_id: str,
|
| 537 |
+
file_id: str,
|
| 538 |
repo_id: Optional[str] = None
|
| 539 |
) -> Optional[LeaderboardSettings]:
|
| 540 |
"""
|
|
|
|
| 543 |
Args:
|
| 544 |
entry_type: "daily" or "weekly"
|
| 545 |
period_id: Date string or week identifier
|
| 546 |
+
file_id: File identifier (e.g., "classic-classic-0")
|
| 547 |
repo_id: Repository ID (uses HF_REPO_ID if None)
|
| 548 |
|
| 549 |
Returns:
|
|
|
|
| 572 |
|
| 573 |
def save_leaderboard(
|
| 574 |
leaderboard: LeaderboardSettings,
|
| 575 |
+
file_id: str,
|
| 576 |
repo_id: Optional[str] = None
|
| 577 |
) -> bool:
|
| 578 |
"""
|
|
|
|
| 580 |
|
| 581 |
Args:
|
| 582 |
leaderboard: LeaderboardSettings object to save
|
| 583 |
+
file_id: File identifier (e.g., "classic-classic-0")
|
| 584 |
repo_id: Repository ID (uses HF_REPO_ID if None)
|
| 585 |
|
| 586 |
Returns:
|
|
|
|
| 589 |
if repo_id is None:
|
| 590 |
repo_id = HF_REPO_ID
|
| 591 |
|
| 592 |
+
# Extract period_id from challenge_id (format: "2025-01-27/classic-classic-0" or "2025-W04/easy-easy-0")
|
| 593 |
+
if "/" in leaderboard.challenge_id:
|
| 594 |
+
period_id = leaderboard.challenge_id.split("/")[0]
|
|
|
|
|
|
|
| 595 |
else:
|
| 596 |
+
# Fallback: try to parse from challenge_id directly
|
| 597 |
+
period_id = leaderboard.challenge_id
|
|
|
|
| 598 |
|
| 599 |
if leaderboard.entry_type == "daily":
|
| 600 |
path = get_daily_leaderboard_path(period_id, file_id)
|
|
|
|
| 883 |
repo_id: Optional[str] = None
|
| 884 |
) -> List[str]:
|
| 885 |
"""
|
| 886 |
+
List available period IDs from folder structure.
|
| 887 |
|
| 888 |
Args:
|
| 889 |
entry_type: "daily" or "weekly"
|
|
|
|
| 893 |
Returns:
|
| 894 |
List of period IDs in reverse chronological order
|
| 895 |
"""
|
| 896 |
+
periods = _list_period_folders(entry_type, repo_id)
|
|
|
|
|
|
|
| 897 |
return periods[:limit]
|
| 898 |
|
| 899 |
|
|
|
|
| 913 |
Returns:
|
| 914 |
List of settings dictionaries with file_id
|
| 915 |
"""
|
| 916 |
+
file_ids = _list_file_ids_for_period(entry_type, period_id, repo_id)
|
| 917 |
+
|
| 918 |
+
settings_list = []
|
| 919 |
+
for file_id in file_ids:
|
| 920 |
+
try:
|
| 921 |
+
wordlist_source, game_mode, sequence = _parse_file_id(file_id)
|
| 922 |
+
settings_list.append({
|
| 923 |
+
"file_id": file_id,
|
| 924 |
+
"wordlist_source": wordlist_source,
|
| 925 |
+
"game_mode": game_mode,
|
| 926 |
+
"sequence": sequence
|
| 927 |
+
})
|
| 928 |
+
except ValueError:
|
| 929 |
+
continue
|
| 930 |
+
|
| 931 |
+
return settings_list
|
wrdler/leaderboard_page.py
CHANGED
|
@@ -16,7 +16,10 @@ from wrdler.leaderboard import (
|
|
| 16 |
get_last_n_daily_leaderboards,
|
| 17 |
get_current_daily_id,
|
| 18 |
get_current_weekly_id,
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
| 20 |
LeaderboardSettings,
|
| 21 |
MAX_DISPLAY_ENTRIES
|
| 22 |
)
|
|
@@ -32,11 +35,11 @@ def _format_time(seconds: int) -> str:
|
|
| 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 |
|
|
@@ -60,7 +63,7 @@ def _render_leaderboard_table(leaderboard: Optional[LeaderboardSettings], title:
|
|
| 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 = "
|
| 64 |
|
| 65 |
rows.append(f"""
|
| 66 |
<tr>
|
|
@@ -117,18 +120,32 @@ def _render_leaderboard_table(leaderboard: Optional[LeaderboardSettings], title:
|
|
| 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
|
| 121 |
else:
|
| 122 |
-
st.caption(f"{total_entries} entries
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"
|
| 129 |
|
| 130 |
# Tab selection
|
| 131 |
-
tab1, tab2, tab3 = st.tabs(["
|
| 132 |
|
| 133 |
with tab1:
|
| 134 |
_render_daily_tab()
|
|
@@ -142,18 +159,22 @@ def render_leaderboard_page():
|
|
| 142 |
|
| 143 |
def _render_daily_tab():
|
| 144 |
"""Render daily leaderboards tab."""
|
| 145 |
-
st.header("
|
| 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"
|
| 157 |
else:
|
| 158 |
title = date_obj.strftime("%A, %B %d, %Y")
|
| 159 |
except ValueError:
|
|
@@ -165,11 +186,15 @@ def _render_daily_tab():
|
|
| 165 |
|
| 166 |
def _render_weekly_tab():
|
| 167 |
"""Render weekly leaderboard tab."""
|
| 168 |
-
st.header("
|
| 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 =
|
| 173 |
|
| 174 |
# Parse week for display
|
| 175 |
try:
|
|
@@ -178,48 +203,80 @@ def _render_weekly_tab():
|
|
| 178 |
except ValueError:
|
| 179 |
title = weekly_id
|
| 180 |
|
| 181 |
-
_render_leaderboard_table(leaderboard, f"
|
| 182 |
|
| 183 |
|
| 184 |
def _render_history_tab():
|
| 185 |
"""Render historical leaderboards tab."""
|
| 186 |
-
st.header("
|
| 187 |
st.write("Look up past leaderboards.")
|
| 188 |
|
| 189 |
col1, col2 = st.columns(2)
|
| 190 |
|
| 191 |
with col1:
|
| 192 |
st.subheader("Daily History")
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
|
| 204 |
with col2:
|
| 205 |
st.subheader("Weekly History")
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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()
|
|
|
|
| 16 |
get_last_n_daily_leaderboards,
|
| 17 |
get_current_daily_id,
|
| 18 |
get_current_weekly_id,
|
| 19 |
+
list_available_periods,
|
| 20 |
+
list_settings_for_period,
|
| 21 |
+
find_matching_leaderboard,
|
| 22 |
+
GameSettings,
|
| 23 |
LeaderboardSettings,
|
| 24 |
MAX_DISPLAY_ENTRIES
|
| 25 |
)
|
|
|
|
| 35 |
def _get_rank_emoji(rank: int) -> str:
|
| 36 |
"""Get emoji for rank."""
|
| 37 |
if rank == 1:
|
| 38 |
+
return "🥇"
|
| 39 |
elif rank == 2:
|
| 40 |
+
return "🥈"
|
| 41 |
elif rank == 3:
|
| 42 |
+
return "🥉"
|
| 43 |
return f"{rank}."
|
| 44 |
|
| 45 |
|
|
|
|
| 63 |
difficulty = f"{user.word_list_difficulty:.2f}" if user.word_list_difficulty else "-"
|
| 64 |
|
| 65 |
# Show challenge indicator if from a challenge
|
| 66 |
+
challenge_badge = " 🎯" if user.source_challenge_id else ""
|
| 67 |
|
| 68 |
rows.append(f"""
|
| 69 |
<tr>
|
|
|
|
| 120 |
# Show entry count and last updated
|
| 121 |
total_entries = len(leaderboard.users)
|
| 122 |
if total_entries > MAX_DISPLAY_ENTRIES:
|
| 123 |
+
st.caption(f"Showing top {MAX_DISPLAY_ENTRIES} of {total_entries} entries · Last updated: {leaderboard.created_at}")
|
| 124 |
else:
|
| 125 |
+
st.caption(f"{total_entries} entries · Last updated: {leaderboard.created_at}")
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def _get_current_game_settings() -> GameSettings:
|
| 129 |
+
"""Get the current game settings from session state."""
|
| 130 |
+
return GameSettings(
|
| 131 |
+
game_mode=st.session_state.get("game_mode", "classic"),
|
| 132 |
+
wordlist_source=st.session_state.get("selected_wordlist", "classic.txt"),
|
| 133 |
+
show_incorrect_guesses=st.session_state.get("show_incorrect_guesses", True),
|
| 134 |
+
enable_free_letters=st.session_state.get("enable_free_letters", False),
|
| 135 |
+
puzzle_options={
|
| 136 |
+
"spacer": st.session_state.get("spacer", 1),
|
| 137 |
+
"may_overlap": False
|
| 138 |
+
}
|
| 139 |
+
)
|
| 140 |
|
| 141 |
|
| 142 |
def render_leaderboard_page():
|
| 143 |
"""Render the full leaderboard page."""
|
| 144 |
game_title = APP_SETTINGS.get("game_title", "Wrdler")
|
| 145 |
+
st.title(f"🏆 {game_title} Leaderboards")
|
| 146 |
|
| 147 |
# Tab selection
|
| 148 |
+
tab1, tab2, tab3 = st.tabs(["📅 Daily", "📆 Weekly", "📚 History"])
|
| 149 |
|
| 150 |
with tab1:
|
| 151 |
_render_daily_tab()
|
|
|
|
| 159 |
|
| 160 |
def _render_daily_tab():
|
| 161 |
"""Render daily leaderboards tab."""
|
| 162 |
+
st.header("📅 Daily Leaderboards")
|
| 163 |
st.write(f"Top {MAX_DISPLAY_ENTRIES} scores for each day. Resets at UTC midnight.")
|
| 164 |
|
| 165 |
+
# Get current game settings for filtering
|
| 166 |
+
settings = _get_current_game_settings()
|
| 167 |
+
st.info(f"Showing leaderboards for: **{settings.game_mode}** mode, **{settings.wordlist_source}**")
|
| 168 |
+
|
| 169 |
# Get last 7 days
|
| 170 |
+
daily_boards = get_last_n_daily_leaderboards(7, settings)
|
| 171 |
|
| 172 |
for date_id, leaderboard in daily_boards:
|
| 173 |
# Format date nicely
|
| 174 |
try:
|
| 175 |
date_obj = datetime.strptime(date_id, "%Y-%m-%d")
|
| 176 |
if date_id == get_current_daily_id():
|
| 177 |
+
title = f"🌟 Today ({date_obj.strftime('%B %d, %Y')})"
|
| 178 |
else:
|
| 179 |
title = date_obj.strftime("%A, %B %d, %Y")
|
| 180 |
except ValueError:
|
|
|
|
| 186 |
|
| 187 |
def _render_weekly_tab():
|
| 188 |
"""Render weekly leaderboard tab."""
|
| 189 |
+
st.header("📆 Weekly Leaderboard")
|
| 190 |
st.write(f"Top {MAX_DISPLAY_ENTRIES} scores for the current week. Resets Monday at UTC midnight.")
|
| 191 |
|
| 192 |
+
# Get current game settings for filtering
|
| 193 |
+
settings = _get_current_game_settings()
|
| 194 |
+
st.info(f"Showing leaderboard for: **{settings.game_mode}** mode, **{settings.wordlist_source}**")
|
| 195 |
+
|
| 196 |
weekly_id = get_current_weekly_id()
|
| 197 |
+
_, leaderboard = find_matching_leaderboard("weekly", weekly_id, settings)
|
| 198 |
|
| 199 |
# Parse week for display
|
| 200 |
try:
|
|
|
|
| 203 |
except ValueError:
|
| 204 |
title = weekly_id
|
| 205 |
|
| 206 |
+
_render_leaderboard_table(leaderboard, f"🗓️ {title}")
|
| 207 |
|
| 208 |
|
| 209 |
def _render_history_tab():
|
| 210 |
"""Render historical leaderboards tab."""
|
| 211 |
+
st.header("📚 Historical Leaderboards")
|
| 212 |
st.write("Look up past leaderboards.")
|
| 213 |
|
| 214 |
col1, col2 = st.columns(2)
|
| 215 |
|
| 216 |
with col1:
|
| 217 |
st.subheader("Daily History")
|
| 218 |
+
daily_periods = list_available_periods("daily", limit=30)
|
| 219 |
+
if daily_periods:
|
| 220 |
+
selected_daily = st.selectbox(
|
| 221 |
+
"Select a date",
|
| 222 |
+
options=daily_periods,
|
| 223 |
+
key="history_daily_select"
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
# Show available settings for this period
|
| 227 |
+
settings_list = list_settings_for_period("daily", selected_daily)
|
| 228 |
+
if settings_list:
|
| 229 |
+
file_id_options = [s["file_id"] for s in settings_list]
|
| 230 |
+
selected_file_id = st.selectbox(
|
| 231 |
+
"Select settings",
|
| 232 |
+
options=file_id_options,
|
| 233 |
+
format_func=lambda x: x.replace("-", " / "),
|
| 234 |
+
key="history_daily_file_id"
|
| 235 |
+
)
|
| 236 |
+
|
| 237 |
+
if st.button("Load Daily", key="load_daily"):
|
| 238 |
+
leaderboard = load_leaderboard("daily", selected_daily, selected_file_id)
|
| 239 |
+
_render_leaderboard_table(leaderboard, f"Daily: {selected_daily} ({selected_file_id})")
|
| 240 |
+
else:
|
| 241 |
+
st.info("No leaderboards found for this date.")
|
| 242 |
+
else:
|
| 243 |
+
st.info("No daily leaderboards found.")
|
| 244 |
|
| 245 |
with col2:
|
| 246 |
st.subheader("Weekly History")
|
| 247 |
+
weekly_periods = list_available_periods("weekly", limit=20)
|
| 248 |
+
if weekly_periods:
|
| 249 |
+
selected_weekly = st.selectbox(
|
| 250 |
+
"Select a week",
|
| 251 |
+
options=weekly_periods,
|
| 252 |
+
key="history_weekly_select"
|
| 253 |
+
)
|
| 254 |
+
|
| 255 |
+
# Show available settings for this period
|
| 256 |
+
settings_list = list_settings_for_period("weekly", selected_weekly)
|
| 257 |
+
if settings_list:
|
| 258 |
+
file_id_options = [s["file_id"] for s in settings_list]
|
| 259 |
+
selected_file_id = st.selectbox(
|
| 260 |
+
"Select settings",
|
| 261 |
+
options=file_id_options,
|
| 262 |
+
format_func=lambda x: x.replace("-", " / "),
|
| 263 |
+
key="history_weekly_file_id"
|
| 264 |
+
)
|
| 265 |
+
|
| 266 |
+
if st.button("Load Weekly", key="load_weekly"):
|
| 267 |
+
leaderboard = load_leaderboard("weekly", selected_weekly, selected_file_id)
|
| 268 |
+
_render_leaderboard_table(leaderboard, f"Weekly: {selected_weekly} ({selected_file_id})")
|
| 269 |
+
else:
|
| 270 |
+
st.info("No leaderboards found for this week.")
|
| 271 |
+
else:
|
| 272 |
+
st.info("No weekly leaderboards found.")
|
| 273 |
|
| 274 |
|
| 275 |
# Entry point for standalone testing
|
| 276 |
if __name__ == "__main__":
|
| 277 |
st.set_page_config(
|
| 278 |
page_title="Wrdler Leaderboards",
|
| 279 |
+
page_icon="🏆",
|
| 280 |
layout="wide"
|
| 281 |
)
|
| 282 |
render_leaderboard_page()
|
wrdler/modules/storage.md
CHANGED
|
@@ -6,6 +6,7 @@ The `storage.py` module provides helper functions for:
|
|
| 6 |
- Managing URL shortening by storing (short URL, full URL) pairs in a JSON file on the repository.
|
| 7 |
- Retrieving full URLs from short URL IDs and vice versa.
|
| 8 |
- Handle specific file types for 3D models, images, video and audio.
|
|
|
|
| 9 |
- **🔑 Cryptographic key management for Open Badge 3.0 issuers.**
|
| 10 |
|
| 11 |
## Key Functions
|
|
@@ -142,9 +143,49 @@ status, retrieved_full_url = gen_full_url(
|
|
| 142 |
print("Status:", status)
|
| 143 |
if status == "success_retrieved_full":
|
| 144 |
print("Retrieved Full URL:", retrieved_full_url)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
## 🔑 Cryptographic Key Management Functions
|
| 146 |
|
| 147 |
-
###
|
| 148 |
- **Purpose:**
|
| 149 |
Securely store cryptographic keys for an issuer in a private Hugging Face repository. Private keys are encrypted before storage.
|
| 150 |
- **⚠️ IMPORTANT:** This function requires a PRIVATE Hugging Face repository to ensure the security of stored private keys. Never use this with public repositories.
|
|
@@ -163,7 +204,7 @@ if success:
|
|
| 163 |
print("Keys stored successfully")
|
| 164 |
else:
|
| 165 |
print("Failed to store keys")
|
| 166 |
-
###
|
| 167 |
- **Purpose:**
|
| 168 |
Retrieve and decrypt stored cryptographic keys for an issuer from the private Hugging Face repository.
|
| 169 |
- **⚠️ IMPORTANT:** This function accesses a PRIVATE Hugging Face repository containing encrypted private keys. Ensure proper access control and security measures.
|
|
@@ -179,7 +220,7 @@ if public_key and private_key:
|
|
| 179 |
# Use private_key for signing operations
|
| 180 |
else:
|
| 181 |
print("Keys not found or error occurred")
|
| 182 |
-
###
|
| 183 |
- **Purpose:**
|
| 184 |
Retrieve the global verification methods registry containing all registered issuer public keys.
|
| 185 |
- **Returns:** `Dict[str, Any]` - Registry data containing all verification methods.
|
|
@@ -193,7 +234,7 @@ for method in methods:
|
|
| 193 |
print(f"Public Key: {method['public_key']}")
|
| 194 |
print(f"Key Type: {method['key_type']}")
|
| 195 |
print("---")
|
| 196 |
-
###
|
| 197 |
- **Purpose:**
|
| 198 |
List all issuer IDs that have stored keys in the repository.
|
| 199 |
- **Returns:** `List[str]` - List of issuer IDs.
|
|
@@ -224,4 +265,4 @@ for issuer_id in issuer_ids:
|
|
| 224 |
|
| 225 |
---
|
| 226 |
|
| 227 |
-
This guide provides the essential usage examples for interacting with the storage, URL-shortening, and cryptographic key management functionality. You can integrate these examples into your application or use them as a reference when extending functionality.
|
|
|
|
| 6 |
- Managing URL shortening by storing (short URL, full URL) pairs in a JSON file on the repository.
|
| 7 |
- Retrieving full URLs from short URL IDs and vice versa.
|
| 8 |
- Handle specific file types for 3D models, images, video and audio.
|
| 9 |
+
- **📁 Listing folders and files in HuggingFace repositories.**
|
| 10 |
- **🔑 Cryptographic key management for Open Badge 3.0 issuers.**
|
| 11 |
|
| 12 |
## Key Functions
|
|
|
|
| 143 |
print("Status:", status)
|
| 144 |
if status == "success_retrieved_full":
|
| 145 |
print("Retrieved Full URL:", retrieved_full_url)
|
| 146 |
+
## 📁 Repository Folder Listing Functions
|
| 147 |
+
|
| 148 |
+
### 5. `_list_repo_folders(repo_id, path_prefix, repo_type="dataset")`
|
| 149 |
+
- **Purpose:**
|
| 150 |
+
List folder names under a given path in a HuggingFace repository. Enables folder-based discovery without index files.
|
| 151 |
+
- **Parameters:**
|
| 152 |
+
- `repo_id` (str): The repository ID on Hugging Face
|
| 153 |
+
- `path_prefix` (str): The path prefix to list folders under
|
| 154 |
+
- `repo_type` (str): Repository type. Default is `"dataset"`.
|
| 155 |
+
- **Returns:** `List[str]` - List of folder names found under the path_prefix.
|
| 156 |
+
- **Usage Example:**
|
| 157 |
+
```python
|
| 158 |
+
from modules.storage import _list_repo_folders
|
| 159 |
+
|
| 160 |
+
# List all date folders in daily leaderboards
|
| 161 |
+
folders = _list_repo_folders("Surn/Wrdler-Data", "games/leaderboards/daily")
|
| 162 |
+
print("Available dates:", folders)
|
| 163 |
+
# Output: ['2025-01-27', '2025-01-26', '2025-01-25']
|
| 164 |
+
```
|
| 165 |
+
|
| 166 |
+
### 6. `_list_repo_files_in_folder(repo_id, folder_path, repo_type="dataset")`
|
| 167 |
+
- **Purpose:**
|
| 168 |
+
List file names directly under a folder in a HuggingFace repository.
|
| 169 |
+
- **Parameters:**
|
| 170 |
+
- `repo_id` (str): The repository ID on Hugging Face
|
| 171 |
+
- `folder_path` (str): The folder path to list files under
|
| 172 |
+
- `repo_type` (str): Repository type. Default is `"dataset"`.
|
| 173 |
+
- **Returns:** `List[str]` - List of file names found directly in the folder.
|
| 174 |
+
- **Usage Example:**
|
| 175 |
+
```python
|
| 176 |
+
from modules.storage import _list_repo_files_in_folder
|
| 177 |
+
|
| 178 |
+
files = _list_repo_files_in_folder(
|
| 179 |
+
"Surn/Wrdler-Data",
|
| 180 |
+
"games/leaderboards/daily/2025-01-27/classic-classic-0"
|
| 181 |
+
)
|
| 182 |
+
print("Files:", files)
|
| 183 |
+
# Output: ['settings.json']
|
| 184 |
+
```
|
| 185 |
+
|
| 186 |
## 🔑 Cryptographic Key Management Functions
|
| 187 |
|
| 188 |
+
### 7. `store_issuer_keypair(issuer_id, public_key, private_key, repo_id=None)`
|
| 189 |
- **Purpose:**
|
| 190 |
Securely store cryptographic keys for an issuer in a private Hugging Face repository. Private keys are encrypted before storage.
|
| 191 |
- **⚠️ IMPORTANT:** This function requires a PRIVATE Hugging Face repository to ensure the security of stored private keys. Never use this with public repositories.
|
|
|
|
| 204 |
print("Keys stored successfully")
|
| 205 |
else:
|
| 206 |
print("Failed to store keys")
|
| 207 |
+
### 8. `get_issuer_keypair(issuer_id, repo_id=None)`
|
| 208 |
- **Purpose:**
|
| 209 |
Retrieve and decrypt stored cryptographic keys for an issuer from the private Hugging Face repository.
|
| 210 |
- **⚠️ IMPORTANT:** This function accesses a PRIVATE Hugging Face repository containing encrypted private keys. Ensure proper access control and security measures.
|
|
|
|
| 220 |
# Use private_key for signing operations
|
| 221 |
else:
|
| 222 |
print("Keys not found or error occurred")
|
| 223 |
+
### 9. `get_verification_methods_registry(repo_id=None)`
|
| 224 |
- **Purpose:**
|
| 225 |
Retrieve the global verification methods registry containing all registered issuer public keys.
|
| 226 |
- **Returns:** `Dict[str, Any]` - Registry data containing all verification methods.
|
|
|
|
| 234 |
print(f"Public Key: {method['public_key']}")
|
| 235 |
print(f"Key Type: {method['key_type']}")
|
| 236 |
print("---")
|
| 237 |
+
### 10. `list_issuer_ids(repo_id=None)`
|
| 238 |
- **Purpose:**
|
| 239 |
List all issuer IDs that have stored keys in the repository.
|
| 240 |
- **Returns:** `List[str]` - List of issuer IDs.
|
|
|
|
| 265 |
|
| 266 |
---
|
| 267 |
|
| 268 |
+
This guide provides the essential usage examples for interacting with the storage, URL-shortening, folder listing, and cryptographic key management functionality. You can integrate these examples into your application or use them as a reference when extending functionality.
|
wrdler/modules/storage.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
# modules/storage.py
|
| 2 |
-
__version__ = "0.1.
|
| 3 |
import os
|
| 4 |
import urllib.parse
|
| 5 |
import tempfile
|
|
@@ -691,18 +691,109 @@ def list_issuer_ids(repo_id: str = None) -> List[str]:
|
|
| 691 |
logger.error(f"Error listing issuer IDs: {e}")
|
| 692 |
return []
|
| 693 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 694 |
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
|
| 700 |
-
|
| 701 |
|
| 702 |
-
|
| 703 |
-
|
| 704 |
|
| 705 |
-
|
| 706 |
-
|
| 707 |
|
| 708 |
-
|
|
|
|
| 1 |
# modules/storage.py
|
| 2 |
+
__version__ = "0.1.6"
|
| 3 |
import os
|
| 4 |
import urllib.parse
|
| 5 |
import tempfile
|
|
|
|
| 691 |
logger.error(f"Error listing issuer IDs: {e}")
|
| 692 |
return []
|
| 693 |
|
| 694 |
+
def _list_repo_folders(repo_id: str, path_prefix: str, repo_type: str = "dataset") -> List[str]:
|
| 695 |
+
"""
|
| 696 |
+
List folder names under a given path in a HuggingFace repository.
|
| 697 |
+
|
| 698 |
+
Args:
|
| 699 |
+
repo_id: The repository ID on Hugging Face
|
| 700 |
+
path_prefix: The path prefix to list folders under (e.g., "leaderboards/daily/2025-01-27")
|
| 701 |
+
repo_type: Repository type ("dataset", "model", "space"). Default is "dataset".
|
| 702 |
+
|
| 703 |
+
Returns:
|
| 704 |
+
List of folder names (not full paths) found under the path_prefix.
|
| 705 |
+
Returns empty list if path not found or on error.
|
| 706 |
+
"""
|
| 707 |
+
try:
|
| 708 |
+
login(token=HF_API_TOKEN)
|
| 709 |
+
api = HfApi()
|
| 710 |
+
|
| 711 |
+
# List all files in the repo under the prefix
|
| 712 |
+
# The list_repo_files returns file paths, so we extract unique folder names
|
| 713 |
+
all_files = api.list_repo_files(
|
| 714 |
+
repo_id=repo_id,
|
| 715 |
+
repo_type=repo_type,
|
| 716 |
+
token=HF_API_TOKEN
|
| 717 |
+
)
|
| 718 |
+
|
| 719 |
+
# Ensure path_prefix ends with /
|
| 720 |
+
if path_prefix and not path_prefix.endswith("/"):
|
| 721 |
+
path_prefix = path_prefix + "/"
|
| 722 |
+
|
| 723 |
+
folders = set()
|
| 724 |
+
for file_path in all_files:
|
| 725 |
+
if file_path.startswith(path_prefix):
|
| 726 |
+
# Get the relative path after the prefix
|
| 727 |
+
relative_path = file_path[len(path_prefix):]
|
| 728 |
+
# Extract the first folder name (before any /)
|
| 729 |
+
if "/" in relative_path:
|
| 730 |
+
folder_name = relative_path.split("/")[0]
|
| 731 |
+
folders.add(folder_name)
|
| 732 |
+
|
| 733 |
+
return sorted(list(folders))
|
| 734 |
+
|
| 735 |
+
except RepositoryNotFoundError:
|
| 736 |
+
logger.warning(f"Repository {repo_id} not found.")
|
| 737 |
+
return []
|
| 738 |
+
except Exception as e:
|
| 739 |
+
logger.error(f"Error listing folders in {repo_id}/{path_prefix}: {e}")
|
| 740 |
+
return []
|
| 741 |
+
|
| 742 |
+
|
| 743 |
+
def _list_repo_files_in_folder(repo_id: str, folder_path: str, repo_type: str = "dataset") -> List[str]:
|
| 744 |
+
"""
|
| 745 |
+
List file names (not full paths) directly under a folder in a HuggingFace repository.
|
| 746 |
+
|
| 747 |
+
Args:
|
| 748 |
+
repo_id: The repository ID on Hugging Face
|
| 749 |
+
folder_path: The folder path to list files under
|
| 750 |
+
repo_type: Repository type. Default is "dataset".
|
| 751 |
+
|
| 752 |
+
Returns:
|
| 753 |
+
List of file names found directly in the folder.
|
| 754 |
+
"""
|
| 755 |
+
try:
|
| 756 |
+
login(token=HF_API_TOKEN)
|
| 757 |
+
api = HfApi()
|
| 758 |
+
|
| 759 |
+
all_files = api.list_repo_files(
|
| 760 |
+
repo_id=repo_id,
|
| 761 |
+
repo_type=repo_type,
|
| 762 |
+
token=HF_API_TOKEN
|
| 763 |
+
)
|
| 764 |
+
|
| 765 |
+
# Ensure folder_path ends with /
|
| 766 |
+
if folder_path and not folder_path.endswith("/"):
|
| 767 |
+
folder_path = folder_path + "/"
|
| 768 |
+
|
| 769 |
+
files = []
|
| 770 |
+
for file_path in all_files:
|
| 771 |
+
if file_path.startswith(folder_path):
|
| 772 |
+
relative_path = file_path[len(folder_path):]
|
| 773 |
+
# Only include files directly in this folder (no subdirectories)
|
| 774 |
+
if "/" not in relative_path and relative_path:
|
| 775 |
+
files.append(relative_path)
|
| 776 |
+
|
| 777 |
+
return sorted(files)
|
| 778 |
+
|
| 779 |
+
except RepositoryNotFoundError:
|
| 780 |
+
logger.warning(f"Repository {repo_id} not found.")
|
| 781 |
+
return []
|
| 782 |
+
except Exception as e:
|
| 783 |
+
logger.error(f"Error listing files in {repo_id}/{folder_path}: {e}")
|
| 784 |
+
return []
|
| 785 |
|
| 786 |
+
if __name__ == "__main__":
|
| 787 |
+
issuer_id = "https://example.edu/issuers/565049"
|
| 788 |
+
# Example usage
|
| 789 |
+
public_key, private_key = get_issuer_keypair(issuer_id)
|
| 790 |
+
print(f"Public Key: {public_key}")
|
| 791 |
+
print(f"Private Key: {private_key}")
|
| 792 |
|
| 793 |
+
# Example to store keys
|
| 794 |
+
store_issuer_keypair(issuer_id, public_key, private_key)
|
| 795 |
|
| 796 |
+
# Example to list issuer IDs
|
| 797 |
+
issuer_ids = list_issuer_ids()
|
| 798 |
|
| 799 |
+
print(f"Issuer IDs: {issuer_ids}")
|