Skip to content
This repository was archived by the owner on Feb 1, 2026. It is now read-only.

Commit 39f8060

Browse files
Spacehunterzclaude
andcommitted
feat: Add global leaderboard with Cloudflare D1
- Add D1 database schema for players table - Add /leaderboard and /leaderboard/sync endpoints to worker - Update frontend to fetch from global Cloudflare API - Backend syncs scores to global leaderboard on every update - Auto-sync on auth to populate existing scores Scores now persist globally - users see each other on leaderboard without requiring the host's machine to be running. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 55d661d commit 39f8060

6 files changed

Lines changed: 224 additions & 65 deletions

File tree

apps/dashboard/backend/routers/game.py

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@
99
router = APIRouter(prefix="/api/game", tags=["game"])
1010

1111

12+
# =============================================================================
13+
# Global Leaderboard Configuration
14+
# =============================================================================
15+
16+
GLOBAL_LEADERBOARD_API = "https://elf-oauth.elf0auth.workers.dev/leaderboard/sync"
17+
18+
1219
# =============================================================================
1320
# Leaderboard Models
1421
# =============================================================================
@@ -327,19 +334,77 @@ async def sync_score(request: Request, payload: Dict[str, Any] = Body(...)):
327334
# Basic Anti-Cheat: Rate limiting / Delta checks could go here.
328335
# For now, we trust the client's score addition but log it.
329336
score_delta = payload.get("score_delta", 0)
330-
337+
331338
with get_db() as conn:
332339
cursor = conn.cursor()
333340
cursor.execute("SELECT score FROM game_state WHERE user_id = ?", (user_id,))
334341
current_score = cursor.fetchone()["score"]
335-
342+
336343
new_score = current_score + score_delta
337-
344+
338345
cursor.execute("UPDATE game_state SET score = ? WHERE user_id = ?", (new_score, user_id))
339346
conn.commit()
340-
347+
348+
# Sync to global leaderboard (non-blocking)
349+
try:
350+
token = request.cookies.get("session_token")
351+
if token:
352+
session = await get_session(token)
353+
if session and session.access_token:
354+
async with httpx.AsyncClient() as client:
355+
await client.post(
356+
GLOBAL_LEADERBOARD_API,
357+
json={"score": new_score},
358+
headers={"Authorization": f"Bearer {session.access_token}"},
359+
timeout=5.0
360+
)
361+
except Exception:
362+
pass
363+
341364
return {"success": True, "new_score": new_score}
342365

366+
@router.post("/sync-global")
367+
async def sync_to_global(request: Request):
368+
"""Manually sync current score to global leaderboard."""
369+
user_id = await get_user_id(request)
370+
if not user_id:
371+
return {"success": False, "message": "Login required"}
372+
373+
# Get current score from local DB
374+
with get_db() as conn:
375+
cursor = conn.cursor()
376+
cursor.execute("SELECT score FROM game_state WHERE user_id = ?", (user_id,))
377+
row = cursor.fetchone()
378+
if not row:
379+
return {"success": False, "message": "No game state found"}
380+
current_score = row["score"]
381+
382+
# Sync to global leaderboard
383+
try:
384+
token = request.cookies.get("session_token")
385+
if not token:
386+
return {"success": False, "message": "Session expired"}
387+
388+
session = await get_session(token)
389+
if not session or not session.access_token:
390+
return {"success": False, "message": "Session expired"}
391+
392+
async with httpx.AsyncClient() as client:
393+
res = await client.post(
394+
GLOBAL_LEADERBOARD_API,
395+
json={"score": current_score},
396+
headers={"Authorization": f"Bearer {session.access_token}"},
397+
timeout=5.0
398+
)
399+
if res.status_code == 200:
400+
data = res.json()
401+
return {"success": True, "rank": data.get("rank"), "score": current_score}
402+
else:
403+
return {"success": False, "message": "Global sync failed"}
404+
except Exception as e:
405+
return {"success": False, "message": str(e)}
406+
407+
343408
@router.post("/equip")
344409
async def equip_item(request: Request, payload: Dict[str, str] = Body(...)):
345410
"""Server-side equip (prevents client from forcing locked items)."""

apps/dashboard/frontend/src/components/game/Leaderboard.tsx

