#!/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 * POST /api/game/room/:code/redmode -> Red Mode an/aus (Host, nur Lobby) * POST /api/game/room/:code/rename -> Raumnamen neu würfeln (Host, nur Lobby) * * 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; } /* ── Lustige Raumnamen ──────────────────────────────────── */ const NAMEN = { adj: [ 'durstigen', 'tanzenden', 'kichernden', 'brüllenden', 'flüsternden', 'glühenden', 'schunkelnden', 'zappelnden', 'grölenden', 'funkelnden', 'randalierenden', 'frechen', 'mutigen', 'verpeilten', 'torkelnden', 'jauchzenden', 'grinsenden', 'schwatzenden', 'pfeifenden', 'trommelnden', 'hopsenden', 'sabbernden', 'schleimigen', 'schillernden', 'tollpatschigen', 'unersättlichen', 'übermütigen', 'schäumenden', 'quietschenden', 'polternden', 'strahlenden', 'müden', ], tier: [ 'Piraten', 'Kartoffeln', 'Nachteulen', 'Chamäleons', 'Pinguine', 'Flamingos', 'Rentiere', 'Einhörner', 'Gurken', 'Muffins', 'Kekse', 'Bananen', 'Tomaten', 'Schildkröten', 'Waschbären', 'Faultiere', 'Kängurus', 'Seehunde', 'Wombats', 'Lemminge', 'Spechte', 'Otter', 'Marder', 'Igel', 'Kamele', 'Lamas', 'Alpakas', 'Zebras', 'Quokkas', 'Tukane', 'Kronkorken', 'Glühbirnen', 'Staubsauger', 'Kaugummis', 'Regenwürmer', 'Luftballons', 'Wackelpuddinge', 'Nudeln', 'Schokolinsen', 'Popcorntüten', ], sing: [ 'Pinguin', 'Kartoffel', 'Muffin', 'Einhorn', 'Otter', 'Faultier', 'Kamel', 'Lama', 'Wackelpudding', 'Kronkorken', 'Kaugummi', 'Regenwurm', 'Igel', 'Quokka', 'Flamingo', 'Waschbär', 'Specht', 'Tomate', 'Banane', 'Keks', ], event: ['Alarm', 'Manie', 'Panik', 'Invasion', 'Verschwörung', 'Olympiade', 'Revolution', 'Notstand', 'Expedition', 'Befreiung'], action: ['Party', 'Chaos', 'Krawall', 'Sause', 'Rambazamba', 'Zoff'], ort: ['Keller', 'Garten', 'Palast', 'U-Boot', 'Zirkus', 'All', 'Baumhaus', 'Schloss', 'Dschungel', 'Wolkenkuckucksheim', 'Kiosk', 'Weltraum'], }; const pick = (liste) => liste[crypto.randomInt(liste.length)]; function lustigerRaumname() { for (let versuch = 0; versuch < 25; versuch++) { const typ = crypto.randomInt(3); let name; if (typ === 0) name = `Die ${pick(NAMEN.adj)} ${pick(NAMEN.tier)}`; else if (typ === 1) name = `${pick(NAMEN.sing)}-${pick(NAMEN.event)}`; else name = `${pick(NAMEN.action)} im ${pick(NAMEN.ort)}`; const vergeben = Object.values(rooms).some((r) => r.raumname === name); if (!vergeben) return name; } return `Die ${pick(NAMEN.adj)} ${pick(NAMEN.tier)} ${crypto.randomInt(100)}`; } 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, raumname: raum.raumname || '', 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, redMode: !!raum.redMode, 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, raumname: lustigerRaumname(), erstelltAm: Date.now(), aktivitaet: Date.now(), status: 'lobby', runde: 0, frage: '', redMode: !!body.redMode, 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) }); } /* Red Mode umschalten (nur Host, nur vor Spielstart) */ if (req.method === 'POST' && aktion === 'redmode') { if (raum.status !== 'lobby') return fehler(res, 409, 'Der Red Ralf Mode ist nur vor Spielstart aenderbar'); if (!spieler.istHost) return fehler(res, 403, 'Nur der Host kann den Red Ralf Mode aendern'); raum.redMode = !!body.redMode; speichern(); return antwort(res, 200, { ok: true, zustand: oeffentlicherZustand(raum, token) }); } /* Raumnamen neu wuerfeln (nur Host, nur vor Spielstart) */ if (req.method === 'POST' && aktion === 'rename') { if (raum.status !== 'lobby') return fehler(res, 409, 'Der Raumname ist nur vor Spielstart aenderbar'); if (!spieler.istHost) return fehler(res, 403, 'Nur der Host kann den Raumnamen aendern'); raum.raumname = lustigerRaumname(); 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(); /* Selbst-Deploy: Der CI-Job ersetzt server.js im Bind-Mount. Sobald sich die * Datei aendert, beendet sich der Prozess sauber – die Restart-Policy des * Containers (unless-stopped) startet ihn mit dem neuen Code neu. */ const eigeneMtime = fs.statSync(__filename).mtimeMs; setInterval(() => { try { if (fs.statSync(__filename).mtimeMs !== eigeneMtime) { console.log('server.js wurde aktualisiert – Neustart für neuen Code'); process.exit(0); } } catch { /* Datei kurzzeitig nicht lesbar – beim naechsten Intervall pruefen */ } }, 5000).unref(); server.listen(PORT, () => console.log(`1bis8-game-api lauscht auf :${PORT} (Räume: ${Object.keys(rooms).length})`));