/** * Zugriff auf die JSON-API des Flask-Backends (ohne Phaser). * * Fehler werden nie geworfen, sondern als Ergebnisobjekt zurückgegeben: Ein * ausgefallener Highscore-Server darf das Spiel nicht unterbrechen. * @module systems/ApiClient */ import { HIGHSCORE } from '../config.js'; /** * @typedef {Object} HighscoreEntry * @property {number} rank * @property {string} name * @property {number} score * @property {number} wave * @property {string} created_at */ /** * @typedef {Object} SubmitResult * @property {boolean} ok * @property {number} [rank] * @property {HighscoreEntry[]} [highscores] * @property {string} [error] Fehlercode der API oder „network“. * @property {string} [message] Lesbare Meldung. * @property {Record} [fields] Feldfehler bei Validierung. */ export class ApiClient { /** * @param {{highscores: string, runs: string}} endpoints * @param {typeof fetch} [fetchImpl] Für Tests austauschbar. * @param {number} [timeoutMs] */ constructor(endpoints, fetchImpl = globalThis.fetch?.bind(globalThis), timeoutMs = HIGHSCORE.requestTimeoutMs) { this.endpoints = endpoints; this.fetchImpl = fetchImpl; this.timeoutMs = timeoutMs; } /** * @param {string} url * @param {RequestInit} [init] * @returns {Promise<{status: number, body: any}>} */ async request(url, init = {}) { const controller = typeof AbortController === 'function' ? new AbortController() : null; const timer = controller ? setTimeout(() => controller.abort(), this.timeoutMs) : null; try { const response = await this.fetchImpl(url, { ...init, signal: controller?.signal, credentials: 'same-origin', headers: { Accept: 'application/json', ...(init.headers ?? {}) }, }); let body = null; try { body = await response.json(); } catch { body = null; } return { status: response.status, body }; } finally { if (timer) clearTimeout(timer); } } /** * Meldet eine neue Spielrunde an. * @returns {Promise} Run-Token oder null, wenn der Server nicht erreichbar ist. */ async startRun() { try { const { status, body } = await this.request(this.endpoints.runs, { method: 'POST' }); return status === 201 && typeof body?.run_token === 'string' ? body.run_token : null; } catch { return null; } } /** * @returns {Promise<{ok: boolean, highscores: HighscoreEntry[]}>} */ async fetchHighscores() { try { const { status, body } = await this.request(this.endpoints.highscores); if (status === 200 && Array.isArray(body?.highscores)) { return { ok: true, highscores: body.highscores }; } } catch { // Netzwerkfehler oder Timeout – unten als Fehlschlag gemeldet. } return { ok: false, highscores: [] }; } /** * Reicht einen Highscore ein. * @param {{name: string, score: number, wave: number, durationS: number, runToken: string}} entry * @returns {Promise} */ async submitHighscore(entry) { const payload = { name: entry.name, score: Math.max(0, Math.floor(entry.score)), wave: Math.max(1, Math.floor(entry.wave)), duration_s: Math.max(0, Math.floor(entry.durationS)), run_token: entry.runToken, }; try { const { status, body } = await this.request(this.endpoints.highscores, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (status === 201) { return { ok: true, rank: body?.rank, highscores: body?.highscores ?? [] }; } return { ok: false, error: body?.error ?? `http_${status}`, message: body?.message ?? 'Unbekannter Fehler.', fields: body?.fields, }; } catch { return { ok: false, error: 'network', message: 'Keine Verbindung zum Server.' }; } } }