Add cloud leaderboard with Supabase integration
All checks were successful
Build / build (push) Successful in 1m39s

- Add Supabase service and cloud leaderboard service for score submission
- Fix Auth0 JWT by adding audience parameter for Supabase RLS compatibility
- Fix BabylonJS shader loading by adding @babylonjs/materials to Vite pre-bundle
- Update CI workflow with Supabase and Auth0 audience secrets

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Michael Mainguy 2025-11-26 08:59:55 -06:00
parent 0c03253c9a
commit a3e17c95db
10 changed files with 591 additions and 15 deletions

View File

@ -23,7 +23,11 @@ jobs:
NODE_OPTIONS: '--max-old-space-size=4096' NODE_OPTIONS: '--max-old-space-size=4096'
VITE_AUTH0_DOMAIN: ${{ secrets.VITE_AUTH0_DOMAIN }} VITE_AUTH0_DOMAIN: ${{ secrets.VITE_AUTH0_DOMAIN }}
VITE_AUTH0_CLIENT_ID: ${{ secrets.VITE_AUTH0_CLIENT_ID }} VITE_AUTH0_CLIENT_ID: ${{ secrets.VITE_AUTH0_CLIENT_ID }}
VITE_AUTH0_AUDIENCE: ${{ secrets.VITE_AUTH0_AUDIENCE }}
AUTH0_CLIENT_SECRET: ${{ secrets.AUTH0_CLIENT_SECRET }} AUTH0_CLIENT_SECRET: ${{ secrets.AUTH0_CLIENT_SECRET }}
VITE_SUPABASE_PROJECT: ${{ secrets.VITE_SUPABASE_PROJECT }}
VITE_SUPABASE_KEY: ${{ secrets.VITE_SUPABASE_KEY }}
- name: Extract hostname from package.json - name: Extract hostname from package.json
id: get-hostname id: get-hostname

View File

@ -144,3 +144,4 @@ public/
- TypeScript target is ES6 with ESNext modules - TypeScript target is ES6 with ESNext modules
- Vite handles bundling and dev server (though dev mode is disabled per user preference) - Vite handles bundling and dev server (though dev mode is disabled per user preference)
- Inspector can be toggled with 'i' key for debugging (only in development) - Inspector can be toggled with 'i' key for debugging (only in development)
- https://dev.flatearthdefense.com is local development, it's proxied back to my localhost which is running npm run dev

122
package-lock.json generated
View File

