Multiplayer: Spielräume mit Code, Ready-Start, geheime Rankings auf jedem Handy (Game-API + Sync-Frontend)
Build & Deploy / deploy (push) Successful in 19s
Build & Deploy / deploy (push) Successful in 19s
This commit is contained in:
@@ -0,0 +1,334 @@
|
||||
#!/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})`));
|
||||
Reference in New Issue
Block a user