Lines changed: 9 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -44,21 +44,10 @@ export interface LeaderboardProps {
4444
}
4545

4646
// ============================================================================
47-
// Mock Data (for demonstration - remove in production)
47+
// API Configuration
4848
// ============================================================================
4949

50-
const MOCK_LEADERBOARD: LeaderboardEntry[] = [
51-
{ rank: 1, username: 'CosmicDestroyer', score: 2847500, level: 42, avatar_url: 'https://avatars.githubusercontent.com/u/1?v=4' },
52-
{ rank: 2, username: 'StarPilot_X', score: 2156000, level: 38, avatar_url: 'https://avatars.githubusercontent.com/u/2?v=4' },
53-
{ rank: 3, username: 'NebulaCrusher', score: 1892300, level: 35, avatar_url: 'https://avatars.githubusercontent.com/u/3?v=4' },
54-
{ rank: 4, username: 'VoidWalker99', score: 1654200, level: 32 },
55-
{ rank: 5, username: 'PhotonRider', score: 1423100, level: 29, avatar_url: 'https://avatars.githubusercontent.com/u/5?v=4' },
56-
{ rank: 6, username: 'GalacticAce', score: 1287600, level: 27 },
57-
{ rank: 7, username: 'AsteroidHunter', score: 1098500, level: 24, avatar_url: 'https://avatars.githubusercontent.com/u/7?v=4' },
58-
{ rank: 8, username: 'WarpDriveMaster', score: 956400, level: 22 },
59-
{ rank: 9, username: 'CometChaser', score: 823700, level: 20, avatar_url: 'https://avatars.githubusercontent.com/u/9?v=4' },
60-
{ rank: 10, username: 'SolarFlareKid', score: 712300, level: 18 },
61-
];
50+
const GLOBAL_LEADERBOARD_API = 'https://elf-oauth.elf0auth.workers.dev/leaderboard';
6251