@ -18,6 +18,7 @@
"@babylonjs/procedural-textures": "8.36.1", "@babylonjs/procedural-textures": "8.36.1",
"@babylonjs/serializers": "8.36.1", "@babylonjs/serializers": "8.36.1",
"@newrelic/browser-agent": "^1.302.0", "@newrelic/browser-agent": "^1.302.0",
"@supabase/supabase-js": "^2.84.0",
"loglevel": "^1.9.2", "loglevel": "^1.9.2",
"openai": "4.52.3", "openai": "4.52.3",
"svelte-routing": "^2.13.0" "svelte-routing": "^2.13.0"
@ -986,6 +987,85 @@
"win32" "win32"
] ]
}, },
"node_modules/@supabase/auth-js": {
"version": "2.84.0",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.84.0.tgz",
"integrity": "sha512-J6XKbqqg1HQPMfYkAT9BrC8anPpAiifl7qoVLsYhQq5B/dnu/lxab1pabnxtJEsvYG5rwI5HEVEGXMjoQ6Wz2Q==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/functions-js": {
"version": "2.84.0",
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.84.0.tgz",
"integrity": "sha512-2oY5QBV4py/s64zMlhPEz+4RTdlwxzmfhM1k2xftD2v1DruRZKfoe7Yn9DCz1VondxX8evcvpc2udEIGzHI+VA==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/postgrest-js": {
"version": "2.84.0",
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.84.0.tgz",
"integrity": "sha512-oplc/3jfJeVW4F0J8wqywHkjIZvOVHtqzF0RESijepDAv5Dn/LThlGW1ftysoP4+PXVIrnghAbzPHo88fNomPQ==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/realtime-js": {
"version": "2.84.0",
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.84.0.tgz",
"integrity": "sha512-ThqjxiCwWiZAroHnYPmnNl6tZk6jxGcG2a7Hp/3kcolPcMj89kWjUTA3cHmhdIWYsP84fHp8MAQjYWMLf7HEUg==",
"license": "MIT",
"dependencies": {
"@types/phoenix": "^1.6.6",
"@types/ws": "^8.18.1",
"tslib": "2.8.1",
"ws": "^8.18.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/storage-js": {
"version": "2.84.0",
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.84.0.tgz",
"integrity": "sha512-vXvAJ1euCuhryOhC6j60dG8ky+lk0V06ubNo+CbhuoUv+sl39PyY0lc+k+qpQhTk/VcI6SiM0OECLN83+nyJ5A==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/supabase-js": {
"version": "2.84.0",
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.84.0.tgz",
"integrity": "sha512-byMqYBvb91sx2jcZsdp0qLpmd4Dioe80e4OU/UexXftCkpTcgrkoENXHf5dO8FCSai8SgNeq16BKg10QiDI6xg==",
"license": "MIT",
"dependencies": {
"@supabase/auth-js": "2.84.0",
"@supabase/functions-js": "2.84.0",
"@supabase/postgrest-js": "2.84.0",
"@supabase/realtime-js": "2.84.0",
"@supabase/storage-js": "2.84.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@sveltejs/acorn-typescript": { "node_modules/@sveltejs/acorn-typescript": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.7.tgz", "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.7.tgz",
@ -1068,6 +1148,12 @@
"form-data": "^4.0.0" "form-data": "^4.0.0"
} }
}, },
"node_modules/@types/phoenix": {
"version": "1.6.6",
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz",
"integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==",
"license": "MIT"
},
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "19.2.2", "version": "19.2.2",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
@ -1086,6 +1172,15 @@
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
}, },
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@xstate/fsm": { "node_modules/@xstate/fsm": {
"version": "1.6.5", "version": "1.6.5",
"resolved": "https://registry.npmjs.org/@xstate/fsm/-/fsm-1.6.5.tgz", "resolved": "https://registry.npmjs.org/@xstate/fsm/-/fsm-1.6.5.tgz",
@ -1779,6 +1874,12 @@
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
}, },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tsx": { "node_modules/tsx": {
"version": "4.20.6", "version": "4.20.6",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.6.tgz", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.6.tgz",
@ -1937,6 +2038,27 @@
"webidl-conversions": "^3.0.0" "webidl-conversions": "^3.0.0"
} }
}, },
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/yaml": { "node_modules/yaml": {
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz",

View File

@ -25,6 +25,7 @@
"@babylonjs/procedural-textures": "8.36.1", "@babylonjs/procedural-textures": "8.36.1",
"@babylonjs/serializers": "8.36.1", "@babylonjs/serializers": "8.36.1",
"@newrelic/browser-agent": "^1.302.0", "@newrelic/browser-agent": "^1.302.0",
"@supabase/supabase-js": "^2.84.0",
"loglevel": "^1.9.2", "loglevel": "^1.9.2",
"openai": "4.52.3", "openai": "4.52.3",
"svelte-routing": "^2.13.0" "svelte-routing": "^2.13.0"

View File

