diff options
| author | Pinapelz <yukais@pinapelz.com> | 2026-06-06 01:55:27 -0700 |
|---|---|---|
| committer | Pinapelz <yukais@pinapelz.com> | 2026-06-06 01:55:27 -0700 |
| commit | 39f75924217a1e9987d97727124b19f7244192bb (patch) | |
| tree | 190e44cb9f72a5af41c0e9b9b929dc9b8bb7723b /src | |
| parent | cfc9cd8c7770ddc8f151610acd177e54169e28d7 (diff) | |
implement stateless session guess checking
Diffstat (limited to 'src')
| -rw-r--r-- | src/helpers/fetchSolution.ts | 59 | ||||
| -rw-r--r-- | src/hooks/useGameState.ts | 313 | ||||
| -rw-r--r-- | src/pages/DailyPage.tsx | 8 | ||||
| -rw-r--r-- | src/react-app-env.d.ts | 5 |
4 files changed, 266 insertions, 119 deletions
diff --git a/src/helpers/fetchSolution.ts b/src/helpers/fetchSolution.ts index e3010fe..d877d8f 100644 --- a/src/helpers/fetchSolution.ts +++ b/src/helpers/fetchSolution.ts @@ -1,17 +1,17 @@ import { Song } from "../types/song"; +import { GuessState, GuessType } from "../types/guess"; -const SALT = import.meta.env.VITE_HEARDLE_SALT ?? 'changeme'; -const API_URL = import.meta.env.VITE_HEARDLE_API_URL ?? 'http://localhost:3001'; +const SALT = import.meta.env.VITE_HEARDLE_SALT ?? "changeme"; +const API_URL = import.meta.env.VITE_HEARDLE_API_URL ?? "http://localhost:3001"; function hexToBytes(hex: string): Uint8Array { const bytes = new Uint8Array(hex.length / 2); for (let i = 0; i < hex.length; i += 2) { bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16); } -return bytes; + return bytes; } - function xor(data: Uint8Array, key: Uint8Array): Uint8Array { const output = new Uint8Array(data.length); for (let i = 0; i < data.length; i++) { @@ -20,7 +20,7 @@ function xor(data: Uint8Array, key: Uint8Array): Uint8Array { return output; } -function getObfuscationKey(date = new Date().toISOString().split('T')[0]): Uint8Array { +function getObfuscationKey(date = new Date().toISOString().split("T")[0]): Uint8Array { return new TextEncoder().encode(SALT + date); } @@ -34,6 +34,33 @@ function decryptResponse(data: string, date?: string): Song { export interface DailySolution { date: string; song: Song; + sessionToken: string; + initialSig: string; +} + +export interface DailyGameState { + date: string; + currentTry: number; + didGuess: boolean; + guesses: GuessType[]; +} + +interface SongGuessPayload { + artist: string; + name: string; +} + +interface SubmitDailyGuessRequest { + sessionToken: string; + state: DailyGameState; + sig: string; + guess?: SongGuessPayload | null; +} + +interface SubmitDailyGuessResponse { + state: DailyGameState; + sig: string; + guessState: GuessState; } export async function getDailySolution(): Promise<DailySolution> { @@ -41,13 +68,33 @@ export async function getDailySolution(): Promise<DailySolution> { if (!solutionData.ok) { throw new Error(`Failed to fetch solution: ${solutionData.statusText}`); } - const { data, date } = await solutionData.json(); + const { data, date, sessionToken, initialSig } = await solutionData.json(); return { date, + sessionToken, + initialSig, song: decryptResponse(data, date), }; } +export async function submitDailyGuess( + payload: SubmitDailyGuessRequest +): Promise<SubmitDailyGuessResponse> { + const response = await fetch(`${API_URL}/guess`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }); + + if (!response.ok) { + throw new Error(`Failed to submit guess: ${response.statusText}`); + } + + return (await response.json()) as SubmitDailyGuessResponse; +} + export async function getSelectSolution(): Promise<Song> { const solutionData = await fetch(`${API_URL}/select`); if (!solutionData.ok) { diff --git a/src/hooks/useGameState.ts b/src/hooks/useGameState.ts index 625dddf..f4b3763 100644 --- a/src/hooks/useGameState.ts +++ b/src/hooks/useGameState.ts @@ -1,21 +1,33 @@ import React from "react"; -import _ from "lodash"; - import { Song } from "../types/song"; import { GuessState, GuessType } from "../types/guess"; +import { DailyGameState, submitDailyGuess } from "../helpers/fetchSolution"; interface UseGameStateOptions { solution: Song | null; persist: boolean; + sessionDate?: string; + sessionToken?: string; + initialSig?: string; +} + +const STATE_VERSION = 2; + +interface PersistedStatsV2 extends DailyGameState { + version: number; + sig: string; + sessionToken: string; } -interface PersistedStats { - solution: string; +interface LegacyStatsV0 { + date: string; currentTry: number; didGuess: boolean; guesses: GuessType[]; } +type PersistedStats = PersistedStatsV2; + const initialGuess: GuessType = { song: undefined, state: undefined, @@ -29,174 +41,151 @@ const isAnsweredGuess = (guess: GuessType | undefined | null) => const normalizeAnsweredGuesses = (guesses: unknown): GuessType[] => { if (!Array.isArray(guesses)) return []; - if (guesses.some((guess) => Array.isArray(guess))) return []; - + if (guesses.some((g) => Array.isArray(g))) return []; return (guesses as GuessType[]).filter(isAnsweredGuess).slice(0, 6); }; -const SALT = import.meta.env.VITE_HEARDLE_SALT || "changeme"; - -const getKey = (salt: string) => { - let key = 0; - for (let i = 0; i < salt.length; i++) { - key = (key + salt.charCodeAt(i)) % 256; - } - return key; -}; - -const KEY = getKey(SALT); - function loadStats(): PersistedStats | null { try { const raw = localStorage.getItem("stats"); if (!raw) return null; const parsed = JSON.parse(raw); - - if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") { + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { localStorage.removeItem("stats"); return null; } + const isLegacyV0 = + !("version" in parsed) && + "date" in parsed && + "guesses" in parsed && + !("sig" in parsed); + + if (isLegacyV0) { + const legacy = parsed as LegacyStatsV0; + + const upgraded: PersistedStats = { + version: STATE_VERSION, + date: legacy.date, + currentTry: Math.max( + 0, + Math.min(6, Math.floor(legacy.currentTry || 0)) + ), + didGuess: !!legacy.didGuess, + guesses: normalizeAnsweredGuesses(legacy.guesses), + sig: "", + sessionToken: "", + }; + + return upgraded; + } + const currentTry = typeof parsed.currentTry === "number" && Number.isFinite(parsed.currentTry) ? Math.max(0, Math.min(6, Math.floor(parsed.currentTry))) : 0; - const normalized: PersistedStats = { - solution: typeof parsed.solution === "string" ? parsed.solution : "", - currentTry, + const normalizedGuesses = normalizeAnsweredGuesses(parsed.guesses); + + return { + version: typeof parsed.version === "number" ? parsed.version : STATE_VERSION, + date: typeof parsed.date === "string" ? parsed.date : "", + currentTry: Math.min(currentTry, normalizedGuesses.length), didGuess: !!parsed.didGuess, - guesses: normalizeAnsweredGuesses(parsed.guesses), + guesses: normalizedGuesses, + sig: typeof parsed.sig === "string" ? parsed.sig : "", + sessionToken: + typeof parsed.sessionToken === "string" ? parsed.sessionToken : "", }; - - return normalized; } catch { localStorage.removeItem("stats"); return null; } } -const encodeSolution = (solution: Song) => { - const json = JSON.stringify(solution); - - const xored = json - .split("") - .map((c) => String.fromCharCode(c.charCodeAt(0) ^ KEY)) - .join(""); - - return btoa(xored); -}; - -const decodeSolution = (value: string): Song | null => { - try { - const decoded = atob(value); - - const json = decoded - .split("") - .map((c) => String.fromCharCode(c.charCodeAt(0) ^ KEY)) - .join(""); - - return JSON.parse(json); - } catch { - return null; - } -}; - -export function useGameState({ solution, persist }: UseGameStateOptions) { +export function useGameState({ + solution, + persist, + sessionDate, + sessionToken, + initialSig, +}: UseGameStateOptions) { const [guesses, setGuesses] = React.useState<GuessType[]>(makeEmptyGuesses()); - const [currentTry, setCurrentTry] = React.useState(0); const [selectedSong, setSelectedSong] = React.useState<Song>(); const [didGuess, setDidGuess] = React.useState(false); + const [isSubmitting, setIsSubmitting] = React.useState(false); const [stats, setStats] = React.useState<PersistedStats | null>(() => loadStats() ); const hydratedRef = React.useRef(false); - const skipNextStatsSyncRef = React.useRef(false); React.useEffect(() => { - if (!persist || !solution) return; + if (!persist) return; if (hydratedRef.current) return; + if (!sessionDate || !sessionToken || !initialSig) return; - skipNextStatsSyncRef.current = true; - - const initialStats: PersistedStats = { - solution: encodeSolution(solution), + const baseStats: PersistedStats = { + version: STATE_VERSION, + date: sessionDate, currentTry: 0, didGuess: false, guesses: [], + sig: initialSig, + sessionToken, }; if (!stats) { - setStats(initialStats); + setStats(baseStats); + setCurrentTry(0); + setGuesses(makeEmptyGuesses()); + setDidGuess(false); hydratedRef.current = true; return; } - const decodedSolution = stats.solution - ? decodeSolution(stats.solution) - : null; + const sameSession = + stats.date === sessionDate && + stats.sessionToken === sessionToken; - const sameGame = _.isEqual(solution, decodedSolution); + if (sameSession) { + const answered = normalizeAnsweredGuesses(stats.guesses); + const hydrated = makeEmptyGuesses(); - if (sameGame) { - const answeredGuesses = normalizeAnsweredGuesses(stats.guesses); - const hydratedGuesses = makeEmptyGuesses(); + answered.forEach((g, i) => (hydrated[i] = g)); - answeredGuesses.forEach((guess, index) => { - hydratedGuesses[index] = guess; - }); - - const normalizedCurrentTry = Math.min( - answeredGuesses.length, - Math.max(0, stats.currentTry ?? 0) + const normalizedTry = Math.min( + answered.length, + Math.max(0, stats.currentTry) ); - setCurrentTry(normalizedCurrentTry); - setGuesses(hydratedGuesses); + setCurrentTry(normalizedTry); + setGuesses(hydrated); setDidGuess(!!stats.didGuess); + setStats({ ...stats, - currentTry: normalizedCurrentTry, - guesses: answeredGuesses, + version: STATE_VERSION, + currentTry: normalizedTry, + guesses: answered, }); } else { - setStats(initialStats); + setStats(baseStats); setCurrentTry(0); setGuesses(makeEmptyGuesses()); setDidGuess(false); } hydratedRef.current = true; - }, [solution, persist]); + }, [persist, sessionDate, sessionToken, initialSig, stats]); React.useEffect(() => { if (!persist) return; if (!hydratedRef.current) return; - if (skipNextStatsSyncRef.current) { - skipNextStatsSyncRef.current = false; - return; - } - - setStats((prev) => { - if (!prev) return prev; - - return { - ...prev, - currentTry, - didGuess, - guesses: guesses.filter(isAnsweredGuess), - }; - }); - }, [guesses, currentTry, didGuess, persist]); - - React.useEffect(() => { - if (!persist) return; - if (!hydratedRef.current) return; if (!stats) { localStorage.removeItem("stats"); return; @@ -205,9 +194,65 @@ export function useGameState({ solution, persist }: UseGameStateOptions) { localStorage.setItem("stats", JSON.stringify(stats)); }, [stats, persist]); - const skip = React.useCallback(() => { + const applyServerState = React.useCallback( + (next: DailyGameState, sig: string) => { + if (!sessionToken) return; + + const answered = normalizeAnsweredGuesses(next.guesses); + const hydrated = makeEmptyGuesses(); + + answered.forEach((g, i) => (hydrated[i] = g)); + + const normalizedTry = Math.min( + answered.length, + Math.max(0, next.currentTry) + ); + + setCurrentTry(normalizedTry); + setGuesses(hydrated); + setDidGuess(!!next.didGuess); + setSelectedSong(undefined); + + setStats({ + version: STATE_VERSION, + date: next.date, + currentTry: normalizedTry, + didGuess: !!next.didGuess, + guesses: answered, + sig, + sessionToken, + }); + }, + [sessionToken] + ); + + const skip = React.useCallback(async () => { if (didGuess || currentTry >= guesses.length) return; + if (persist && sessionDate && sessionToken) { + if (!stats?.sig || isSubmitting) return; + + setIsSubmitting(true); + try { + const res = await submitDailyGuess({ + sessionToken, + sig: stats.sig, + state: { + date: sessionDate, + currentTry, + didGuess, + guesses: guesses.filter(isAnsweredGuess), + }, + guess: null, + }); + + applyServerState(res.state, res.sig); + } finally { + setIsSubmitting(false); + } + return; + } + setGuesses((prev) => { const copy = [...prev]; copy[currentTry] = { @@ -218,12 +263,49 @@ export function useGameState({ solution, persist }: UseGameStateOptions) { }); setCurrentTry((t) => t + 1); - }, [currentTry, didGuess, guesses.length]); + }, [ + didGuess, + currentTry, + guesses, + persist, + sessionDate, + sessionToken, + stats, + isSubmitting, + applyServerState, + ]); - const guess = React.useCallback(() => { + const guess = React.useCallback(async () => { if (!selectedSong || !solution) return; if (didGuess || currentTry >= guesses.length) return; + if (persist && sessionDate && sessionToken) { + if (!stats?.sig || isSubmitting) return; + + setIsSubmitting(true); + try { + const res = await submitDailyGuess({ + sessionToken, + sig: stats.sig, + state: { + date: sessionDate, + currentTry, + didGuess, + guesses: guesses.filter(isAnsweredGuess), + }, + guess: { + artist: selectedSong.artist, + name: selectedSong.name, + }, + }); + + applyServerState(res.state, res.sig); + } finally { + setIsSubmitting(false); + } + return; + } + let state = GuessState.Incorrect; if ( @@ -237,20 +319,27 @@ export function useGameState({ solution, persist }: UseGameStateOptions) { setGuesses((prev) => { const copy = [...prev]; - copy[currentTry] = { - song: selectedSong, - state, - }; + copy[currentTry] = { song: selectedSong, state }; return copy; }); setCurrentTry((t) => t + 1); setSelectedSong(undefined); - if (state === GuessState.Correct) { - setDidGuess(true); - } - }, [selectedSong, solution, currentTry, didGuess, guesses.length]); + if (state === GuessState.Correct) setDidGuess(true); + }, [ + selectedSong, + solution, + currentTry, + didGuess, + guesses, + persist, + sessionDate, + sessionToken, + stats, + isSubmitting, + applyServerState, + ]); const reset = React.useCallback(() => { setGuesses(makeEmptyGuesses()); diff --git a/src/pages/DailyPage.tsx b/src/pages/DailyPage.tsx index 24ac20c..d1482b1 100644 --- a/src/pages/DailyPage.tsx +++ b/src/pages/DailyPage.tsx @@ -69,7 +69,13 @@ export function DailyPage() { didGuess, skip, guess, - } = useGameState({ solution: todaysSolution?.song ?? null, persist: true }); + } = useGameState({ + solution: todaysSolution?.song ?? null, + persist: true, + sessionDate: todaysSolution?.date, + sessionToken: todaysSolution?.sessionToken, + initialSig: todaysSolution?.initialSig, + }); const [isInfoPopUpOpen, setIsInfoPopUpOpen] = React.useState<boolean>(firstRun); diff --git a/src/react-app-env.d.ts b/src/react-app-env.d.ts index f6733c9..ac88680 100644 --- a/src/react-app-env.d.ts +++ b/src/react-app-env.d.ts @@ -1,10 +1,15 @@ /// <reference types="react-scripts" /> + declare global { interface ImportMetaEnv { readonly VITE_CDN_URL?: string; + readonly VITE_HEARDLE_SALT?: string; + readonly VITE_HEARDLE_API_URL?: string; } interface ImportMeta { readonly env: ImportMetaEnv; } } + +export {}; |