6352
// ============================================================================
6453
// Utility Functions
@@ -365,21 +354,13 @@ export const Leaderboard: React.FC<LeaderboardProps> = ({
365354
const data = await fetchLeaderboard();
366355
setEntries(data);
367356
} else {
368-
// Try to fetch from API, fallback to mock data
369-
try {
370-
const res = await fetch('/api/game/leaderboard', {
371-
credentials: 'include'
372-
});
373-
if (res.ok) {
374-
const data = await res.json();
375-
setEntries(data.entries || []);
376-
} else {
377-
// Use mock data for demo
378-
setEntries(MOCK_LEADERBOARD);
379-
}
380-
} catch {
381-
// Use mock data for demo
382-
setEntries(MOCK_LEADERBOARD);
357+
// Fetch from global leaderboard API
358+
const res = await fetch(GLOBAL_LEADERBOARD_API);
359+
if (res.ok) {
360+
const data = await res.json();
361+
setEntries(data.entries || []);
362+
} else {
363+
throw new Error('Failed to fetch leaderboard');
383364
}
384365
}
385366
} catch (err) {

apps/dashboard/frontend/src/context/GameContext.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,12 @@ export const GameProvider: React.FC<{ children: React.ReactNode }> = ({ children
9494
talkinheadUnlocked: gameData.talkinhead_unlocked || false,
9595
talkinheadAutolaunch: gameData.talkinhead_autolaunch || false,
9696
}));
97+
98+
// 3. Sync to global leaderboard (non-blocking)
99+
fetch('http://localhost:8888/api/game/sync-global', {
100+
method: 'POST',
101+
credentials: 'include'
102+
}).catch(() => {/* Ignore errors - global sync is best-effort */});
97103
}
98104
} catch (err) {
99105
console.error("Failed to sync game state:", err);

infra/cloudflare-worker/schema.sql

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
CREATE TABLE IF NOT EXISTS players (
2+
github_id INTEGER PRIMARY KEY,
3+
username TEXT NOT NULL,
4+
avatar_url TEXT,
5+
score INTEGER DEFAULT 0,
6+
created_at TEXT DEFAULT (datetime('now')),
7+
updated_at TEXT DEFAULT (datetime('now'))
8+
);
9+
10+
CREATE INDEX IF NOT EXISTS idx_players_score ON players(score DESC);

infra/cloudflare-worker/worker.js

Lines changed: 125 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,19 @@ function corsHeaders(origin) {
88
const allowedOrigin = ALLOWED_ORIGINS.includes(origin) ? origin : ALLOWED_ORIGINS[0];
99
return {
1010
'Access-Control-Allow-Origin': allowedOrigin,
11-
'Access-Control-Allow-Methods': 'POST, OPTIONS',
12-
'Access-Control-Allow-Headers': 'Content-Type',
11+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
12+
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
13+
'Access-Control-Allow-Credentials': 'true',
1314
};
1415
}
1516

17+
function jsonResponse(data, status = 200, origin = '') {
18+
return new Response(JSON.stringify(data), {
19+
status,
20+
headers: { ...corsHeaders(origin), 'Content-Type': 'application/json' }
21+
});
22+
}
23+
1624
export default {
1725
async fetch(request, env) {
1826
const origin = request.headers.get('Origin') || '';
@@ -24,29 +32,25 @@ export default {
2432
const url = new URL(request.url);
2533

2634
if (url.pathname === '/oauth/config' && request.method === 'GET') {
27-
return new Response(JSON.stringify({
35+
return jsonResponse({
2836
client_id: env.GITHUB_CLIENT_ID,
2937
redirect_uri: env.OAUTH_REDIRECT_URI || 'http://localhost:8888/api/auth/callback'
30-
}), {
31-
headers: { ...corsHeaders(origin), 'Content-Type': 'application/json' }
32-
});
38+
}, 200, origin);
3339
}
3440

35-
if (request.method !== 'POST') {
36-
return new Response(JSON.stringify({ error: 'Method not allowed' }), {
37-
status: 405,
38-
headers: { ...corsHeaders(origin), 'Content-Type': 'application/json' }
39-
});
41+
if (url.pathname === '/oauth/token' && request.method === 'POST') {
42+
return handleTokenExchange(request, env, origin);
4043
}
4144

42-
if (url.pathname === '/oauth/token') {
43-
return handleTokenExchange(request, env, origin);
45+
if (url.pathname === '/leaderboard' && request.method === 'GET') {
46+
return handleGetLeaderboard(request, env, origin);
4447
}
4548

46-
return new Response(JSON.stringify({ error: 'Not found' }), {
47-
status: 404,
48-
headers: { ...corsHeaders(origin), 'Content-Type': 'application/json' }
49-
});
49+
if (url.pathname === '/leaderboard/sync' && request.method === 'POST') {
50+
return handleSyncScore(request, env, origin);
51+
}
52+
53+
return jsonResponse({ error: 'Not found' }, 404, origin);
5054
}
5155
};
5256

@@ -56,10 +60,7 @@ async function handleTokenExchange(request, env, origin) {
5660
const { code, redirect_uri } = body;
5761

5862
if (!code) {
59-
return new Response(JSON.stringify({ error: 'Missing code parameter' }), {
60-
status: 400,
61-
headers: { ...corsHeaders(origin), 'Content-Type': 'application/json' }
62-
});
63+
return jsonResponse({ error: 'Missing code parameter' }, 400, origin);
6364
}
6465

6566
const tokenResponse = await fetch('https://github.com/login/oauth/access_token', {
@@ -79,27 +80,118 @@ async function handleTokenExchange(request, env, origin) {
7980
const tokenData = await tokenResponse.json();
8081

8182
if (tokenData.error) {
82-
return new Response(JSON.stringify({
83+
return jsonResponse({
8384
error: tokenData.error,
8485
error_description: tokenData.error_description
85-
}), {
86-
status: 400,
87-
headers: { ...corsHeaders(origin), 'Content-Type': 'application/json' }
88-
});
86+
}, 400, origin);
8987
}
9088

91-
return new Response(JSON.stringify({
89+
return jsonResponse({
9290
access_token: tokenData.access_token,
9391
token_type: tokenData.token_type,
9492
scope: tokenData.scope
95-
}), {
96-
headers: { ...corsHeaders(origin), 'Content-Type': 'application/json' }
97-
});
93+
}, 200, origin);
94+
95+
} catch (error) {
96+
return jsonResponse({ error: 'Token exchange failed' }, 500, origin);
97+
}
98+
}
99+
100+
async function handleGetLeaderboard(request, env, origin) {
101+
try {
102+
const url = new URL(request.url);
103+
const limit = Math.min(parseInt(url.searchParams.get('limit') || '10'), 100);
104+
const offset = parseInt(url.searchParams.get('offset') || '0');
105+
106+
const result = await env.DB.prepare(`
107+
SELECT github_id, username, avatar_url, score,
108+
DENSE_RANK() OVER (ORDER BY score DESC) as rank
109+
FROM players
110+
WHERE score > 0
111+
ORDER BY score DESC
112+
LIMIT ? OFFSET ?
113+
`).bind(limit, offset).all();
114+
115+
const countResult = await env.DB.prepare(
116+
'SELECT COUNT(*) as total FROM players WHERE score > 0'
117+
).first();
118+
119+
return jsonResponse({
120+
entries: result.results.map(row => ({
121+
rank: row.rank,
122+
github_id: row.github_id,
123+
username: row.username,
124+
avatar_url: row.avatar_url,
125+
score: row.score
126+
})),
127+
total_players: countResult?.total || 0,
128+
has_more: (offset + limit) < (countResult?.total || 0)
129+
}, 200, origin);
98130

99131
} catch (error) {
100-
return new Response(JSON.stringify({ error: 'Token exchange failed' }), {
101-
status: 500,
102-
headers: { ...corsHeaders(origin), 'Content-Type': 'application/json' }
132+
return jsonResponse({ error: 'Failed to fetch leaderboard', details: error.message }, 500, origin);
133+
}
134+
}
135+
136+
async function handleSyncScore(request, env, origin) {
137+
try {
138+
const authHeader = request.headers.get('Authorization');
139+
if (!authHeader || !authHeader.startsWith('Bearer ')) {
140+
return jsonResponse({ error: 'Missing authorization' }, 401, origin);
141+
}
142+
143+
const accessToken = authHeader.substring(7);
144+
145+
const userResponse = await fetch('https://api.github.com/user', {
146+
headers: {
147+
'Authorization': `token ${accessToken}`,
148+
'Accept': 'application/vnd.github+json',
149+
'User-Agent': 'ELF-Leaderboard'
150+
}
103151
});
152+
153+
if (!userResponse.ok) {
154+
return jsonResponse({ error: 'Invalid token' }, 401, origin);
155+
}
156+
157+
const userData = await userResponse.json();
158+
const body = await request.json();
159+
const { score } = body;
160+
161+
if (typeof score !== 'number' || score < 0) {
162+
return jsonResponse({ error: 'Invalid score' }, 400, origin);
163+
}
164+
165+
const existing = await env.DB.prepare(
166+
'SELECT score FROM players WHERE github_id = ?'
167+
).bind(userData.id).first();
168+
169+
if (existing) {
170+
if (score > existing.score) {
171+
await env.DB.prepare(`
172+
UPDATE players SET score = ?, username = ?, avatar_url = ?, updated_at = datetime('now')
173+
WHERE github_id = ?
174+
`).bind(score, userData.login, userData.avatar_url, userData.id).run();
175+
}
176+
} else {
177+
await env.DB.prepare(`
178+
INSERT INTO players (github_id, username, avatar_url, score)
179+
VALUES (?, ?, ?, ?)
180+
`).bind(userData.id, userData.login, userData.avatar_url, score).run();
181+
}
182+
183+
const rankResult = await env.DB.prepare(`
184+
SELECT COUNT(*) + 1 as rank FROM players WHERE score > ?
185+
`).bind(score).first();
186+
187+
return jsonResponse({
188+
success: true,
189+
score: score,
190+
rank: rankResult?.rank || 1,
191+
username: userData.login
192+
}, 200, origin);
193+
194+
} catch (error) {
195+
return jsonResponse({ error: 'Sync failed', details: error.message }, 500, origin);
104196
}
105197
}

infra/cloudflare-worker/wrangler.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,8 @@ compatibility_date = "2024-01-01"
44

55
[vars]
66
OAUTH_REDIRECT_URI = "http://localhost:8888/api/auth/callback"
7+
8+
[[d1_databases]]
9+
binding = "DB"
10+
database_name = "elf-leaderboard"
11+
database_id = "a4a602bb-9f75-4c50-94f4-6a0daaaa2660"

0 commit comments

Comments
 (0)