Files
1bis8/server/game/api/server.js
T
Kimi Work b1fb42fe7c
Build & Deploy / deploy (push) Successful in 19s
Lustige Raumnamen: Generator, Lobby-Anzeige + Würfel-Button
- Server: 3 Name-Muster (Adj+Tiere, Wort-Event, Action-im-Ort), rename-Endpunkt (Host, Lobby)
- Lobby: Raumname ueber Code, Host kann wuerfeln, Share-Text mit Name
2026-10-10 19:06:57 +02:00

402 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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, 'Red Mode ist nur vor Spielstart aenderbar');
if (!spieler.istHost) return fehler(res, 403, 'Nur der Host kann den Red 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();
server.listen(PORT, () => console.log(`1bis8-game-api lauscht auf :${PORT} (Räume: ${Object.keys(rooms).length})`));