fertig-classic-games/src/services/localApi.js

174 lines
6.9 KiB
JavaScript

// 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 };