import { browser } from "$app/environment"; import { evaluateGuess, generateUUID, type Guess } from "$lib/utils/game"; // Returns a stable anonymous ID for this browser, creating one if it doesn't exist yet. // Used to attribute stats to a player who hasn't signed in. function getOrCreateAnonymousId(): string { if (!browser) return ""; const key = "bibdle-anonymous-id"; let id = localStorage.getItem(key); if (!id) { id = generateUUID(); localStorage.setItem(key, id); } return id; } // Reactive store that keeps in-memory game state in sync with localStorage. // Accepts getter functions (rather than plain values) so Svelte's reactivity // system can track dependencies and re-run effects when they change. type AuthUser = { id: string; firstName?: string | null; lastName?: string | null; email?: string | null; }; export function createGamePersistence( getDate: () => string, getReference: () => string, getCorrectBookId: () => string, getUser: () => AuthUser | null | undefined, ) { let guesses = $state([]); let anonymousId = $state(""); let statsSubmitted = $state(false); let chapterGuessCompleted = $state(false); let chapterCorrect = $state(false); // On mount (and if the user logs in/out), resolve the player's identity and // restore per-day flags from localStorage. $effect(() => { if (!browser) return; const user = getUser(); // CRITICAL: If user is logged in, ALWAYS use their user ID if (user) { anonymousId = user.id; } else { anonymousId = getOrCreateAnonymousId(); } // Tell analytics which player this is so events are grouped correctly. if ((window as any).umami) { (window as any).umami.identify(anonymousId); } if (user) { const nameParts = [user.firstName, user.lastName].filter(Boolean); (window as any).rybbit?.identify(user.id, { ...(nameParts.length ? { name: nameParts.join(' ') } : {}), ...(user.email ? { email: user.email } : {}), }); } else { (window as any).rybbit?.identify(anonymousId); } const date = getDate(); const reference = getReference(); // Restore whether today's completion was already submitted to the server. statsSubmitted = localStorage.getItem(`bibdle-stats-submitted-${date}`) === "true"; // Restore the chapter bonus guess result. The stored value includes the // chapter the player selected, so we can re-derive whether it was correct. const chapterGuessKey = `bibdle-chapter-guess-${reference}`; chapterGuessCompleted = localStorage.getItem(chapterGuessKey) !== null; if (chapterGuessCompleted) { const saved = localStorage.getItem(chapterGuessKey); if (saved) { const data = JSON.parse(saved); const match = reference.match(/\s(\d+):/); const correctChapter = match ? parseInt(match[1], 10) : 1; chapterCorrect = data.selectedChapter === correctChapter; } } }); // On mount (and if the date or correct answer changes), load today's guesses // from localStorage and reconstruct them as typed Guess objects by re-evaluating // each stored book ID against the correct answer. $effect(() => { if (!browser) return; const date = getDate(); const correctBookId = getCorrectBookId(); const key = `bibdle-guesses-${date}`; const saved = localStorage.getItem(key); if (!saved) { guesses = []; return; } let savedIds: string[] = JSON.parse(saved); savedIds = Array.from(new Set(savedIds)); // deduplicate, just in case guesses = savedIds .map((bookId) => evaluateGuess(bookId, correctBookId)) .filter((g): g is Guess => g !== null); }); // Persist guesses to localStorage whenever they change. Only the book IDs are // stored — the full Guess shape is re-derived on load (see effect above). $effect(() => { if (!browser) return; const date = getDate(); localStorage.setItem( `bibdle-guesses-${date}`, JSON.stringify(guesses.map((g) => g.book.id)), ); }); // Called after stats are successfully submitted to the server so that // returning to the page doesn't trigger a duplicate submission. function markStatsSubmitted() { if (!browser) return; statsSubmitted = true; localStorage.setItem(`bibdle-stats-submitted-${getDate()}`, "true"); } // Marks the win as tracked for analytics. Returns true the first time (new // win), false on subsequent calls so the analytics event fires exactly once. function markWinTracked() { if (!browser) return; const key = `bibdle-win-tracked-${getDate()}`; if (localStorage.getItem(key) === "true") return false; localStorage.setItem(key, "true"); return true; } // Returns true if the win has already been tracked in a previous render/session. // Used to skip the animation delay when returning to an already-won game. function isWinAlreadyTracked(): boolean { if (!browser) return false; return localStorage.getItem(`bibdle-win-tracked-${getDate()}`) === "true"; } // Overwrites local state with the server's authoritative guess record. // Called when a logged-in user opens the game on a new device so their // progress from another device is restored. function hydrateFromServer(guessIds: string[]) { if (!browser) return; const correctBookId = getCorrectBookId(); const date = getDate(); guesses = guessIds .map((bookId) => evaluateGuess(bookId, correctBookId)) .filter((g): g is Guess => g !== null); } // Called by the WinScreen after the player submits their chapter bonus guess. // Reads the result written to localStorage by WinScreen and updates reactive state. function onChapterGuessCompleted() { if (!browser) return; chapterGuessCompleted = true; const reference = getReference(); const chapterGuessKey = `bibdle-chapter-guess-${reference}`; const saved = localStorage.getItem(chapterGuessKey); if (saved) { const data = JSON.parse(saved); const match = reference.match(/\s(\d+):/); const correctChapter = match ? parseInt(match[1], 10) : 1; chapterCorrect = data.selectedChapter === correctChapter; } } return { get guesses() { return guesses; }, set guesses(v: Guess[]) { guesses = v; }, get anonymousId() { return anonymousId; }, get statsSubmitted() { return statsSubmitted; }, get chapterGuessCompleted() { return chapterGuessCompleted; }, get chapterCorrect() { return chapterCorrect; }, markStatsSubmitted, markWinTracked, isWinAlreadyTracked, onChapterGuessCompleted, hydrateFromServer, }; }