Surn commited on
Commit
9c7fde6
·
1 Parent(s): 38961b4

Leaderboard - Storage strategy Simplification

Browse files
specs/leaderboard_spec.md CHANGED
@@ -1,6 +1,6 @@
1
  # Wrdler Leaderboard System Specification
2
 
3
- **Document Version:** 1.2.0
4
  **Target Project Version:** 0.2.0
5
  **Author:** GitHub Copilot
6
  **Date:** 2025-01-27
@@ -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. **Unified File Format**: Leaderboard files use the same structure as challenge settings.json with an `entry_type` field to distinguish between "daily", "weekly", and "challenge" entries
 
 
60
 
61
  ### Secondary Goals
62
 
@@ -85,74 +88,69 @@ The following settings define a unique leaderboard:
85
 
86
  ### 3.1 Storage Structure
87
 
88
- Each date/week can have multiple leaderboard folders, one per unique settings combination. The folder name includes the file_id suffix, and all leaderboards use `settings.json` as the filename (consistent with challenges):
89
 
90
  ```
91
  HF_REPO_ID/
92
- ??? games/ # Existing challenge storage
93
- ? ??? {challenge_id}/
94
- ? ??? settings.json # entry_type: "challenge"
95
- ??? leaderboards/
96
- ? ??? daily/
97
- ? ? ??? 2025-01-27-0/ # First settings combination for 2025-01-27
98
- ? ? ? ??? settings.json
99
- ? ? ??? 2025-01-27-1/ # Second settings combination for 2025-01-27
100
- ? ? ? ??? settings.json
101
- ? ? ??? 2025-01-26-0/
102
- ? ? ??? settings.json
103
- ? ??? weekly/
104
- ? ? ??? 2025-W04-0/ # First settings combination for week 4
105
- ? ? ? ??? settings.json
106
- ? ? ??? 2025-W04-1/
107
- ? ? ? ??? settings.json
108
- ? ? ??? 2025-W03-0/
109
- ? ? ??? settings.json
110
- ? ??? index.json # Index of all leaderboards (maps settings to folder IDs)
 
 
 
111
  ??? shortener.json # Existing URL shortener
112
  ```
113
 
114
- ### 3.2 Leaderboard Index File
115
 
116
- The `index.json` file maps settings combinations to folder identifiers for fast lookup:
117
 
118
- ```json
119
- {
120
- "daily": {
121
- "2025-01-27": [
122
- {
123
- "file_id": 0,
124
- "game_mode": "classic",
125
- "wordlist_source": "classic.txt",
126
- "show_incorrect_guesses": true,
127
- "enable_free_letters": true,
128
- "puzzle_options": {"spacer": 0, "may_overlap": false}
129
- },
130
- {
131
- "file_id": 1,
132
- "game_mode": "easy",
133
- "wordlist_source": "easy.txt",
134
- "show_incorrect_guesses": true,
135
- "enable_free_letters": true,
136
- "puzzle_options": {"spacer": 0, "may_overlap": false}
137
- }
138
- ]
139
- },
140
- "weekly": {
141
- "2025-W04": [
142
- {
143
- "file_id": 0,
144
- "game_mode": "classic",
145
- "wordlist_source": "classic.txt",
146
- "show_incorrect_guesses": true,
147
- "enable_free_letters": true,
148
- "puzzle_options": {"spacer": 0, "may_overlap": false}
149
- }
150
- ]
151
- }
152
- }
153
  ```
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
 
155
- ### 3.3 Data Flow
 
 
 
 
 
 
 
 
 
 
 
 
 
156
 
157
  ```
158
  ??????????????????????
@@ -167,33 +165,40 @@ The `index.json` file maps settings combinations to folder identifiers for fast
167
  ?
168
  ?
169
  ??????????????????????
170
- ? Find matching ?
171
- ? leaderboard or ?
172
- ? create new one ?
 
 
 
 
 
 
 
173
  ??????????????????????
174
  ?
175
  ?????????????
176
  ? ?
177
  ? ?
178
- ????????? ???????????
179
- ? Daily ? ? Weekly ?
180
- ? LB ? ? LB ?
181
- ????????? ???????????
182
  ? ?
183
  ????????????
184
  ?
185
  ?
186
- ?????????????????????
187
- ? Check if score ?
188
- ? qualifies (top ?
189
- ? 20 displayed) ?
190
- ?????????????????????
191
  ?
192
  ?
193
- ?????????????????????
194
- ? Update & Upload ?
195
- ? to HF repo ?
196
- ?????????????????????
197
  ```
