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