Spaces:
Running
Running
| "use client"; | |
| import { useState, useEffect, useCallback, useRef } from "react"; | |
| import { getStoredPlayer, storePlayer } from "@/lib/player-store"; | |
| import { isSupabaseConfigured } from "@/lib/supabase/client"; | |
| import { | |
| getPlayerAchievements, | |
| upsertAchievementProgress, | |
| getPlayerGameStats, | |
| } from "@/lib/supabase/achievement-queries"; | |
| import { addStarCoins, getPlayerStarCoins, getOrCreatePlayer } from "@/lib/supabase/queries"; | |
| import { | |
| evaluateAchievements, | |
| type GameResult, | |
| type AchievementUpdate, | |
| type CurrentProgress, | |
| type PlayerStats, | |
| } from "@/lib/achievements/checker"; | |
| import { | |
| calculateCoinsEarned, | |
| type CoinBreakdown, | |
| } from "@/lib/achievements/coin-calculator"; | |
| // --------------------------------------------------------------------------- | |
| // Public interface | |
| // --------------------------------------------------------------------------- | |
| export interface UsePlayerProgressReturn { | |
| coinBalance: number; | |
| isLoading: boolean; | |
| processGameResult: (result: GameResult) => Promise<{ | |
| coinsEarned: CoinBreakdown; | |
| newAchievements: AchievementUpdate[]; | |
| } | null>; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Hook | |
| // --------------------------------------------------------------------------- | |
| /** | |
| * Manages player achievement progress and coin balance. | |
| * | |
| * On mount, loads the player's current achievement progress and coin balance | |
| * from Supabase (when configured). Provides `processGameResult` to evaluate | |
| * achievements, calculate coins, persist updates, and return the breakdown. | |
| * | |
| * When Supabase is not configured, coins are still calculated locally but | |
| * nothing is persisted. | |
| */ | |
| export function usePlayerProgress(): UsePlayerProgressReturn { | |
| const [coinBalance, setCoinBalance] = useState(0); | |
| const [isLoading, setIsLoading] = useState(true); | |
| const progressRef = useRef<CurrentProgress>({}); | |
| const playerIdRef = useRef<string | null>(null); | |
| // Load initial achievement progress and coin balance | |
| useEffect(() => { | |
| let cancelled = false; | |
| async function load() { | |
| const player = getStoredPlayer(); | |
| if (!player || !isSupabaseConfigured) { | |
| if (!cancelled) setIsLoading(false); | |
| return; | |
| } | |
| // Always verify player exists in DB -- heals stale/reset UUIDs in localStorage | |
| const dbPlayer = await getOrCreatePlayer(player.anonymousId, player.displayName); | |
| if (!dbPlayer) { | |
| if (!cancelled) setIsLoading(false); | |
| return; | |
| } | |
| if (player.id !== dbPlayer.id) { | |
| storePlayer({ ...player, id: dbPlayer.id }); | |
| } | |
| playerIdRef.current = dbPlayer.id; | |
| try { | |
| const [achievementRows, coins] = await Promise.all([ | |
| getPlayerAchievements(dbPlayer.id), | |
| getPlayerStarCoins(dbPlayer.id), | |
| ]); | |
| if (cancelled) return; | |
| // Convert DB rows to CurrentProgress map | |
| const progressMap: CurrentProgress = {}; | |
| for (const row of achievementRows) { | |
| progressMap[row.achievement_id] = { | |
| progress: row.progress, | |
| unlocked: row.unlocked, | |
| }; | |
| } | |
| progressRef.current = progressMap; | |
| setCoinBalance(coins); | |
| } catch { | |
| // Silently ignore -- local-only mode still works | |
| } | |
| if (!cancelled) setIsLoading(false); | |
| } | |
| load(); | |
| return () => { | |
| cancelled = true; | |
| }; | |
| }, []); | |
| // Process a game result: evaluate achievements, calculate coins, persist | |
| const processGameResult = useCallback( | |
| async ( | |
| result: GameResult | |
| ): Promise<{ | |
| coinsEarned: CoinBreakdown; | |
| newAchievements: AchievementUpdate[]; | |
| } | null> => { | |
| const player = getStoredPlayer(); | |
| let playerId = playerIdRef.current ?? player?.id ?? null; | |
| // Always verify player exists in DB -- heals stale/reset UUIDs in localStorage | |
| if (player && isSupabaseConfigured) { | |
| const dbPlayer = await getOrCreatePlayer(player.anonymousId, player.displayName); | |
| if (dbPlayer) { | |
| if (player.id !== dbPlayer.id) { | |
| storePlayer({ ...player, id: dbPlayer.id }); | |
| } | |
| playerId = dbPlayer.id; | |
| playerIdRef.current = dbPlayer.id; | |
| } | |
| } | |
| // Build player stats from Supabase or use local fallbacks | |
| let playerStats: PlayerStats; | |
| if (playerId && isSupabaseConfigured) { | |
| try { | |
| const dbStats = await getPlayerGameStats(playerId); | |
| playerStats = { | |
| totalGames: dbStats.totalGames + 1, // include current game | |
| totalCorrectAnswers: | |
| dbStats.totalCorrectAnswers + | |
| (result.type === "trivia" ? result.questionsCorrect : 0), | |
| distinctModesPlayed: dbStats.distinctModes, | |
| gamesToday: 1, // conservative estimate; refined below | |
| achievementsUnlocked: Object.values(progressRef.current).filter( | |
| (p) => p.unlocked | |
| ).length, | |
| }; | |
| // Add current mode to distinct modes if not already present | |
| const currentMode = | |
| result.type === "trivia" ? result.mode : result.gameId; | |
| if (!playerStats.distinctModesPlayed.includes(currentMode)) { | |
| playerStats.distinctModesPlayed = [ | |
| ...playerStats.distinctModesPlayed, | |
| currentMode, | |
| ]; | |
| } | |
| } catch { | |
| // Fall back to local estimates | |
| playerStats = buildLocalStats(result, progressRef.current); | |
| } | |
| } else { | |
| playerStats = buildLocalStats(result, progressRef.current); | |
| } | |
| // Determine if this is the first game today | |
| const isFirstGameToday = playerStats.gamesToday <= 1; | |
| // Evaluate achievements | |
| const newAchievements = evaluateAchievements( | |
| result, | |
| progressRef.current, | |
| playerStats | |
| ); | |
| // Calculate coins | |
| const coinsEarned = calculateCoinsEarned( | |
| result, | |
| newAchievements, | |
| isFirstGameToday | |
| ); | |
| // Persist to Supabase when available | |
| if (playerId && isSupabaseConfigured) { | |
| try { | |
| // Persist achievement progress | |
| const upsertPromises = newAchievements.map((update) => | |
| upsertAchievementProgress( | |
| playerId, | |
| update.achievementId, | |
| update.newProgress, | |
| update.justUnlocked | |
| ) | |
| ); | |
| await Promise.all(upsertPromises); | |
| // Add coins and update local balance | |
| if (coinsEarned.total > 0) { | |
| const newBalance = await addStarCoins(playerId, coinsEarned.total); | |
| if (newBalance !== null) { | |
| setCoinBalance(newBalance); | |
| } else { | |
| // Optimistic update if DB write failed | |
| setCoinBalance((prev) => prev + coinsEarned.total); | |
| } | |
| } | |
| } catch { | |
| // Optimistic local update on persistence failure | |
| setCoinBalance((prev) => prev + coinsEarned.total); | |
| } | |
| } else { | |
| // No Supabase -- just update local state | |
| setCoinBalance((prev) => prev + coinsEarned.total); | |
| } | |
| // Update local progress cache | |
| for (const update of newAchievements) { | |
| progressRef.current[update.achievementId] = { | |
| progress: update.newProgress, | |
| unlocked: update.justUnlocked, | |
| }; | |
| } | |
| return { coinsEarned, newAchievements }; | |
| }, | |
| [] | |
| ); | |
| return { coinBalance, isLoading, processGameResult }; | |
| } | |
| // --------------------------------------------------------------------------- | |
| // Helpers | |
| // --------------------------------------------------------------------------- | |
| /** | |
| * Builds a conservative PlayerStats object when Supabase is unavailable. | |
| */ | |
| function buildLocalStats( | |
| result: GameResult, | |
| currentProgress: CurrentProgress | |
| ): PlayerStats { | |
| return { | |
| totalGames: 1, | |
| totalCorrectAnswers: | |
| result.type === "trivia" ? result.questionsCorrect : 0, | |
| distinctModesPlayed: [ | |
| result.type === "trivia" ? result.mode : result.gameId, | |
| ], | |
| gamesToday: 1, | |
| achievementsUnlocked: Object.values(currentProgress).filter( | |
| (p) => p.unlocked | |
| ).length, | |
| }; | |
| } | |