198
 
199
  ---
@@ -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}-{file_id}/settings.json` |
211
- | `"weekly"` | Weekly leaderboard entry | `leaderboards/weekly/{week}-{file_id}/settings.json` |
212
 
213
  ### 4.2 Unified File Schema (Consistent with Challenge settings.json)
214
 
@@ -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
- def list_available_periods(
1034
- entry_type: EntryType,
1035
- limit: int = 30,
1036
- repo_id: Optional[str] = None
1037
- ) -> List[str]:
1038
- """
1039
- List available period IDs from the index.
1040
 
1041
- Args:
1042
- entry_type: "daily" or "weekly"
1043
- limit: Maximum number of IDs to return
1044
- repo_id: Repository ID
1045
 
1046
- Returns:
1047
- List of period IDs in reverse chronological order
1048
- """
1049
- index = _load_index(repo_id)
1050
- periods = list(index.get(entry_type, {}).keys())
1051
- periods.sort(reverse=True)
1052
- return periods[:limit]
1053
-
1054
-
1055
- def list_settings_for_period(
1056
- entry_type: EntryType,
1057
- period_id: str,
1058
- repo_id: Optional[str] = None
1059
- ) -> List[Dict[str, Any]]:
1060
- """
1061
- List all settings combinations available for a period.
1062
-
1063
- Args:
1064
- entry_type: "daily" or "weekly"
1065
- period_id: Date or week identifier
1066
- repo_id: Repository ID
1067
-
1068
- Returns:
1069
- List of settings dictionaries with file_id
1070
- """
1071
- index = _load_index(repo_id)
1072
- return index.get(entry_type, {}).get(period_id, [])
1073
- ```
1074
 
1075
  ---
1076
 
@@ -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 index management (`_load_index`, `_save_index`) | leaderboard.py | 1h |
1085
- | 1.3 | Implement `find_matching_leaderboard()` and `create_or_get_leaderboard()` | leaderboard.py | 1.5h |
1086
  | 1.4 | Implement `check_qualification()` and sorting | leaderboard.py | 1h |
1087
  | 1.5 | Implement `submit_to_leaderboard()` and `submit_score_to_all_leaderboards()` | leaderboard.py | 1h |
1088
  | 1.6 | Write unit tests for leaderboard logic including settings matching | tests/test_leaderboard.py | 2h |
@@ -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 unified format |
1164
  | `wrdler/leaderboard_page.py` | Streamlit leaderboard page |
1165
  | `tests/test_leaderboard.py` | Unit tests for leaderboard |
1166
  | `specs/leaderboard_spec.md` | This specification |
@@ -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: int,
1202
  repo_id: Optional[str] = None
1203
  ) -> Optional[LeaderboardSettings]:
1204
- """Load a specific leaderboard."""
 
 
 
 
 
 
 
 
1205
 
