#!/usr/bin/env node /* 1 bis 8 Party – Game-API (Multiplayer-Spielräume) * * Reine Node-Standardbibliothek (node:22-alpine, keine Abhängigkeiten) – * Muster: onebis8-mail-api. Verwaltet Spielräume, über die jeder Spieler * von seinem eigenen Handy aus abstimmt. * * Ablauf: * POST /api/game/room -> Raum eröffnen (Host tritt gleich bei) * POST /api/game/room/:code/join -> als Spieler beitreten * GET /api/game/room/:code -> Zustand (pollen; Rankings bleiben geheim) * POST /api/game/room/:code/ready -> Ready-Status setzen * POST /api/game/room/:code/question -> Fragesteller stellt die Frage * POST /api/game/room/:code/ranking -> eigenes geheimes Ranking abgeben * POST /api/game/room/:code/next -> Auflösung -> Punktestand -> neue Runde * * Auth: X-Player-Token-Header (oder Body-Feld "token"), pro Spieler ein Token. * Start: Sobald ALLE Spieler ready sind (min. 3), wechselt der Raum automatisch * in die Fragephase. Auflösung erfolgt automatisch, wenn alle Rankings da sind. */ const http = require('http'); const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); const PORT = parseInt(process.env.PORT || '8080', 10); const DATA_DIR = process.env.DATA_DIR || '/data'; const MIN_SPIELER = 3; const MAX_SPIELER = 8; const ROOM_TTL_MS = 6 * 3600 * 1000; // Räume verfallen nach 6 h fs.mkdirSync(DATA_DIR, { recursive: true }); const STATE_FILE = path.join(DATA_DIR, 'rooms.json'); let rooms = {}; try { rooms = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8') || '{}'); } catch { rooms = {}; } let speicherTimer = null; function speichern() { if (speicherTimer) return; speicherTimer = setTimeout(() => { speicherTimer = null; const tmp = STATE_FILE + '.tmp'; fs.writeFileSync(tmp, JSON.stringify(rooms)); fs.renameSync(tmp, STATE_FILE); }, 400); } /* ── Punkteberechnung (identisch mit app/src/lib/scoring.ts) ── */ function berechneRunde(spieler, rankings) { const n = spieler.length; const listen = spieler.map((s) => rankings[s]).filter(Boolean); const mehrheit = []; const stimmenProPlatz = []; for (let p = 0; p < n; p++) { const zaehler = {}; for (const liste of listen) { const person = liste[p]; if (person && !mehrheit.includes(person)) { zaehler[person] = (zaehler[person] ?? 0) + 1; } } stimmenProPlatz.push(zaehler); let beste = ''; let besteStimmen = -1; let besterSchnitt = Infinity; for (const k of Object.keys(zaehler)) { const stimmen = zaehler[k]; const listenMitK = listen.filter((l) => l.includes(k)).length; const schnitt = listen.reduce((acc, l) => acc + (l.indexOf(k) + 1 || 0), 0) / Math.max(1, listenMitK); if (stimmen > besteStimmen || (stimmen === besteStimmen && schnitt < besterSchnitt)) { beste = k; besteStimmen = stimmen; besterSchnitt = schnitt; } } mehrheit.push(beste); } const punkte = {}; for (const s of spieler) punkte[s] = 0; for (const s of spieler) { const liste = rankings[s]; if (!liste) continue; for (let p = 0; p < n; p++) { if (liste[p] === mehrheit[p]) punkte[s] += n - p; } } return { mehrheit, punkte, platzDetails: mehrheit.map((person, p) => ({ platz: p + 1, person, stimmen: stimmenProPlatz[p][person] ?? 0, punkte: n - p, })), }; } /* ── Helfer ─────────────────────────────────────────────── */ function neuerCode() { const zeichen = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // ohne 0/O/1/I (Verwechslungen) for (let versuch = 0; versuch < 50; versuch++) { let code = ''; for (let i = 0; i < 4; i++) code += zeichen[crypto.randomInt(zeichen.length)]; if (!rooms[code]) return code; } return null; } function raumFinden(code) { const raum = rooms[(code || '').toUpperCase()]; if (!raum) return null; if (Date.now() - raum.aktivitaet > ROOM_TTL_MS) { delete rooms[raum.code]; speichern(); return null; } return raum; } function spielerFinden(raum, token) { return raum.spieler.find((s) => s.token === token) || null; } function fragesteller(raum) { return raum.spieler[raum.runde % raum.spieler.length]?.name ?? ''; } /* Öffentliche Sicht: Rankings bleiben bis zur Auflösung verborgen */ function oeffentlicherZustand(raum, token) { const ich = spielerFinden(raum, token); const alleReady = raum.spieler.length >= MIN_SPIELER && raum.spieler.every((s) => s.ready); return { code: raum.code, status: raum.status, runde: raum.runde, anzahlRunden: raum.spieler.length, frage: raum.frage, fragesteller: raum.status === 'lobby' ? '' : fragesteller(raum), spieler: raum.spieler.map((s) => ({ name: s.name, ready: s.ready, istHost: !!s.istHost, rankingAbgegeben: !!raum.rankings[s.name], })), minSpieler: MIN_SPIELER, maxSpieler: MAX_SPIELER, alleReady, rankingsFertig: Object.keys(raum.rankings).length, punkte: raum.punkte, ergebnis: raum.status === 'reveal' || raum.status === 'scoreboard' || raum.status === 'finished' ? raum.ergebnis : null, ich: ich ? ich.name : '', }; } function antwort(res, code, body) { const daten = JSON.stringify(body); res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(daten) }); res.end(daten); } function fehler(res, code, msg) { antwort(res, code, { fehler: msg }); } /* ── HTTP ───────────────────────────────────────────────── */ const server = http.createServer(async (req, res) => { const u = new URL(req.url, 'http://x'); const pfad = u.pathname.replace(/\/+$/, '') || '/'; const teile = pfad.split('/').filter(Boolean); // z.B. ['api','game','room','ABCD','join'] if (teile[0] !== 'api' || teile[1] !== 'game') return fehler(res, 404, 'nicht gefunden'); let body = {}; try { if (req.method === 'POST') { let roh = ''; for await (const chunk of req) { roh += chunk; if (roh.length > 32_000) return fehler(res, 413, 'Body zu gross'); } body = JSON.parse(roh || '{}'); } } catch { return fehler(res, 400, 'ungueltiges JSON'); } const token = req.headers['x-player-token'] || body.token || ''; const code = (teile[3] || '').toUpperCase(); const aktion = teile[4] || ''; try { /* Raum eröffnen */ if (req.method === 'POST' && teile[2] === 'room' && !aktion) { const name = String(body.name || '').trim().slice(0, 16); if (!name) return fehler(res, 400, 'Name fehlt'); const neuerRaumCode = neuerCode(); if (!neuerRaumCode) return fehler(res, 500, 'kein Raumcode frei'); const raum = { code: neuerRaumCode, erstelltAm: Date.now(), aktivitaet: Date.now(), status: 'lobby', runde: 0, frage: '', spieler: [{ name, token: crypto.randomBytes(18).toString('hex'), ready: false, istHost: true }], rankings: {}, punkte: { [name]: 0 }, ergebnis: null, }; rooms[neuerRaumCode] = raum; speichern(); return antwort(res, 201, { ok: true, code: raum.code, token: raum.spieler[0].token, zustand: oeffentlicherZustand(raum, raum.spieler[0].token) }); } const raum = raumFinden(code); if (!raum) return fehler(res, 404, 'Spielraum nicht gefunden'); raum.aktivitaet = Date.now(); /* Zustand lesen */ if (req.method === 'GET' && teile[2] === 'room' && !aktion) { return antwort(res, 200, oeffentlicherZustand(raum, token)); } /* Beitreten (braucht noch keinen Token – der wird erst ausgestellt) */ if (req.method === 'POST' && aktion === 'join') { if (raum.status !== 'lobby') return fehler(res, 409, 'Spiel läuft bereits'); const name = String(body.name || '').trim().slice(0, 16); if (!name) return fehler(res, 400, 'Name fehlt'); if (raum.spieler.some((s) => s.name.toLowerCase() === name.toLowerCase())) return fehler(res, 409, 'Name ist bereits vergeben'); if (raum.spieler.length >= MAX_SPIELER) return fehler(res, 409, `Maximal ${MAX_SPIELER} Spieler`); const neuerSpieler = { name, token: crypto.randomBytes(18).toString('hex'), ready: false, istHost: false }; raum.spieler.push(neuerSpieler); raum.punkte[name] = 0; speichern(); return antwort(res, 201, { ok: true, token: neuerSpieler.token, zustand: oeffentlicherZustand(raum, neuerSpieler.token) }); } const spieler = spielerFinden(raum, token); if (!spieler) return fehler(res, 401, 'ungueltiger Spieler-Token'); /* Ready */ if (req.method === 'POST' && aktion === 'ready') { if (raum.status !== 'lobby') return fehler(res, 409, 'Spiel läuft bereits'); spieler.ready = !!body.ready; // Alle ready + mindestens 3 -> Spiel startet automatisch if (raum.spieler.length >= MIN_SPIELER && raum.spieler.every((s) => s.ready)) { raum.status = 'question'; raum.runde = 0; } speichern(); return antwort(res, 200, { ok: true, zustand: oeffentlicherZustand(raum, token) }); } /* Frage stellen */ if (req.method === 'POST' && aktion === 'question') { if (raum.status !== 'question') return fehler(res, 409, 'Keine Fragephase'); if (spieler.name !== fragesteller(raum)) return fehler(res, 403, 'Nur der Fragesteller stellt die Frage'); const frage = String(body.frage || '').trim().slice(0, 300); if (!frage) return fehler(res, 400, 'Frage fehlt'); raum.frage = frage; raum.rankings = {}; raum.status = 'ranking'; speichern(); return antwort(res, 200, { ok: true, zustand: oeffentlicherZustand(raum, token) }); } /* Geheimes Ranking abgeben */ if (req.method === 'POST' && aktion === 'ranking') { if (raum.status !== 'ranking') return fehler(res, 409, 'Keine Rankingphase'); const reihenfolge = Array.isArray(body.reihenfolge) ? body.reihenfolge.map(String) : []; const namen = raum.spieler.map((s) => s.name).sort(); if (reihenfolge.length !== namen.length || [...reihenfolge].sort().join('|') !== namen.join('|')) { return fehler(res, 400, 'Reihenfolge muss alle Spieler genau einmal enthalten'); } raum.rankings[spieler.name] = reihenfolge; // Alle abgegeben -> automatische Auflösung mit Punkteberechnung if (raum.spieler.every((s) => raum.rankings[s.name])) { const ergebnis = berechneRunde(raum.spieler.map((s) => s.name), raum.rankings); for (const [name, pkt] of Object.entries(ergebnis.punkte)) { raum.punkte[name] = (raum.punkte[name] ?? 0) + pkt; } raum.ergebnis = ergebnis; raum.status = 'reveal'; } speichern(); return antwort(res, 200, { ok: true, zustand: oeffentlicherZustand(raum, token) }); } /* Weiter (Auflösung -> Punktestand -> naechste Runde / Ende) */ if (req.method === 'POST' && aktion === 'next') { if (raum.status === 'reveal') { raum.status = 'scoreboard'; } else if (raum.status === 'scoreboard') { raum.runde += 1; raum.frage = ''; raum.rankings = {}; raum.ergebnis = null; raum.status = raum.runde >= raum.spieler.length ? 'finished' : 'question'; } else { return fehler(res, 409, 'Hier geht es nicht weiter'); } speichern(); return antwort(res, 200, { ok: true, zustand: oeffentlicherZustand(raum, token) }); } return fehler(res, 404, 'nicht gefunden'); } catch (err) { console.error(`[${new Date().toISOString()}] FEHLER ${req.method} ${pfad}:`, err); return fehler(res, 500, 'interner Fehler'); } }); // Aufraeumer: verfallene Raeume entfernen setInterval(() => { const jetzt = Date.now(); for (const [code, raum] of Object.entries(rooms)) { if (jetzt - raum.aktivitaet > ROOM_TTL_MS) { delete rooms[code]; console.log(`Raum ${code} verfallen`); } } speichern(); }, 10 * 60 * 1000).unref(); server.listen(PORT, () => console.log(`1bis8-game-api lauscht auf :${PORT} (Räume: ${Object.keys(rooms).length})`));