Add cloud leaderboard with Supabase integration
All checks were successful
Build / build (push) Successful in 1m39s
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:
parent
0c03253c9a
commit
a3e17c95db
4
.github/workflows/build.yml
vendored
4
.github/workflows/build.yml
vendored
@ -23,7 +23,11 @@ jobs:
|
||||
NODE_OPTIONS: '--max-old-space-size=4096'
|
||||
VITE_AUTH0_DOMAIN: ${{ secrets.VITE_AUTH0_DOMAIN }}
|
||||
VITE_AUTH0_CLIENT_ID: ${{ secrets.VITE_AUTH0_CLIENT_ID }}
|
||||
VITE_AUTH0_AUDIENCE: ${{ secrets.VITE_AUTH0_AUDIENCE }}
|
||||
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
|
||||
id: get-hostname
|
||||
|
||||
@ -144,3 +144,4 @@ public/
|
||||
- TypeScript target is ES6 with ESNext modules
|
||||
- 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)
|
||||
- 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
122
package-lock.json
generated
@ -18,6 +18,7 @@
|
||||
"@babylonjs/procedural-textures": "8.36.1",
|
||||
"@babylonjs/serializers": "8.36.1",
|
||||
"@newrelic/browser-agent": "^1.302.0",
|
||||
"@supabase/supabase-js": "^2.84.0",
|
||||
"loglevel": "^1.9.2",
|
||||
"openai": "4.52.3",
|
||||
"svelte-routing": "^2.13.0"
|
||||
@ -986,6 +987,85 @@
|
||||
"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": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.7.tgz",
|
||||
@ -1068,6 +1148,12 @@
|
||||
"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": {
|
||||
"version": "19.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz",
|
||||
@ -1086,6 +1172,15 @@
|
||||
"@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": {
|
||||
"version": "1.6.5",
|
||||
"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",
|
||||
"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": {
|
||||
"version": "4.20.6",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.6.tgz",
|
||||
@ -1937,6 +2038,27 @@
|
||||
"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": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz",
|
||||
|
||||
@ -25,6 +25,7 @@
|
||||
"@babylonjs/procedural-textures": "8.36.1",
|
||||
"@babylonjs/serializers": "8.36.1",
|
||||
"@newrelic/browser-agent": "^1.302.0",
|
||||
"@supabase/supabase-js": "^2.84.0",
|
||||
"loglevel": "^1.9.2",
|
||||
"openai": "4.52.3",
|
||||
"svelte-routing": "^2.13.0"
|
||||
|
||||
@ -3,11 +3,47 @@
|
||||
import { Link } from 'svelte-routing';
|
||||
import { gameResultsStore } from '../../stores/gameResults';
|
||||
import type { GameResult } from '../../services/gameResultsService';
|
||||
import { CloudLeaderboardService, type CloudLeaderboardEntry } from '../../services/cloudLeaderboardService';
|
||||
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
|
||||
onMount(() => {
|
||||
gameResultsStore.refresh();
|
||||
if (cloudAvailable && activeView === 'cloud') {
|
||||
loadCloudLeaderboard();
|
||||
}
|
||||
});
|
||||
|
||||
// Format time as MM:SS
|
||||
@ -28,18 +64,37 @@
|
||||
}
|
||||
|
||||
// Get color for end reason badge
|
||||
function getEndReasonColor(result: GameResult): string {
|
||||
if (result.endReason === 'victory') return '#4ade80';
|
||||
if (result.endReason === 'death') return '#ef4444';
|
||||
function getEndReasonColor(endReason: string): string {
|
||||
if (endReason === 'victory') return '#4ade80';
|
||||
if (endReason === 'death') return '#ef4444';
|
||||
return '#f59e0b'; // stranded
|
||||
}
|
||||
|
||||
// Get emoji for end reason
|
||||
function getEndReasonEmoji(result: GameResult): string {
|
||||
if (result.endReason === 'victory') return '';
|
||||
if (result.endReason === 'death') return '';
|
||||
return '';
|
||||
// Normalize cloud entry to match local result shape for display
|
||||
function normalizeCloudEntry(entry: CloudLeaderboardEntry): GameResult {
|
||||
return {
|
||||
id: entry.id,
|
||||
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>
|
||||
|
||||
<div class="editor-container">
|
||||
@ -48,8 +103,37 @@
|
||||
<h1>Leaderboard</h1>
|
||||
<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">
|
||||
{#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">
|
||||
<p>No game results yet!</p>
|
||||
<p class="muted">Play a level to see your scores here.</p>
|
||||
@ -69,7 +153,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each $gameResultsStore as result, i}
|
||||
{#each displayResults as result, i}
|
||||
<tr class:victory={result.completed}>
|
||||
<td class="rank-col">
|
||||
<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>
|
||||
</td>
|
||||
<td class="result-col">
|
||||
<span class="result-badge" style="background-color: {getEndReasonColor(result)}">
|
||||
{getEndReasonEmoji(result)} {result.endReason}
|
||||
<span class="result-badge" style="background-color: {getEndReasonColor(result.endReason)}">
|
||||
{result.endReason}
|
||||
</span>
|
||||
</td>
|
||||
<td class="time-col">{formatTime(result.gameTimeSeconds)}</td>
|
||||
@ -100,11 +184,60 @@
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<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 {
|
||||
background: var(--color-bg-card, rgba(20, 20, 40, 0.9));
|
||||
border: 1px solid var(--color-border-default, rgba(255, 255, 255, 0.2));
|
||||
|
||||
@ -42,11 +42,13 @@ export class AuthService {
|
||||
}
|
||||
|
||||
console.log('[AuthService] Creating Auth0 client...');
|
||||
const audience = import.meta.env.VITE_AUTH0_AUDIENCE;
|
||||
this._client = await createAuth0Client({
|
||||
domain,
|
||||
clientId,
|
||||
authorizationParams: {
|
||||
redirect_uri: window.location.origin
|
||||
redirect_uri: window.location.origin,
|
||||
audience: audience || undefined
|
||||
},
|
||||
cacheLocation: 'localstorage', // Persist tokens across page reloads
|
||||
useRefreshTokens: true // Enable silent token refresh
|
||||
|
||||
195
src/services/cloudLeaderboardService.ts
Normal file
195
src/services/cloudLeaderboardService.ts
Normal 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 || [];
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,5 @@
|
||||
import { AuthService } from './authService';
|
||||
import { CloudLeaderboardService } from './cloudLeaderboardService';
|
||||
import { GameStats } from '../game/gameStats';
|
||||
import { Scoreboard } from '../ui/hud/scoreboard';
|
||||
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 {
|
||||
console.log('[GameResultsService] saveResult called with:', result);
|
||||
@ -60,6 +61,29 @@ export class GameResultsService {
|
||||
this.saveToStorage(results);
|
||||
console.log('[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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
93
src/services/supabaseService.ts
Normal file
93
src/services/supabaseService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -29,6 +29,7 @@ export default defineConfig({
|
||||
'@babylonjs/core',
|
||||
'@babylonjs/loaders',
|
||||
'@babylonjs/havok',
|
||||
'@babylonjs/materials',
|
||||
'@babylonjs/procedural-textures',
|
||||
'@babylonjs/procedural-textures/fireProceduralTexture'
|
||||
],
|
||||
|
||||
Loading…
Reference in New Issue
Block a user