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 | |
| parent | cfc9cd8c7770ddc8f151610acd177e54169e28d7 (diff) | |
implement stateless session guess checking
| -rw-r--r-- | package.json | 1 | ||||
| -rw-r--r-- | playlist_generator/generate_daily.py | 33 | ||||
| -rw-r--r-- | pnpm-lock.yaml | 18 | ||||
| -rw-r--r-- | server/index.ts | 307 | ||||
| -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 |
8 files changed, 578 insertions, 166 deletions
diff --git a/package.json b/package.json index 7b07c3e..ccd7ad9 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "cors": "^2.8.6", "dotenv": "^17.4.2", "express": "^5.2.1", + "express-rate-limit": "^8.5.2", "lodash": "^4.18.1", "prop-types": "^15.8.1", "react": "^19.2.7", diff --git a/playlist_generator/generate_daily.py b/playlist_generator/generate_daily.py index aba73b0..1e6e39e 100644 --- a/playlist_generator/generate_daily.py +++ b/playlist_generator/generate_daily.py @@ -15,15 +15,21 @@ ACCESS_KEY = os.getenv("R2_ACCESS_KEY") SECRET_KEY = os.getenv("R2_SECRET_KEY") BUCKET = os.getenv("R2_BUCKET") API_URL = os.getenv("API_URL") -OBFUSCATION_KEY = os.getenv("OBFUSCATION_KEY") +HEARDLE_SALT = ( + os.getenv("VITE_HEARDLE_SALT") + or os.getenv("OBFUSCATION_KEY") +) def xor_buffer(data: bytes, key: bytes) -> bytes: return bytes(b ^ key[i % len(key)] for i, b in enumerate(data)) -def get_obfuscation_key() -> bytes: - date = datetime.now(timezone.utc).strftime("%Y-%m-%d") - return (OBFUSCATION_KEY + date).encode("utf-8") +def get_obfuscation_key(date: str) -> bytes: + if not HEARDLE_SALT: + raise ValueError( + "Missing HEARDLE salt. Set VITE_HEARDLE_SALT (preferred) or OBFUSCATION_KEY." + ) + return (HEARDLE_SALT + date).encode("utf-8") def delete_file(file_path): if os.path.exists(file_path): @@ -31,18 +37,25 @@ def delete_file(file_path): return True return False -def decode_data(hex_data: str): +def decode_data(hex_data: str, date: str): encrypted = bytes.fromhex(hex_data) - key = get_obfuscation_key() + key = get_obfuscation_key(date) decrypted = xor_buffer(encrypted, key) return json.loads(decrypted.decode("utf-8")) def fetch_daily() -> dict: + if not API_URL: + raise ValueError("Missing API_URL in environment.") + url = f"{API_URL}/today" - response = requests.get(url) + response = requests.get(url, timeout=15) response.raise_for_status() - return response.json() + payload = response.json() + if "date" not in payload or "data" not in payload: + raise ValueError(f"Unexpected /today response shape: {payload}") + + return payload def download_random_segment_mp3(youtube_id: str, output_file="today.mp3") -> str: @@ -116,11 +129,11 @@ def main(): else: new_data = True daily_data = fetch_daily() - data = decode_data(daily_data["data"]) + data = decode_data(daily_data["data"], daily_data["date"]) print(data) youtube_id = data["youtubeId"] clip_path = download_random_segment_mp3(youtube_id) - date = datetime.now(timezone.utc).strftime("%Y-%m-%d") + date = daily_data["date"] upload_to_r2(clip_path, f"kheardle/{date}.mp3") delete_file("today.mp3") write_json("save.json", daily_data) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 828f20b..c30d2fb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,6 +43,9 @@ dependencies: express: specifier: ^5.2.1 version: 5.2.1 + express-rate-limit: + specifier: ^8.5.2 + version: 8.5.2(express@5.2.1) lodash: specifier: ^4.18.1 version: 4.18.1 @@ -1816,6 +1819,16 @@ packages: jest-util: 30.4.1 dev: false + /express-rate-limit@8.5.2(express@5.2.1): + resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + dependencies: + express: 5.2.1 + ip-address: 10.2.0 + dev: false + /express@5.2.1: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} @@ -2071,6 +2084,11 @@ packages: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} dev: false + /ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + engines: {node: '>= 12'} + dev: false + /ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} diff --git a/server/index.ts b/server/index.ts index c9cc6b4..3737105 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1,9 +1,10 @@ -import express from 'express'; -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; -import path from 'path'; -import { songs } from './data/songs'; -import cors from 'cors'; -import dotenv from 'dotenv'; +import express from "express"; +import { createHmac, timingSafeEqual } from "crypto"; +import { songs } from "./data/songs"; +import cors from "cors"; +import dotenv from "dotenv"; +import rateLimit from "express-rate-limit"; + dotenv.config(); const app = express(); @@ -11,13 +12,27 @@ app.use(cors()); app.use(express.json()); const SERVER_PORT = process.env.SERVER_PORT || 3001; -const SALT = process.env.VITE_HEARDLE_SALT ?? 'changeme'; -const DAILY_SONGS_FILE = path.resolve( - process.env.DAILY_SONGS_FILE ?? 'server/data/daily-songs.json', -); +const SALT = process.env.VITE_HEARDLE_SALT ?? "changeme"; +const SIGNING_SECRET = process.env.HEARDLE_SIGNING_SECRET ?? SALT; type Song = (typeof songs)[number]; -type DailySongs = Record<string, Song>; + +type GuessResult = "Correct" | "PartiallyCorrect" | "Incorrect" | "Skipped"; + +type GuessEntry = { + song?: { + artist: string; + name: string; + }; + state: GuessResult; +}; + +type SignedState = { + date: string; + currentTry: number; + didGuess: boolean; + guesses: GuessEntry[]; +}; function getObfuscationKey(date = getUtcDate()): Buffer { return Buffer.from(SALT + date); @@ -34,6 +49,7 @@ function xorBuffer(data: Buffer, key: Buffer): Buffer { function getUtcDate(): string { return new Date().toISOString().slice(0, 10); } + function hashString(str: string): number { let hash = 0; for (let i = 0; i < str.length; i++) { @@ -42,60 +58,277 @@ function hashString(str: string): number { return hash; } -function readDailySongs(): DailySongs { - if (!existsSync(DAILY_SONGS_FILE)) { - return {}; +function getDailySong(date: string): Song { + const seed = hashString(date); + const index = seed % songs.length; + return songs[index]; +} + +function signValue(prefix: string, value: string): string { + return createHmac("sha256", SIGNING_SECRET) + .update(`${prefix}:${value}`) + .digest("hex"); +} + +function safeEqualHex(a: string, b: string): boolean { + try { + const left = Buffer.from(a, "hex"); + const right = Buffer.from(b, "hex"); + if (left.length !== right.length) return false; + return timingSafeEqual(left, right); + } catch { + return false; } +} - return JSON.parse(readFileSync(DAILY_SONGS_FILE, 'utf8')) as DailySongs; +function buildSessionToken(date: string): string { + const payload = Buffer.from(JSON.stringify({ date }), "utf8").toString("base64url"); + const sig = signValue("session", payload); + return `${payload}.${sig}`; } -function writeDailySongs(dailySongs: DailySongs): void { - mkdirSync(path.dirname(DAILY_SONGS_FILE), { recursive: true }); - writeFileSync(DAILY_SONGS_FILE, JSON.stringify(dailySongs, null, 2)); +function verifySessionToken(token: unknown, expectedDate: string): boolean { + if (typeof token !== "string") return false; + const [payload, sig] = token.split("."); + if (!payload || !sig) return false; + + const expectedSig = signValue("session", payload); + if (!safeEqualHex(sig, expectedSig)) return false; + + try { + const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as { + date?: unknown; + }; + + return typeof decoded.date === "string" && decoded.date === expectedDate; + } catch { + return false; + } } -function getDailySong(date: string): Song { - const dailySongs = readDailySongs(); - const savedSong = dailySongs[date]; +function normalizeGuessEntry(entry: unknown): GuessEntry | null { + if (!entry || typeof entry !== "object" || Array.isArray(entry)) return null; - if (savedSong) { - return savedSong; + const maybeState = (entry as { state?: unknown }).state; + if ( + maybeState !== "Correct" && + maybeState !== "PartiallyCorrect" && + maybeState !== "Incorrect" && + maybeState !== "Skipped" + ) { + return null; } - const seed = hashString(date); - const index = seed % songs.length; - const song = songs[index]; - dailySongs[date] = song; - writeDailySongs(dailySongs); - return song; + const maybeSong = (entry as { song?: unknown }).song; + + if (maybeState === "Skipped") { + return { state: "Skipped" }; + } + + if (!maybeSong || typeof maybeSong !== "object" || Array.isArray(maybeSong)) { + return null; + } + + const artist = (maybeSong as { artist?: unknown }).artist; + const name = (maybeSong as { name?: unknown }).name; + + if (typeof artist !== "string" || typeof name !== "string") { + return null; + } + + return { + song: { artist, name }, + state: maybeState, + }; } -app.get('/today', (_req, res) => { +function normalizeSignedState(input: unknown): SignedState | null { + if (!input || typeof input !== "object" || Array.isArray(input)) return null; + + const date = (input as { date?: unknown }).date; + const currentTryRaw = (input as { currentTry?: unknown }).currentTry; + const didGuessRaw = (input as { didGuess?: unknown }).didGuess; + const guessesRaw = (input as { guesses?: unknown }).guesses; + + if (typeof date !== "string") return null; + if (!Array.isArray(guessesRaw)) return null; + if (typeof currentTryRaw !== "number" || !Number.isFinite(currentTryRaw)) return null; + + const currentTry = Math.floor(currentTryRaw); + if (currentTry < 0 || currentTry > 6) return null; + + const guesses: GuessEntry[] = []; + for (const entry of guessesRaw) { + const normalized = normalizeGuessEntry(entry); + if (!normalized) return null; + guesses.push(normalized); + } + + if (guesses.length > 6) return null; + if (guesses.length !== currentTry) return null; + + const didGuess = !!didGuessRaw; + if (didGuess && !guesses.some((guess) => guess.state === "Correct")) { + return null; + } + + return { + date, + currentTry, + didGuess, + guesses, + }; +} + +function canonicalState(state: SignedState): string { + return JSON.stringify({ + date: state.date, + currentTry: state.currentTry, + didGuess: state.didGuess, + guesses: state.guesses.map((guess) => ({ + state: guess.state, + song: guess.song + ? { + artist: guess.song.artist, + name: guess.song.name, + } + : null, + })), + }); +} + +function signState(state: SignedState): string { + return signValue("state", canonicalState(state)); +} + +function verifyStateSignature(state: SignedState, sig: unknown): boolean { + if (typeof sig !== "string") return false; + const expectedSig = signState(state); + return safeEqualHex(sig, expectedSig); +} + +const guessLimiter = rateLimit({ + windowMs: 60 * 1000, + limit: 30, + standardHeaders: true, + legacyHeaders: false, + message: { error: "Too many requests. Please slow down." }, +}); + +app.get("/today", (_req, res) => { const date = getUtcDate(); const song = getDailySong(date); const obfuscationKey = getObfuscationKey(date); const songJson = JSON.stringify(song); - const obfuscatedData = xorBuffer(Buffer.from(songJson, 'utf8'), obfuscationKey); + const obfuscatedData = xorBuffer(Buffer.from(songJson, "utf8"), obfuscationKey); + + const initialState: SignedState = { + date, + currentTry: 0, + didGuess: false, + guesses: [], + }; + res.json({ date, - data: obfuscatedData.toString('hex'), + data: obfuscatedData.toString("hex"), + sessionToken: buildSessionToken(date), + initialSig: signState(initialState), + }); +}); + +app.post("/guess", guessLimiter, (req, res) => { + const today = getUtcDate(); + const body = req.body as { + sessionToken?: unknown; + state?: unknown; + sig?: unknown; + guess?: unknown; + }; + + if (!verifySessionToken(body.sessionToken, today)) { + res.status(401).json({ error: "Invalid session token." }); + return; + } + + const state = normalizeSignedState(body.state); + if (!state) { + res.status(400).json({ error: "Invalid state payload." }); + return; + } + + if (state.date !== today) { + res.status(409).json({ error: "State date is not valid for today." }); + return; + } + + if (!verifyStateSignature(state, body.sig)) { + res.status(403).json({ error: "State signature mismatch." }); + return; + } + + if (state.didGuess || state.currentTry >= 6) { + res.status(409).json({ error: "Round already finished." }); + return; + } + + let nextGuess: GuessEntry; + const guess = body.guess as { artist?: unknown; name?: unknown } | null | undefined; + + if (guess == null) { + nextGuess = { + state: "Skipped", + }; + } else if (typeof guess.artist === "string" && typeof guess.name === "string") { + const solution = getDailySong(today); + + if (guess.artist === solution.artist && guess.name === solution.name) { + nextGuess = { + song: { artist: guess.artist, name: guess.name }, + state: "Correct", + }; + } else if (guess.artist === solution.artist) { + nextGuess = { + song: { artist: guess.artist, name: guess.name }, + state: "PartiallyCorrect", + }; + } else { + nextGuess = { + song: { artist: guess.artist, name: guess.name }, + state: "Incorrect", + }; + } + } else { + res.status(400).json({ error: "Invalid guess payload." }); + return; + } + + const nextState: SignedState = { + date: state.date, + currentTry: Math.min(6, state.currentTry + 1), + didGuess: state.didGuess || nextGuess.state === "Correct", + guesses: [...state.guesses, nextGuess], + }; + + res.json({ + state: nextState, + sig: signState(nextState), + guessState: nextGuess.state, }); }); -app.get('/select', (_req, res) => { +app.get("/select", (_req, res) => { const song = songs[Math.floor(Math.random() * songs.length)]; const obfuscationKey = getObfuscationKey(); const songJson = JSON.stringify(song); - const obfuscatedData = xorBuffer(Buffer.from(songJson, 'utf8'), obfuscationKey); + const obfuscatedData = xorBuffer(Buffer.from(songJson, "utf8"), obfuscationKey); res.json({ - data: obfuscatedData.toString('hex'), + data: obfuscatedData.toString("hex"), }); }); -app.get('/songs', (_req, res) => { +app.get("/songs", (_req, res) => { res.json(songs.map(({ artist, name }) => ({ artist, name }))); }); - app.listen(SERVER_PORT, () => console.log(`Server running on :${SERVER_PORT}`)); 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 {}; |
