Triviaverse / src /hooks /usePlayerProgress.ts
Simeon Garratt
Fix stale player ID causing leaderboard/coins/achievements to fail
0160cc1
Raw
History Blame Contribute Delete
8.23 kB
"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,
};
}