golfgame/docs/v2/V2_05_STATS_LEADERBOARDS.md
Aaron D. Lee bea85e6b28 Huge v2 uplift, now deployable with real user management and tooling!
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 11:32:15 -05:00

872 lines
28 KiB
Markdown

# V2-05: Stats & Leaderboards
## Overview
This document covers player statistics aggregation and leaderboard systems.
**Dependencies:** V2-03 (User Accounts), V2-01 (Events for aggregation)
**Dependents:** None (end feature)
---
## Goals
1. Aggregate player statistics from game events
2. Create leaderboard views (by wins, by average score, etc.)
3. Background worker for stats processing
4. Leaderboard API endpoints
5. Leaderboard UI in client
6. Achievement/badge system (stretch goal)
---
## Database Schema
```sql
-- migrations/versions/004_stats_leaderboards.sql
-- Player statistics (aggregated from events)
CREATE TABLE player_stats (
user_id UUID PRIMARY KEY REFERENCES users(id),
-- Game counts
games_played INT DEFAULT 0,
games_won INT DEFAULT 0,
games_vs_humans INT DEFAULT 0,
games_won_vs_humans INT DEFAULT 0,
-- Round stats
rounds_played INT DEFAULT 0,
rounds_won INT DEFAULT 0,
total_points INT DEFAULT 0, -- Sum of all round scores (lower is better)
-- Best/worst
best_round_score INT,
worst_round_score INT,
best_game_score INT, -- Lowest total in a game
-- Achievements
knockouts INT DEFAULT 0, -- Times going out first
perfect_rounds INT DEFAULT 0, -- Score of 0 or less
wolfpacks INT DEFAULT 0, -- Four jacks achieved
-- Streaks
current_win_streak INT DEFAULT 0,
best_win_streak INT DEFAULT 0,
-- Timestamps
first_game_at TIMESTAMPTZ,
last_game_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Stats processing queue (for background worker)
CREATE TABLE stats_queue (
id BIGSERIAL PRIMARY KEY,
game_id UUID NOT NULL,
status VARCHAR(20) DEFAULT 'pending', -- pending, processing, completed, failed
created_at TIMESTAMPTZ DEFAULT NOW(),
processed_at TIMESTAMPTZ,
error_message TEXT
);
-- Leaderboard cache (refreshed periodically)
CREATE MATERIALIZED VIEW leaderboard_overall AS
SELECT
u.id as user_id,
u.username,
s.games_played,
s.games_won,
ROUND(s.games_won::numeric / NULLIF(s.games_played, 0) * 100, 1) as win_rate,
s.rounds_won,
ROUND(s.total_points::numeric / NULLIF(s.rounds_played, 0), 1) as avg_score,
s.best_round_score,
s.knockouts,
s.best_win_streak,
s.last_game_at
FROM player_stats s
JOIN users u ON s.user_id = u.id
WHERE s.games_played >= 5 -- Minimum games for ranking
AND u.deleted_at IS NULL
AND u.is_banned = false;
CREATE UNIQUE INDEX idx_leaderboard_overall_user ON leaderboard_overall(user_id);
CREATE INDEX idx_leaderboard_overall_wins ON leaderboard_overall(games_won DESC);
CREATE INDEX idx_leaderboard_overall_rate ON leaderboard_overall(win_rate DESC);
CREATE INDEX idx_leaderboard_overall_score ON leaderboard_overall(avg_score ASC);
-- Achievements/badges
CREATE TABLE achievements (
id VARCHAR(50) PRIMARY KEY,
name VARCHAR(100) NOT NULL,
description TEXT,
icon VARCHAR(50),
category VARCHAR(50), -- games, rounds, special
threshold INT, -- e.g., 10 for "Win 10 games"
sort_order INT DEFAULT 0
);
CREATE TABLE user_achievements (
user_id UUID REFERENCES users(id),
achievement_id VARCHAR(50) REFERENCES achievements(id),
earned_at TIMESTAMPTZ DEFAULT NOW(),
game_id UUID, -- Game where it was earned (optional)
PRIMARY KEY (user_id, achievement_id)
);
-- Seed achievements
INSERT INTO achievements (id, name, description, icon, category, threshold, sort_order) VALUES
('first_win', 'First Victory', 'Win your first game', '🏆', 'games', 1, 1),
('win_10', 'Rising Star', 'Win 10 games', '⭐', 'games', 10, 2),
('win_50', 'Veteran', 'Win 50 games', '🎖️', 'games', 50, 3),
('win_100', 'Champion', 'Win 100 games', '👑', 'games', 100, 4),
('perfect_round', 'Perfect', 'Score 0 or less in a round', '💎', 'rounds', 1, 10),
('negative_round', 'Below Zero', 'Score negative in a round', '❄️', 'rounds', 1, 11),
('knockout_10', 'Closer', 'Go out first 10 times', '🚪', 'special', 10, 20),
('wolfpack', 'Wolfpack', 'Get all 4 Jacks', '🐺', 'special', 1, 21),
('streak_5', 'Hot Streak', 'Win 5 games in a row', '🔥', 'special', 5, 30),
('streak_10', 'Unstoppable', 'Win 10 games in a row', '⚡', 'special', 10, 31);
-- Indexes
CREATE INDEX idx_stats_queue_pending ON stats_queue(status, created_at)
WHERE status = 'pending';
CREATE INDEX idx_user_achievements_user ON user_achievements(user_id);
```
---
## Stats Service
```python
# server/services/stats_service.py
from dataclasses import dataclass
from typing import Optional, List
from datetime import datetime
import asyncpg
from stores.event_store import EventStore
from models.events import EventType
@dataclass
class PlayerStats:
user_id: str
username: str
games_played: int
games_won: int
win_rate: float
rounds_played: int
rounds_won: int
avg_score: float
best_round_score: Optional[int]
knockouts: int
best_win_streak: int
achievements: List[str]
@dataclass
class LeaderboardEntry:
rank: int
user_id: str
username: str
value: float # The metric being ranked by
games_played: int
secondary_value: Optional[float] = None
class StatsService:
"""Player statistics and leaderboards."""
def __init__(self, db_pool: asyncpg.Pool, event_store: EventStore):
self.db = db_pool
self.event_store = event_store
# --- Stats Queries ---
async def get_player_stats(self, user_id: str) -> Optional[PlayerStats]:
"""Get stats for a specific player."""
async with self.db.acquire() as conn:
row = await conn.fetchrow("""
SELECT s.*, u.username,
ROUND(s.games_won::numeric / NULLIF(s.games_played, 0) * 100, 1) as win_rate,
ROUND(s.total_points::numeric / NULLIF(s.rounds_played, 0), 1) as avg_score
FROM player_stats s
JOIN users u ON s.user_id = u.id
WHERE s.user_id = $1
""", user_id)
if not row:
return None
# Get achievements
achievements = await conn.fetch("""
SELECT achievement_id FROM user_achievements
WHERE user_id = $1
""", user_id)
return PlayerStats(
user_id=row["user_id"],
username=row["username"],
games_played=row["games_played"],
games_won=row["games_won"],
win_rate=float(row["win_rate"] or 0),
rounds_played=row["rounds_played"],
rounds_won=row["rounds_won"],
avg_score=float(row["avg_score"] or 0),
best_round_score=row["best_round_score"],
knockouts=row["knockouts"],
best_win_streak=row["best_win_streak"],
achievements=[a["achievement_id"] for a in achievements],
)
async def get_leaderboard(
self,
metric: str = "wins",
limit: int = 50,
offset: int = 0,
) -> List[LeaderboardEntry]:
"""
Get leaderboard by metric.
Metrics: wins, win_rate, avg_score, knockouts, streak
"""
order_map = {
"wins": ("games_won", "DESC"),
"win_rate": ("win_rate", "DESC"),
"avg_score": ("avg_score", "ASC"), # Lower is better
"knockouts": ("knockouts", "DESC"),
"streak": ("best_win_streak", "DESC"),
}
if metric not in order_map:
metric = "wins"
column, direction = order_map[metric]
async with self.db.acquire() as conn:
# Use materialized view for performance
rows = await conn.fetch(f"""
SELECT
user_id, username, games_played, games_won,
win_rate, avg_score, knockouts, best_win_streak,
ROW_NUMBER() OVER (ORDER BY {column} {direction}) as rank
FROM leaderboard_overall
ORDER BY {column} {direction}
LIMIT $1 OFFSET $2
""", limit, offset)
return [
LeaderboardEntry(
rank=row["rank"],
user_id=row["user_id"],
username=row["username"],
value=float(row[column] or 0),
games_played=row["games_played"],
secondary_value=float(row["win_rate"] or 0) if metric != "win_rate" else None,
)
for row in rows
]
async def get_player_rank(self, user_id: str, metric: str = "wins") -> Optional[int]:
"""Get a player's rank on a leaderboard."""
order_map = {
"wins": ("games_won", "DESC"),
"win_rate": ("win_rate", "DESC"),
"avg_score": ("avg_score", "ASC"),
}
if metric not in order_map:
return None
column, direction = order_map[metric]
async with self.db.acquire() as conn:
row = await conn.fetchrow(f"""
SELECT rank FROM (
SELECT user_id, ROW_NUMBER() OVER (ORDER BY {column} {direction}) as rank
FROM leaderboard_overall
) ranked
WHERE user_id = $1
""", user_id)
return row["rank"] if row else None
async def refresh_leaderboard(self) -> None:
"""Refresh the materialized view."""
async with self.db.acquire() as conn:
await conn.execute("REFRESH MATERIALIZED VIEW CONCURRENTLY leaderboard_overall")
# --- Achievement Queries ---
async def get_achievements(self) -> List[dict]:
"""Get all available achievements."""
async with self.db.acquire() as conn:
rows = await conn.fetch("""
SELECT id, name, description, icon, category, threshold
FROM achievements
ORDER BY sort_order
""")
return [dict(row) for row in rows]
async def get_user_achievements(self, user_id: str) -> List[dict]:
"""Get achievements earned by a user."""
async with self.db.acquire() as conn:
rows = await conn.fetch("""
SELECT a.id, a.name, a.description, a.icon, ua.earned_at
FROM user_achievements ua
JOIN achievements a ON ua.achievement_id = a.id
WHERE ua.user_id = $1
ORDER BY ua.earned_at DESC
""", user_id)
return [dict(row) for row in rows]
# --- Stats Processing ---
async def process_game_end(self, game_id: str) -> None:
"""
Process a completed game and update player stats.
Called by background worker or directly after game ends.
"""
# Get game events
events = await self.event_store.get_events(game_id)
if not events:
return
# Extract game data from events
game_data = self._extract_game_data(events)
if not game_data:
return
async with self.db.acquire() as conn:
async with conn.transaction():
for player_id, player_data in game_data["players"].items():
# Skip CPU players (they don't have user accounts)
if player_data.get("is_cpu"):
continue
# Ensure stats row exists
await conn.execute("""
INSERT INTO player_stats (user_id)
VALUES ($1)
ON CONFLICT (user_id) DO NOTHING
""", player_id)
# Update stats
is_winner = player_id == game_data["winner_id"]
total_score = player_data["total_score"]
rounds_won = player_data["rounds_won"]
await conn.execute("""
UPDATE player_stats SET
games_played = games_played + 1,
games_won = games_won + $2,
rounds_played = rounds_played + $3,
rounds_won = rounds_won + $4,
total_points = total_points + $5,
knockouts = knockouts + $6,
best_round_score = LEAST(best_round_score, $7),
worst_round_score = GREATEST(worst_round_score, $8),
best_game_score = LEAST(best_game_score, $5),
current_win_streak = CASE WHEN $2 = 1 THEN current_win_streak + 1 ELSE 0 END,
best_win_streak = GREATEST(best_win_streak,
CASE WHEN $2 = 1 THEN current_win_streak + 1 ELSE best_win_streak END),
first_game_at = COALESCE(first_game_at, NOW()),
last_game_at = NOW(),
updated_at = NOW()
WHERE user_id = $1
""",
player_id,
1 if is_winner else 0,
game_data["num_rounds"],
rounds_won,
total_score,
player_data.get("knockouts", 0),
player_data.get("best_round", total_score),
player_data.get("worst_round", total_score),
)
# Check for new achievements
await self._check_achievements(conn, player_id, game_id, player_data, is_winner)
def _extract_game_data(self, events) -> Optional[dict]:
"""Extract game data from events."""
data = {
"players": {},
"num_rounds": 0,
"winner_id": None,
}
for event in events:
if event.event_type == EventType.PLAYER_JOINED:
data["players"][event.player_id] = {
"is_cpu": event.data.get("is_cpu", False),
"total_score": 0,
"rounds_won": 0,
"knockouts": 0,
"best_round": None,
"worst_round": None,
}
elif event.event_type == EventType.ROUND_ENDED:
data["num_rounds"] += 1
scores = event.data.get("scores", {})
winner_id = event.data.get("winner_id")
for player_id, score in scores.items():
if player_id in data["players"]:
p = data["players"][player_id]
p["total_score"] += score
if p["best_round"] is None or score < p["best_round"]:
p["best_round"] = score
if p["worst_round"] is None or score > p["worst_round"]:
p["worst_round"] = score
if player_id == winner_id:
p["rounds_won"] += 1
# Track who went out first (finisher)
# This would need to be tracked in events
elif event.event_type == EventType.GAME_ENDED:
data["winner_id"] = event.data.get("winner_id")
return data if data["num_rounds"] > 0 else None
async def _check_achievements(
self,
conn: asyncpg.Connection,
user_id: str,
game_id: str,
player_data: dict,
is_winner: bool,
) -> List[str]:
"""Check and award new achievements."""
new_achievements = []
# Get current stats
stats = await conn.fetchrow("""
SELECT games_won, knockouts, best_win_streak, current_win_streak
FROM player_stats
WHERE user_id = $1
""", user_id)
if not stats:
return []
# Get already earned achievements
earned = await conn.fetch("""
SELECT achievement_id FROM user_achievements WHERE user_id = $1
""", user_id)
earned_ids = {e["achievement_id"] for e in earned}
# Check win milestones
wins = stats["games_won"]
if wins >= 1 and "first_win" not in earned_ids:
new_achievements.append("first_win")
if wins >= 10 and "win_10" not in earned_ids:
new_achievements.append("win_10")
if wins >= 50 and "win_50" not in earned_ids:
new_achievements.append("win_50")
if wins >= 100 and "win_100" not in earned_ids:
new_achievements.append("win_100")
# Check streak achievements
streak = stats["current_win_streak"]
if streak >= 5 and "streak_5" not in earned_ids:
new_achievements.append("streak_5")
if streak >= 10 and "streak_10" not in earned_ids:
new_achievements.append("streak_10")
# Check knockout achievements
if stats["knockouts"] >= 10 and "knockout_10" not in earned_ids:
new_achievements.append("knockout_10")
# Check round-specific achievements
if player_data.get("best_round") is not None:
if player_data["best_round"] <= 0 and "perfect_round" not in earned_ids:
new_achievements.append("perfect_round")
if player_data["best_round"] < 0 and "negative_round" not in earned_ids:
new_achievements.append("negative_round")
# Award new achievements
for achievement_id in new_achievements:
await conn.execute("""
INSERT INTO user_achievements (user_id, achievement_id, game_id)
VALUES ($1, $2, $3)
ON CONFLICT DO NOTHING
""", user_id, achievement_id, game_id)
return new_achievements
```
---
## Background Worker
```python
# server/workers/stats_worker.py
import asyncio
from datetime import datetime, timedelta
import asyncpg
from arq import create_pool
from arq.connections import RedisSettings
from services.stats_service import StatsService
from stores.event_store import EventStore
async def process_stats_queue(ctx):
"""Process pending games in the stats queue."""
db: asyncpg.Pool = ctx["db_pool"]
stats_service: StatsService = ctx["stats_service"]
async with db.acquire() as conn:
# Get pending games
games = await conn.fetch("""
SELECT id, game_id FROM stats_queue
WHERE status = 'pending'
ORDER BY created_at
LIMIT 100
""")
for game in games:
try:
# Mark as processing
await conn.execute("""
UPDATE stats_queue SET status = 'processing' WHERE id = $1
""", game["id"])
# Process
await stats_service.process_game_end(game["game_id"])
# Mark complete
await conn.execute("""
UPDATE stats_queue
SET status = 'completed', processed_at = NOW()
WHERE id = $1
""", game["id"])
except Exception as e:
# Mark failed
await conn.execute("""
UPDATE stats_queue
SET status = 'failed', error_message = $2
WHERE id = $1
""", game["id"], str(e))
async def refresh_leaderboard(ctx):
"""Refresh the materialized leaderboard view."""
stats_service: StatsService = ctx["stats_service"]
await stats_service.refresh_leaderboard()
async def cleanup_old_queue_entries(ctx):
"""Clean up old processed queue entries."""
db: asyncpg.Pool = ctx["db_pool"]
async with db.acquire() as conn:
await conn.execute("""
DELETE FROM stats_queue
WHERE status IN ('completed', 'failed')
AND processed_at < NOW() - INTERVAL '7 days'
""")
class WorkerSettings:
"""arq worker settings."""
functions = [
process_stats_queue,
refresh_leaderboard,
cleanup_old_queue_entries,
]
cron_jobs = [
# Process queue every minute
cron(process_stats_queue, minute={0, 15, 30, 45}),
# Refresh leaderboard every 5 minutes
cron(refresh_leaderboard, minute={0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55}),
# Cleanup daily
cron(cleanup_old_queue_entries, hour=3, minute=0),
]
redis_settings = RedisSettings()
@staticmethod
async def on_startup(ctx):
"""Initialize worker context."""
ctx["db_pool"] = await asyncpg.create_pool(DATABASE_URL)
ctx["event_store"] = EventStore(ctx["db_pool"])
ctx["stats_service"] = StatsService(ctx["db_pool"], ctx["event_store"])
@staticmethod
async def on_shutdown(ctx):
"""Cleanup worker context."""
await ctx["db_pool"].close()
```
---
## API Endpoints
```python
# server/routers/stats.py
from fastapi import APIRouter, Depends, Query
from typing import Optional
router = APIRouter(prefix="/api/stats", tags=["stats"])
@router.get("/leaderboard")
async def get_leaderboard(
metric: str = Query("wins", regex="^(wins|win_rate|avg_score|knockouts|streak)$"),
limit: int = Query(50, ge=1, le=100),
offset: int = Query(0, ge=0),
service: StatsService = Depends(get_stats_service),
):
"""Get leaderboard by metric."""
entries = await service.get_leaderboard(metric, limit, offset)
return {
"metric": metric,
"entries": [
{
"rank": e.rank,
"user_id": e.user_id,
"username": e.username,
"value": e.value,
"games_played": e.games_played,
}
for e in entries
],
}
@router.get("/players/{user_id}")
async def get_player_stats(
user_id: str,
service: StatsService = Depends(get_stats_service),
):
"""Get stats for a specific player."""
stats = await service.get_player_stats(user_id)
if not stats:
raise HTTPException(status_code=404, detail="Player not found")
return {
"user_id": stats.user_id,
"username": stats.username,
"games_played": stats.games_played,
"games_won": stats.games_won,
"win_rate": stats.win_rate,
"rounds_played": stats.rounds_played,
"rounds_won": stats.rounds_won,
"avg_score": stats.avg_score,
"best_round_score": stats.best_round_score,
"knockouts": stats.knockouts,
"best_win_streak": stats.best_win_streak,
"achievements": stats.achievements,
}
@router.get("/players/{user_id}/rank")
async def get_player_rank(
user_id: str,
metric: str = "wins",
service: StatsService = Depends(get_stats_service),
):
"""Get player's rank on a leaderboard."""
rank = await service.get_player_rank(user_id, metric)
return {"user_id": user_id, "metric": metric, "rank": rank}
@router.get("/me")
async def get_my_stats(
user: User = Depends(get_current_user),
service: StatsService = Depends(get_stats_service),
):
"""Get current user's stats."""
stats = await service.get_player_stats(user.id)
if not stats:
return {
"games_played": 0,
"games_won": 0,
"achievements": [],
}
return stats.__dict__
@router.get("/achievements")
async def get_achievements(
service: StatsService = Depends(get_stats_service),
):
"""Get all available achievements."""
return {"achievements": await service.get_achievements()}
@router.get("/players/{user_id}/achievements")
async def get_user_achievements(
user_id: str,
service: StatsService = Depends(get_stats_service),
):
"""Get achievements earned by a player."""
return {"achievements": await service.get_user_achievements(user_id)}
```
---
## Frontend Integration
```javascript
// client/components/leaderboard.js
class LeaderboardComponent {
constructor(container) {
this.container = container;
this.metric = 'wins';
this.render();
}
async fetchLeaderboard() {
const response = await fetch(`/api/stats/leaderboard?metric=${this.metric}&limit=50`);
return response.json();
}
async render() {
const data = await this.fetchLeaderboard();
this.container.innerHTML = `
<div class="leaderboard">
<div class="leaderboard-tabs">
<button class="tab ${this.metric === 'wins' ? 'active' : ''}" data-metric="wins">Wins</button>
<button class="tab ${this.metric === 'win_rate' ? 'active' : ''}" data-metric="win_rate">Win Rate</button>
<button class="tab ${this.metric === 'avg_score' ? 'active' : ''}" data-metric="avg_score">Avg Score</button>
</div>
<table class="leaderboard-table">
<thead>
<tr>
<th>#</th>
<th>Player</th>
<th>${this.getMetricLabel()}</th>
<th>Games</th>
</tr>
</thead>
<tbody>
${data.entries.map(e => `
<tr>
<td class="rank">${this.getRankBadge(e.rank)}</td>
<td class="username">${e.username}</td>
<td class="value">${this.formatValue(e.value)}</td>
<td class="games">${e.games_played}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
// Bind tab clicks
this.container.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
this.metric = tab.dataset.metric;
this.render();
});
});
}
getMetricLabel() {
const labels = {
wins: 'Wins',
win_rate: 'Win %',
avg_score: 'Avg Score',
};
return labels[this.metric] || this.metric;
}
formatValue(value) {
if (this.metric === 'win_rate') return `${value}%`;
if (this.metric === 'avg_score') return value.toFixed(1);
return value;
}
getRankBadge(rank) {
if (rank === 1) return '🥇';
if (rank === 2) return '🥈';
if (rank === 3) return '🥉';
return rank;
}
}
```
---
## Acceptance Criteria
1. **Stats Aggregation**
- [ ] Stats calculated from game events
- [ ] Games played/won tracked
- [ ] Rounds played/won tracked
- [ ] Best/worst scores tracked
- [ ] Win streaks tracked
- [ ] Knockouts tracked
2. **Leaderboards**
- [ ] Leaderboard by wins
- [ ] Leaderboard by win rate
- [ ] Leaderboard by average score
- [ ] Minimum games requirement
- [ ] Pagination working
- [ ] Materialized view refreshes
3. **Background Worker**
- [ ] Queue processing works
- [ ] Failed jobs retried
- [ ] Leaderboard auto-refreshes
- [ ] Old entries cleaned up
4. **Achievements**
- [ ] Achievement definitions in DB
- [ ] Achievements awarded correctly
- [ ] Achievement progress tracked
- [ ] Achievement UI displays
5. **API**
- [ ] GET /leaderboard works
- [ ] GET /players/{id} works
- [ ] GET /me works
- [ ] GET /achievements works
6. **UI**
- [ ] Leaderboard displays
- [ ] Tabs switch metrics
- [ ] Player profiles show stats
- [ ] Achievements display
---
## Implementation Order
1. Create database migrations
2. Implement stats processing logic
3. Add stats queue integration
4. Set up background worker
5. Implement leaderboard queries
6. Create API endpoints
7. Build leaderboard UI
8. Add achievements system
9. Test full flow
---
## Notes
- Materialized views are great for leaderboards but need periodic refresh
- Consider caching hot leaderboard data in Redis
- Achievement checking should be efficient (batch checks)
- Stats processing is async - don't block game completion
- Consider separate "vs humans only" stats in future