// In-browser replacement for the old Express `/api` backend. // // Everything that used to be an HTTP round-trip is handled here against // localStorage and the ported word engines. The public surface is a single // `dispatch(method, path, { body, formData })` that returns { status, data }, // mirroring what the server endpoints returned. `services/api.js` calls this // instead of `fetch`, so the ~135 call sites across the games stay unchanged. import { listGames, getGame } from '../data/gamesRegistry.js'; import * as store from './localStore.js'; import wordRouter from '../words/wordRouter.js'; const ok = (data) => ({ status: 200, data }); const created = (data) => ({ status: 201, data }); const bad = (error, status = 400) => ({ status, data: { error } }); // ── Profile / auth ────────────────────────────────────────────────────────── // The old `/auth/me` user shape is a subset of the profile. function userFromProfile(p) { return { id: p.id, email: p.email, username: p.username, emailVerified: p.emailVerified, displayName: p.displayName, avatarPath: p.avatarPath, }; } function readFileAsDataUrl(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = () => reject(reader.error ?? new Error('Failed to read file.')); reader.readAsDataURL(file); }); } // ── Dispatch ────────────────────────────────────────────────────────────────── export async function dispatch(method, rawPath, { body, formData } = {}) { const [path, queryString = ''] = String(rawPath).split('?'); const query = Object.fromEntries(new URLSearchParams(queryString)); // ----- Word games: delegate to the ported engines ----- if (path.startsWith('/words/')) { const subpath = path.slice('/words'.length); // e.g. '/wordle/start' return wordRouter.handle(method, subpath, query, body); } // ----- Auth (no accounts: a single implicit local profile) ----- if (path === '/auth/me' && method === 'GET') { return ok({ user: userFromProfile(store.getProfile()) }); } if (path === '/auth/login' && method === 'POST') { return ok({ user: userFromProfile(store.getProfile()) }); } if (path === '/auth/register' && method === 'POST') { const p = store.saveProfile({}); return created({ user: userFromProfile(p), verification: { sent: false, devLink: null } }); } if (path === '/auth/logout' && method === 'POST') { return ok({ ok: true }); } // ----- Profile ----- if (path === '/profile' && method === 'GET') { return ok({ profile: store.getProfile() }); } if (path === '/profile' && method === 'PATCH') { const patch = {}; if (body?.displayName !== undefined) patch.displayName = String(body.displayName).slice(0, 60); if (body?.bio !== undefined) patch.bio = String(body.bio).slice(0, 500); return ok({ profile: store.saveProfile(patch) }); } if (path === '/profile/avatar' && method === 'POST') { const file = formData?.get?.('avatar'); if (!file) return bad('No file uploaded.'); const dataUrl = await readFileAsDataUrl(file); return ok({ profile: store.saveProfile({ avatarPath: dataUrl }) }); } if (path === '/profile/chips' && method === 'GET') { return ok({ chips: store.getProfile().chips }); } if (path === '/profile/chips/adjust' && method === 'POST') { const { delta } = body ?? {}; if (typeof delta !== 'number' || !Number.isInteger(delta)) { return bad('delta must be an integer.'); } const next = Math.max(0, store.getProfile().chips + delta); return ok({ chips: store.saveProfile({ chips: next }).chips }); } if (path === '/profile/chips/reset' && method === 'POST') { if (store.getProfile().chips >= 100) { return bad('Financial reset is only available when your chip balance is below $100.', 403); } return ok({ chips: store.saveProfile({ chips: store.CHIPS_RESET_AMOUNT }).chips }); } // ----- Games catalog ----- if (path === '/games' && method === 'GET') { return ok({ games: listGames() }); } // ----- History ----- if (path === '/history' && method === 'GET') { const matches = store.read('history', []); const summary = matches.reduce( (acc, m) => { if (m.result === 'win') acc.wins += 1; else if (m.result === 'loss') acc.losses += 1; else if (m.result === 'draw') acc.draws += 1; return acc; }, { wins: 0, losses: 0, draws: 0 }, ); return ok({ matches, summary }); } if (path === '/history/single-player' && method === 'POST') { const { slug, score, opponentScores, result } = body ?? {}; const def = getGame(slug); if (typeof slug !== 'string' || !slug || !def) return bad('Unknown game slug.'); if (!Number.isFinite(score) || score < 0 || score > 100000) return bad('Invalid score.'); if (!Array.isArray(opponentScores) || !opponentScores.every((n) => Number.isFinite(n))) { return bad('Invalid opponentScores.'); } if (!['win', 'loss', 'draw'].includes(result)) return bad('Invalid result.'); const matches = store.read('history', []); const matchId = (matches.at(-1)?.matchId ?? 0) + 1; const now = new Date().toISOString(); matches.push({ matchId, slug: def.slug, name: def.name, category: def.category, startedAt: now, endedAt: now, status: 'completed', seat: 0, result, score: Math.round(score), }); // Keep the list bounded, like the old "last 100" query. store.write('history', matches.slice(-100)); return ok({ matchId }); } // ----- Puzzle progress ----- const puzzleMatch = path.match(/^\/puzzles\/([^/]+)\/(progress|complete|reset)$/); if (puzzleMatch) { const [, slug, action] = puzzleMatch; if (!getGame(slug)) return bad('Unknown game slug.'); const progress = store.read('puzzleProgress', {}); const current = progress[slug] ?? 0; if (action === 'progress' && method === 'GET') { return ok({ levelsCompleted: current }); } if (action === 'complete' && method === 'POST') { const { level } = body ?? {}; if (!Number.isInteger(level) || level < 1) return bad('Invalid level.'); // Levels must clear in order: only advance when level === current + 1. if (level !== current + 1) return ok({ levelsCompleted: current }); progress[slug] = level; store.write('puzzleProgress', progress); return ok({ levelsCompleted: level }); } if (action === 'reset' && method === 'POST') { delete progress[slug]; store.write('puzzleProgress', progress); return ok({ levelsCompleted: 0 }); } } return bad(`No local route ${method} ${path}`, 404); } export default { dispatch };