"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({}); const playerIdRef = useRef(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, }; }