1206
  def get_last_n_daily_leaderboards(
1207
  n: int = 7,
@@ -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 leaderboard ID (challenge_id)."""
1215
 
1216
  def get_current_weekly_id() -> str:
1217
- """Get this week's leaderboard ID (challenge_id)."""
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("?? Back to Game"):
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
- class TestUnifiedFormat:
1296
- def test_leaderboard_matches_challenge_structure(self): ...
1297
- def test_entry_type_field_present(self): ...
1298
- def test_challenge_id_as_primary_identifier(self): ...
1299
  ```
1300
 
1301
 
@@ -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 unified format from start
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 (Settings-Based)
 
 
1342
 
1343
  ```json
1344
  {
1345
- "challenge_id": "2025-01-27-0",
1346
  "entry_type": "daily",
1347
  "game_mode": "classic",
1348
  "grid_size": 8,
@@ -1374,44 +685,14 @@ class TestUnifiedFormat:
1374
 
1375
  ---
1376
 
1377
- ## Appendix B: Example Index JSON
1378
 
1379
- ```json
1380
- {
1381
- "daily": {
1382
- "2025-01-27": [
1383
- {
1384
- "file_id": 0,
1385
- "game_mode": "classic",
1386
- "wordlist_source": "classic.txt",
1387
- "show_incorrect_guesses": true,
1388
- "enable_free_letters": true,
1389
- "puzzle_options": {"spacer": 0, "may_overlap": false}
1390
- },
1391
- {
1392
- "file_id": 1,
1393
- "game_mode": "easy",
1394
- "wordlist_source": "easy.txt",
1395
- "show_incorrect_guesses": true,
1396
- "enable_free_letters": true,
1397
- "puzzle_options": {"spacer": 0, "may_overlap": false}
1398
- }
1399
- ]
1400
- },
1401
- "weekly": {
1402
- "2025-W04": [
1403
- {
1404
- "file_id": 0,
1405
- "game_mode": "classic",
1406
- "wordlist_source": "classic.txt",
1407
- "show_incorrect_guesses": true,
1408
- "enable_free_letters": true,
1409
- "puzzle_options": {"spacer": 0, "may_overlap": false}
1410
- }
1411
- ]
1412
- }
1413
- }
1414
- ```
1415
 
1416
  ---
1417
 
@@ -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}-{file_id}/settings.json` | `leaderboards/weekly/{week}-{file_id}/settings.json` | `games/{id}/settings.json` |
1425
  | Reset frequency | Daily (UTC midnight) | Weekly (Monday UTC midnight) | Never (permanent) |
1426
- | Settings-based | Yes (separate folder per settings) | Yes (separate folder per settings) | N/A (settings fixed per challenge) |
1427
  | `max_display_entries` | 20 | 20 | N/A (all users shown) |
 
1428
 
1429
  ---
1430
 
 
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 date format
516
- daily = LeaderboardSettings(challenge_id="2025-01-27", entry_type="daily")
517
- assert daily.challenge_id == "2025-01-27"
518
 
519
- # Weekly uses week format
520
- weekly = LeaderboardSettings(challenge_id="2025-W04", entry_type="weekly")
521
- assert weekly.challenge_id == "2025-W04"
522
 
523
  # Challenge uses UID format
524
  challenge = LeaderboardSettings(challenge_id="20251130T190249Z-ABCDEF", entry_type="challenge")
 
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
- DAILY_LEADERBOARD_PATH = "leaderboards/daily"
35
- WEEKLY_LEADERBOARD_PATH = "leaderboards/weekly"
36
- LEADERBOARD_INDEX_PATH = "leaderboards/index.json"
37
 
38
  # Entry types
39
  EntryType = Literal["daily", "weekly", "challenge"]
40
 
41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  @dataclass
43
  class GameSettings:
44
  """Settings that define a unique leaderboard."""
@@ -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.wordlist_source == other.wordlist_source and
56
  self.show_incorrect_guesses == other.show_incorrect_guesses and
57
  self.enable_free_letters == other.enable_free_letters and
58
  self.puzzle_options.get("spacer", 0) == other.puzzle_options.get("spacer", 0) and
59
  self.puzzle_options.get("may_overlap", False) == other.puzzle_options.get("may_overlap", False)
60
  )
61
 
 
 
 
 
 
 
 
 
 
 
62
  def to_dict(self) -> Dict[str, Any]:
63
  """Convert to dictionary."""
64
  return {
@@ -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 # Date-fileId for daily, week-fileId for weekly, UID for challenge
148
  entry_type: EntryType # "daily", "weekly", or "challenge"
149
  game_mode: str = "classic"
150
  grid_size: int = 8
@@ -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(date_id: str, file_id: int) -> str:
227
  """Get the file path for a daily leaderboard (folder-based with settings.json)."""
228
- return f"{DAILY_LEADERBOARD_PATH}/{date_id}-{file_id}/settings.json"
229
 
230
 
231
- def get_weekly_leaderboard_path(week_id: str, file_id: int) -> str:
232
  """Get the file path for a weekly leaderboard (folder-based with settings.json)."""
233
- return f"{WEEKLY_LEADERBOARD_PATH}/{week_id}-{file_id}/settings.json"
234
 
235
 
236
  def _sort_users(users: List[UserEntry]) -> List[UserEntry]:
@@ -245,23 +344,56 @@ def _sort_users(users: List[UserEntry]) -> List[UserEntry]:
245
  )
246
 
247
 
248
- def _load_index(repo_id: Optional[str] = None) -> Dict[str, Any]:
249
- """Load the leaderboard index."""
 
 
 
 
 
 
 
 
 
250
  if repo_id is None:
251
  repo_id = HF_REPO_ID
252
 
253
- data = _get_json_from_repo(repo_id, LEADERBOARD_INDEX_PATH, "dataset")
254
- if not data:
255
- return {"daily": {}, "weekly": {}}
256
- return data
 
 
 
 
 
 
 
257
 
258
 
259
- def _save_index(index: Dict[str, Any], repo_id: Optional[str] = None) -> bool:
260
- """Save the leaderboard index."""
 
 
 
 
 
 
 
 
 
 
261
  if repo_id is None:
262
  repo_id = HF_REPO_ID
263
 
264
- return _upload_json_to_repo(index, repo_id, LEADERBOARD_INDEX_PATH, "dataset")
 
 
 
 
 
 
 
265
 
266
 
267
  def find_matching_leaderboard(
@@ -269,9 +401,10 @@ def find_matching_leaderboard(
269
  period_id: str,
270
  settings: GameSettings,
271
  repo_id: Optional[str] = None
272
- ) -> Tuple[Optional[int], Optional[LeaderboardSettings]]:
273
  """
274
  Find a leaderboard matching the given settings for a period.
 
275
 
276
  Args:
277
  entry_type: "daily" or "weekly"
@@ -285,34 +418,78 @@ def find_matching_leaderboard(
285
  if repo_id is None:
286
  repo_id = HF_REPO_ID
287
 
288
- index = _load_index(repo_id)
289
- period_entries = index.get(entry_type, {}).get(period_id, [])
290
-
291
- for entry in period_entries:
292
- entry_settings = GameSettings.from_dict(entry)
293
- if settings.matches(entry_settings):
294
- file_id = entry["file_id"]
295
- # Load the actual leaderboard
296
- if entry_type == "daily":
297
- path = get_daily_leaderboard_path(period_id, file_id)
298
- else:
299
- path = get_weekly_leaderboard_path(period_id, file_id)
300
-
301
- data = _get_json_from_repo(repo_id, path, "dataset")
302
- if data:
303
- return file_id, LeaderboardSettings.from_dict(data)
 
 
 
 
 
 
304
 
305
  return None, None
306
 
307
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
308
  def create_or_get_leaderboard(
309
  entry_type: EntryType,
310
  period_id: str,
311
  settings: GameSettings,
312
  repo_id: Optional[str] = None
313
- ) -> Tuple[int, LeaderboardSettings]:
314
  """
315
  Get existing leaderboard or create a new one for the settings.
 
316
 
317
  Args:
318
  entry_type: "daily" or "weekly"
@@ -333,18 +510,11 @@ def create_or_get_leaderboard(
333
  return file_id, leaderboard
334
 
335
  # Create new leaderboard
336
- index = _load_index(repo_id)
337
- if entry_type not in index:
338
- index[entry_type] = {}
339
- if period_id not in index[entry_type]:
340
- index[entry_type][period_id] = []
341
-
342
- # Get next file_id
343
- existing_ids = [e["file_id"] for e in index[entry_type][period_id]]
344
- file_id = max(existing_ids, default=-1) + 1
345
 
346
- # Create challenge_id (folder name)
347
- challenge_id = f"{period_id}-{file_id}"
348
 
349
  # Create new leaderboard
350
  leaderboard = LeaderboardSettings(
@@ -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: int,
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: int,
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
- # The challenge_id is the folder name, which includes the file_id
430
- parts = leaderboard.challenge_id.rsplit("-", 1)
431
- if len(parts) == 2 and parts[1].isdigit():
432
- period_id = parts[0]
433
  else:
434
- # Handle weekly format like "2025-W04-0"
435
- parts = leaderboard.challenge_id.rsplit("-", 1)
436
- period_id = parts[0] if len(parts) > 1 else leaderboard.challenge_id
437
 
438
  if leaderboard.entry_type == "daily":
439
  path = get_daily_leaderboard_path(period_id, file_id)
@@ -722,7 +883,7 @@ def list_available_periods(
722
  repo_id: Optional[str] = None
723
  ) -> List[str]:
724
  """
725
- List available period IDs from the index.
726
 
727
  Args:
728
  entry_type: "daily" or "weekly"
@@ -732,9 +893,7 @@ def list_available_periods(
732
  Returns:
733
  List of period IDs in reverse chronological order
734
  """
735
- index = _load_index(repo_id)
736
- periods = list(index.get(entry_type, {}).keys())
737
- periods.sort(reverse=True)
738
  return periods[:limit]
739
 
740
 
@@ -754,5 +913,19 @@ def list_settings_for_period(
754
  Returns:
755
  List of settings dictionaries with file_id
756
  """
757
- index = _load_index(repo_id)
758
- return index.get(entry_type, {}).get(period_id, [])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- list_available_leaderboards,
 
 
 
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 = " ??" if user.source_challenge_id else ""
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 Last updated: {leaderboard.created_at}")
121
  else:
122
- st.caption(f"{total_entries} entries Last updated: {leaderboard.created_at}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
 
124
 
125
  def render_leaderboard_page():
126
  """Render the full leaderboard page."""
127
  game_title = APP_SETTINGS.get("game_title", "Wrdler")
128
- st.title(f"?? {game_title} Leaderboards")
129
 
130
  # Tab selection
131
- tab1, tab2, tab3 = st.tabs(["?? Daily", "?? Weekly", "?? History"])
132
 
133
  with tab1:
134
  _render_daily_tab()
@@ -142,18 +159,22 @@ def render_leaderboard_page():
142
 
143
  def _render_daily_tab():
144
  """Render daily leaderboards tab."""
145
- st.header("?? Daily Leaderboards")
146
  st.write(f"Top {MAX_DISPLAY_ENTRIES} scores for each day. Resets at UTC midnight.")
147
 
 
 
 
 
148
  # Get last 7 days
149
- daily_boards = get_last_n_daily_leaderboards(7)
150
 
151
  for date_id, leaderboard in daily_boards:
152
  # Format date nicely
153
  try:
154
  date_obj = datetime.strptime(date_id, "%Y-%m-%d")
155
  if date_id == get_current_daily_id():
156
- title = f"?? Today ({date_obj.strftime('%B %d, %Y')})"
157
  else:
158
  title = date_obj.strftime("%A, %B %d, %Y")
159
  except ValueError:
@@ -165,11 +186,15 @@ def _render_daily_tab():
165
 
166
  def _render_weekly_tab():
167
  """Render weekly leaderboard tab."""
168
- st.header("?? Weekly Leaderboard")
169
  st.write(f"Top {MAX_DISPLAY_ENTRIES} scores for the current week. Resets Monday at UTC midnight.")
170
 
 
 
 
 
171
  weekly_id = get_current_weekly_id()
172
- leaderboard = load_leaderboard("weekly", weekly_id)
173
 
174
  # Parse week for display
175
  try:
@@ -178,48 +203,80 @@ def _render_weekly_tab():
178
  except ValueError:
179
  title = weekly_id
180
 
181
- _render_leaderboard_table(leaderboard, f"?? {title}")
182
 
183
 
184
  def _render_history_tab():
185
  """Render historical leaderboards tab."""
186
- st.header("?? Historical Leaderboards")
187
  st.write("Look up past leaderboards.")
188
 
189
  col1, col2 = st.columns(2)
190
 
191
  with col1:
192
  st.subheader("Daily History")
193
- daily_ids = list_available_leaderboards("daily", limit=30)
194
- selected_daily = st.selectbox(
195
- "Select a date",
196
- options=daily_ids,
197
- key="history_daily_select"
198
- )
199
-
200
- if st.button("Load Daily", key="load_daily"):
201
- leaderboard = load_leaderboard("daily", selected_daily)
202
- _render_leaderboard_table(leaderboard, f"Daily: {selected_daily}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
203
 
204
  with col2:
205
  st.subheader("Weekly History")
206
- weekly_ids = list_available_leaderboards("weekly", limit=20)
207
- selected_weekly = st.selectbox(
208
- "Select a week",
209
- options=weekly_ids,
210
- key="history_weekly_select"
211
- )
212
-
213
- if st.button("Load Weekly", key="load_weekly"):
214
- leaderboard = load_leaderboard("weekly", selected_weekly)
215
- _render_leaderboard_table(leaderboard, f"Weekly: {selected_weekly}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
216
 
217
 
218
  # Entry point for standalone testing
219
  if __name__ == "__main__":
220
  st.set_page_config(
221
  page_title="Wrdler Leaderboards",
222
- page_icon="??",
223
  layout="wide"
224
  )
225
  render_leaderboard_page()
 
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
- ### 5. `store_issuer_keypair(issuer_id, public_key, private_key, repo_id=None)`
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
- ### 6. `get_issuer_keypair(issuer_id, repo_id=None)`
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
- ### 7. `get_verification_methods_registry(repo_id=None)`
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
- ### 8. `list_issuer_ids(repo_id=None)`
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.5"
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
- if __name__ == "__main__":
696
- issuer_id = "https://example.edu/issuers/565049"
697
- # Example usage
698
- public_key, private_key = get_issuer_keypair(issuer_id)
699
- print(f"Public Key: {public_key}")
700
- print(f"Private Key: {private_key}")
701
 
702
- # Example to store keys
703
- store_issuer_keypair(issuer_id, public_key, private_key)
704
 
705
- # Example to list issuer IDs
706
- issuer_ids = list_issuer_ids()
707
 
708
- print(f"Issuer IDs: {issuer_ids}")
 
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}")