@ -3,11 +3,47 @@
import { Link } from 'svelte-routing'; import { Link } from 'svelte-routing';
import { gameResultsStore } from '../../stores/gameResults'; import { gameResultsStore } from '../../stores/gameResults';
import type { GameResult } from '../../services/gameResultsService'; import type { GameResult } from '../../services/gameResultsService';
import { CloudLeaderboardService, type CloudLeaderboardEntry } from '../../services/cloudLeaderboardService';
import { formatStars } from '../../game/scoreCalculator'; import { formatStars } from '../../game/scoreCalculator';
// View toggle: 'local' or 'cloud'
let activeView: 'local' | 'cloud' = 'cloud';
let cloudResults: CloudLeaderboardEntry[] = [];
let cloudLoading = false;
let cloudError = '';
// Check if cloud is available
const cloudService = CloudLeaderboardService.getInstance();
const cloudAvailable = cloudService.isAvailable();
// Load cloud leaderboard
async function loadCloudLeaderboard() {
cloudLoading = true;
cloudError = '';
try {
cloudResults = await cloudService.getGlobalLeaderboard(20);
} catch (error) {
cloudError = 'Failed to load cloud leaderboard';
console.error('[Leaderboard] Cloud load error:', error);
} finally {
cloudLoading = false;
}
}
// Switch view
function setView(view: 'local' | 'cloud') {
activeView = view;
if (view === 'cloud' && cloudResults.length === 0 && !cloudLoading) {
loadCloudLeaderboard();
}
}
// Refresh data on mount // Refresh data on mount
onMount(() => { onMount(() => {
gameResultsStore.refresh(); gameResultsStore.refresh();
if (cloudAvailable && activeView === 'cloud') {
loadCloudLeaderboard();
}
}); });
// Format time as MM:SS // Format time as MM:SS
@ -28,18 +64,37 @@
} }
// Get color for end reason badge // Get color for end reason badge
function getEndReasonColor(result: GameResult): string { function getEndReasonColor(endReason: string): string {
if (result.endReason === 'victory') return '#4ade80'; if (endReason === 'victory') return '#4ade80';
if (result.endReason === 'death') return '#ef4444'; if (endReason === 'death') return '#ef4444';
return '#f59e0b'; // stranded return '#f59e0b'; // stranded
} }
// Get emoji for end reason // Normalize cloud entry to match local result shape for display
function getEndReasonEmoji(result: GameResult): string { function normalizeCloudEntry(entry: CloudLeaderboardEntry): GameResult {
if (result.endReason === 'victory') return ''; return {
if (result.endReason === 'death') return ''; id: entry.id,
return ''; timestamp: new Date(entry.created_at).getTime(),
playerName: entry.player_name,
levelId: entry.level_id,
levelName: entry.level_name,
completed: entry.completed,
endReason: entry.end_reason as 'victory' | 'death' | 'stranded',
gameTimeSeconds: entry.game_time_seconds,
asteroidsDestroyed: entry.asteroids_destroyed,
totalAsteroids: entry.total_asteroids,
accuracy: entry.accuracy,
hullDamageTaken: entry.hull_damage_taken,
fuelConsumed: entry.fuel_consumed,
finalScore: entry.final_score,
starRating: entry.star_rating
};
} }
// Get current results based on active view
$: displayResults = activeView === 'cloud'
? cloudResults.map(normalizeCloudEntry)
: $gameResultsStore;
</script> </script>
<div class="editor-container"> <div class="editor-container">
@ -48,8 +103,37 @@
<h1>Leaderboard</h1> <h1>Leaderboard</h1>
<p class="subtitle">Top 20 High Scores</p> <p class="subtitle">Top 20 High Scores</p>
<!-- View Toggle -->
{#if cloudAvailable}
<div class="view-toggle">
<button
class="toggle-btn"
class:active={activeView === 'cloud'}
on:click={() => setView('cloud')}
>
Global
</button>
<button
class="toggle-btn"
class:active={activeView === 'local'}
on:click={() => setView('local')}
>
Local
</button>
</div>
{/if}
<div class="leaderboard-wrapper"> <div class="leaderboard-wrapper">
{#if $gameResultsStore.length === 0} {#if cloudLoading && activeView === 'cloud'}
<div class="no-results">
<p>Loading global leaderboard...</p>
</div>
{:else if cloudError && activeView === 'cloud'}
<div class="no-results">
<p>{cloudError}</p>
<button class="retry-btn" on:click={loadCloudLeaderboard}>Retry</button>
</div>
{:else if displayResults.length === 0}
<div class="no-results"> <div class="no-results">
<p>No game results yet!</p> <p>No game results yet!</p>
<p class="muted">Play a level to see your scores here.</p> <p class="muted">Play a level to see your scores here.</p>
@ -69,7 +153,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{#each $gameResultsStore as result, i} {#each displayResults as result, i}
<tr class:victory={result.completed}> <tr class:victory={result.completed}>
<td class="rank-col"> <td class="rank-col">
<span class="rank-badge" class:gold={i === 0} class:silver={i === 1} class:bronze={i === 2}> <span class="rank-badge" class:gold={i === 0} class:silver={i === 1} class:bronze={i === 2}>
@ -86,8 +170,8 @@
<span class="star-count">{result.starRating}/12</span> <span class="star-count">{result.starRating}/12</span>
</td> </td>
<td class="result-col"> <td class="result-col">
<span class="result-badge" style="background-color: {getEndReasonColor(result)}"> <span class="result-badge" style="background-color: {getEndReasonColor(result.endReason)}">
{getEndReasonEmoji(result)} {result.endReason} {result.endReason}
</span> </span>
</td> </td>
<td class="time-col">{formatTime(result.gameTimeSeconds)}</td> <td class="time-col">{formatTime(result.gameTimeSeconds)}</td>
@ -100,11 +184,60 @@
</div> </div>
<div class="leaderboard-footer"> <div class="leaderboard-footer">
<p class="muted">Showing top 20 scores sorted by highest score</p> <p class="muted">
{#if activeView === 'cloud'}
Showing top 20 global scores
{:else}
Showing top 20 local scores (this device only)
{/if}
</p>
</div> </div>
</div> </div>
<style> <style>
.view-toggle {
display: flex;
justify-content: center;
gap: var(--space-sm, 8px);
margin-top: var(--space-lg, 24px);
}
.toggle-btn {
padding: var(--space-sm, 8px) var(--space-xl, 32px);
border: 1px solid rgba(255, 255, 255, 0.3);
background: rgba(255, 255, 255, 0.1);
color: var(--color-text-secondary, #e8e8e8);
border-radius: var(--radius-md, 6px);
cursor: pointer;
font-size: var(--font-size-sm, 0.9rem);
transition: all var(--transition-fast, 0.2s ease);
}
.toggle-btn:hover {
background: rgba(255, 255, 255, 0.2);
}
.toggle-btn.active {
background: var(--color-primary, #4f46e5);
border-color: var(--color-primary, #4f46e5);
color: white;
}
.retry-btn {
margin-top: var(--space-md, 16px);
padding: var(--space-sm, 8px) var(--space-lg, 24px);
background: var(--color-primary, #4f46e5);
color: white;
border: none;
border-radius: var(--radius-md, 6px);
cursor: pointer;
font-size: var(--font-size-sm, 0.9rem);
}
.retry-btn:hover {
opacity: 0.9;
}
.leaderboard-wrapper { .leaderboard-wrapper {
background: var(--color-bg-card, rgba(20, 20, 40, 0.9)); background: var(--color-bg-card, rgba(20, 20, 40, 0.9));
border: 1px solid var(--color-border-default, rgba(255, 255, 255, 0.2)); border: 1px solid var(--color-border-default, rgba(255, 255, 255, 0.2));

View File

@ -42,11 +42,13 @@ export class AuthService {
} }
console.log('[AuthService] Creating Auth0 client...'); console.log('[AuthService] Creating Auth0 client...');
const audience = import.meta.env.VITE_AUTH0_AUDIENCE;
this._client = await createAuth0Client({ this._client = await createAuth0Client({
domain, domain,
clientId, clientId,
authorizationParams: { authorizationParams: {
redirect_uri: window.location.origin redirect_uri: window.location.origin,
audience: audience || undefined
}, },
cacheLocation: 'localstorage', // Persist tokens across page reloads cacheLocation: 'localstorage', // Persist tokens across page reloads
useRefreshTokens: true // Enable silent token refresh useRefreshTokens: true // Enable silent token refresh

View File

@ -0,0 +1,195 @@
import { SupabaseService } from './supabaseService';
import { AuthService } from './authService';
import type { GameResult } from './gameResultsService';
/**
* Represents a leaderboard entry from Supabase
*/
export interface CloudLeaderboardEntry {
id: string;
user_id: string;
player_name: string;
level_id: string;
level_name: string;
completed: boolean;
end_reason: string;
game_time_seconds: number;
asteroids_destroyed: number;
total_asteroids: number;
accuracy: number;
hull_damage_taken: number;
fuel_consumed: number;
final_score: number;
star_rating: number;
created_at: string;
}
/**
* Service for interacting with the cloud-based leaderboard via Supabase
*/
export class CloudLeaderboardService {
private static _instance: CloudLeaderboardService;
private constructor() {}
/**
* Get the singleton instance
*/
public static getInstance(): CloudLeaderboardService {
if (!CloudLeaderboardService._instance) {
CloudLeaderboardService._instance = new CloudLeaderboardService();
}
return CloudLeaderboardService._instance;
}
/**
* Check if cloud leaderboard is available
*/
public isAvailable(): boolean {
return SupabaseService.getInstance().isConfigured();
}
/**
* Submit a game result to the cloud leaderboard
* Requires authenticated user
*/
public async submitScore(result: GameResult): Promise<boolean> {
const supabase = SupabaseService.getInstance();
if (!supabase.isConfigured()) {
console.warn('[CloudLeaderboardService] Supabase not configured');
return false;
}
// Get user ID from Auth0
const authService = AuthService.getInstance();
const user = authService.getUser();
if (!user?.sub) {
console.warn('[CloudLeaderboardService] No user sub claim - user not logged in');
return false;
}
console.log('[CloudLeaderboardService] Submitting score for user:', user.sub);
// Get authenticated client for insert (requires RLS)
const client = await supabase.getAuthenticatedClient();
if (!client) {
console.warn('[CloudLeaderboardService] Not authenticated - cannot submit score');
return false;
}
const entry = {
user_id: user.sub,
player_name: result.playerName,
level_id: result.levelId,
level_name: result.levelName,
completed: result.completed,
end_reason: result.endReason,
game_time_seconds: result.gameTimeSeconds,
asteroids_destroyed: result.asteroidsDestroyed,
total_asteroids: result.totalAsteroids,
accuracy: result.accuracy,
hull_damage_taken: result.hullDamageTaken,
fuel_consumed: result.fuelConsumed,
final_score: result.finalScore,
star_rating: result.starRating
};
console.log('[CloudLeaderboardService] Inserting entry:', entry);
const { data, error } = await client
.from('leaderboard')
.insert(entry)
.select();
if (error) {
console.error('[CloudLeaderboardService] Failed to submit score:', error);
console.error('[CloudLeaderboardService] Error details:', JSON.stringify(error, null, 2));
return false;
}
console.log('[CloudLeaderboardService] Score submitted successfully:', data);
return true;
}
/**
* Fetch the global leaderboard (top scores across all players)
*/
public async getGlobalLeaderboard(limit: number = 20): Promise<CloudLeaderboardEntry[]> {
const supabase = SupabaseService.getInstance();
const client = supabase.getClient();
if (!client) {
console.warn('[CloudLeaderboardService] Supabase not configured');
return [];
}
const { data, error } = await client
.from('leaderboard')
.select('*')
.order('final_score', { ascending: false })
.limit(limit);
if (error) {
console.error('[CloudLeaderboardService] Failed to fetch leaderboard:', error);
return [];
}
return data || [];
}
/**
* Fetch a user's personal scores
*/
public async getUserScores(userId: string, limit: number = 10): Promise<CloudLeaderboardEntry[]> {
const supabase = SupabaseService.getInstance();
const client = supabase.getClient();
if (!client) {
console.warn('[CloudLeaderboardService] Supabase not configured');
return [];
}
const { data, error } = await client
.from('leaderboard')
.select('*')
.eq('user_id', userId)
.order('final_score', { ascending: false })
.limit(limit);
if (error) {
console.error('[CloudLeaderboardService] Failed to fetch user scores:', error);
return [];
}
return data || [];
}
/**
* Fetch leaderboard for a specific level
*/
public async getLevelLeaderboard(levelId: string, limit: number = 20): Promise<CloudLeaderboardEntry[]> {
const supabase = SupabaseService.getInstance();
const client = supabase.getClient();
if (!client) {
console.warn('[CloudLeaderboardService] Supabase not configured');
return [];
}
const { data, error } = await client
.from('leaderboard')
.select('*')
.eq('level_id', levelId)
.order('final_score', { ascending: false })
.limit(limit);
if (error) {
console.error('[CloudLeaderboardService] Failed to fetch level leaderboard:', error);
return [];
}
return data || [];
}
}

View File

@ -1,4 +1,5 @@
import { AuthService } from './authService'; import { AuthService } from './authService';
import { CloudLeaderboardService } from './cloudLeaderboardService';
import { GameStats } from '../game/gameStats'; import { GameStats } from '../game/gameStats';
import { Scoreboard } from '../ui/hud/scoreboard'; import { Scoreboard } from '../ui/hud/scoreboard';
import debugLog from '../core/debug'; import debugLog from '../core/debug';
@ -50,7 +51,7 @@ export class GameResultsService {
} }
/** /**
* Save a game result to storage * Save a game result to storage (local + cloud)
*/ */
public saveResult(result: GameResult): void { public saveResult(result: GameResult): void {
console.log('[GameResultsService] saveResult called with:', result); console.log('[GameResultsService] saveResult called with:', result);
@ -60,6 +61,29 @@ export class GameResultsService {
this.saveToStorage(results); this.saveToStorage(results);
console.log('[GameResultsService] Saved result:', result.id, result.finalScore); console.log('[GameResultsService] Saved result:', result.id, result.finalScore);
debugLog('[GameResultsService] Saved result:', result.id, result.finalScore); debugLog('[GameResultsService] Saved result:', result.id, result.finalScore);
// Submit to cloud leaderboard (non-blocking)
this.submitToCloud(result);
}
/**
* Submit result to cloud leaderboard (async, non-blocking)
*/
private async submitToCloud(result: GameResult): Promise<void> {
try {
const cloudService = CloudLeaderboardService.getInstance();
if (cloudService.isAvailable()) {
const success = await cloudService.submitScore(result);
if (success) {
console.log('[GameResultsService] Cloud submission successful');
} else {
console.log('[GameResultsService] Cloud submission skipped (not authenticated or failed)');
}
}
} catch (error) {
// Don't let cloud failures affect local save
console.warn('[GameResultsService] Cloud submission error:', error);
}
} }
/** /**

View File

@ -0,0 +1,93 @@
import { createClient, SupabaseClient } from '@supabase/supabase-js';
import { AuthService } from './authService';
const SUPABASE_URL = import.meta.env.VITE_SUPABASE_PROJECT;
const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_KEY;
/**
* Singleton service for managing Supabase client
* Integrates with Auth0 JWT tokens for authenticated requests
*/
export class SupabaseService {
private static _instance: SupabaseService;
private _client: SupabaseClient | null = null;
private _authenticatedClient: SupabaseClient | null = null;
private constructor() {
if (SUPABASE_URL && SUPABASE_ANON_KEY) {
this._client = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
} else {
console.warn('[SupabaseService] Supabase not configured - cloud features disabled');
}
}
/**
* Get the singleton instance
*/
public static getInstance(): SupabaseService {
if (!SupabaseService._instance) {
SupabaseService._instance = new SupabaseService();
}
return SupabaseService._instance;
}
/**
* Check if Supabase is configured
*/
public isConfigured(): boolean {
return this._client !== null;
}
/**
* Get the base Supabase client (for unauthenticated requests like reading leaderboard)
*/
public getClient(): SupabaseClient | null {
return this._client;
}
/**
* Get an authenticated Supabase client using Auth0 JWT token
* Creates a new client instance with the token in headers
*/
public async getAuthenticatedClient(): Promise<SupabaseClient | null> {
if (!SUPABASE_URL || !SUPABASE_ANON_KEY) {
console.warn('[SupabaseService] Missing Supabase URL or key');
return null;
}
const authService = AuthService.getInstance();
const token = await authService.getAccessToken();
if (!token) {
console.warn('[SupabaseService] No auth token available');
return null;
}
console.log('[SupabaseService] Got Auth0 token, length:', token.length);
// Debug: decode JWT to see claims (without verification)
try {
const payload = JSON.parse(atob(token.split('.')[1]));
console.log('[SupabaseService] Token claims:', {
iss: payload.iss,
sub: payload.sub,
aud: payload.aud,
exp: payload.exp,
role: payload.role
});
} catch (e) {
console.warn('[SupabaseService] Could not decode token');
}
// Create a new client with the Auth0 token for RLS
this._authenticatedClient = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
global: {
headers: {
Authorization: `Bearer ${token}`
}
}
});
return this._authenticatedClient;
}
}

View File

@ -29,6 +29,7 @@ export default defineConfig({
'@babylonjs/core', '@babylonjs/core',
'@babylonjs/loaders', '@babylonjs/loaders',
'@babylonjs/havok', '@babylonjs/havok',
'@babylonjs/materials',
'@babylonjs/procedural-textures', '@babylonjs/procedural-textures',
'@babylonjs/procedural-textures/fireProceduralTexture' '@babylonjs/procedural-textures/fireProceduralTexture'
], ],