Multiplayer: Spielräume mit Code, Ready-Start, geheime Rankings auf jedem Handy (Game-API + Sync-Frontend)
Build & Deploy / deploy (push) Successful in 19s

This commit is contained in:
Kimi Work
2026-10-10 17:22:55 +02:00
parent ad6230c335
commit 4e2eda855a
6 changed files with 972 additions and 390 deletions
+7 -4
View File
@@ -1,9 +1,11 @@
# 1 bis 8 Party – Mobile Web-App
Das ultimative Gesellschaftsspiel über Selbsteinschätzung, Vorurteile und echte
Offenbarungen – als mobile Web-App umgesetzt: offizielle Spielregeln, 20
Offenbarungen – als mobile Multiplayer-Web-App: offizielle Spielregeln, 20
Statement-Karten in 5 Kategorien und ein interaktiver Spielleiter für 3–8
Spieler mit automatischer Mehrheits-Punkteberechnung.
Spieler. **Jeder spielt und stimmt auf seinem eigenen Handy ab:** Ein Host
eröffnet einen Spielraum und gibt den 4-stelligen Code weiter; sobald alle
Spieler „Ready“ melden, startet das Spiel automatisch.
**Live:** https://1bis.hocks.eu/ (Alias: https://1bis8.hocks.eu/)
@@ -13,8 +15,9 @@ Spieler mit automatischer Mehrheits-Punkteberechnung.
|---|---|
| `app/` | React + TypeScript + Vite + Tailwind (Quelle der Web-App) |
| `app/src/data/game.ts` | Kategorien, 20 Statement-Karten, Regelwerk-Texte |
| `app/src/lib/scoring.ts` | Punkteberechnung nach offiziellen Regeln |
| `server/` | Docker-Stack für den VPS (Web + Postfix-Relay + Mail-API) |
| `app/src/lib/api.ts` | Client für die Game-API (Räume, Ready, Rankings) |
| `server/game/api/server.js` | Game-API: Spielräume, Auto-Start, Punkteberechnung |
| `server/web/`, `server/mail/` | nginx-Web-Stack und Postfix-Relay + Mail-API |
| `docs/` | Original-Spielregeln (PDF) |
## Entwicklung
+114
View File
@@ -0,0 +1,114 @@
// ── Client für die Game-API (Multiplayer-Spielräume) ──────
export type RaumStatus = 'lobby' | 'question' | 'ranking' | 'reveal' | 'scoreboard' | 'finished'
export interface SpielerInfo {
name: string
ready: boolean
istHost: boolean
rankingAbgegeben: boolean
}
export interface PlatzDetail {
platz: number
person: string
stimmen: number
punkte: number
}
export interface Ergebnis {
mehrheit: string[]
punkte: Record<string, number>
platzDetails: PlatzDetail[]
}
export interface RaumZustand {
code: string
status: RaumStatus
runde: number
anzahlRunden: number
frage: string
fragesteller: string
spieler: SpielerInfo[]
minSpieler: number
maxSpieler: number
alleReady: boolean
rankingsFertig: number
punkte: Record<string, number>
ergebnis: Ergebnis | null
ich: string
}
export interface RaumSession {
code: string
token: string
name: string
}
const SESSION_KEY = 'einsbisacht-raum-v1'
export function ladeSession(): RaumSession | null {
try {
const roh = localStorage.getItem(SESSION_KEY)
return roh ? (JSON.parse(roh) as RaumSession) : null
} catch {
return null
}
}
export function speichereSession(s: RaumSession | null) {
if (s) localStorage.setItem(SESSION_KEY, JSON.stringify(s))
else localStorage.removeItem(SESSION_KEY)
}
class ApiFehler extends Error {}
async function anfrage(methode: 'GET' | 'POST', pfad: string, body?: object, token?: string): Promise<RaumZustand> {
const res = await fetch(`/api/game/room${pfad}`, {
method: methode,
headers: {
'Content-Type': 'application/json',
...(token ? { 'X-Player-Token': token } : {}),
},
...(body ? { body: JSON.stringify(body) } : {}),
})
const daten = await res.json().catch(() => ({}))
if (!res.ok) throw new ApiFehler(daten.fehler || `Fehler ${res.status}`)
if (daten.zustand) return daten.zustand as RaumZustand
throw new ApiFehler('Unerwartete Antwort der Spiel-API')
}
/** Raum eröffnen; liefert Session direkt mit */
export async function raumErstellen(name: string): Promise<{ session: RaumSession; zustand: RaumZustand }> {
const res = await fetch('/api/game/room', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
})
const daten = await res.json().catch(() => ({}))
if (!res.ok || !daten.token) throw new ApiFehler(daten.fehler || 'Raum konnte nicht erstellt werden')
const session: RaumSession = { code: daten.code, token: daten.token, name }
speichereSession(session)
return { session, zustand: daten.zustand }
}
/** Raum beitreten; liefert Session direkt mit */
export async function raumBeitreten(code: string, name: string): Promise<{ session: RaumSession; zustand: RaumZustand }> {
const res = await fetch(`/api/game/room/${code.toUpperCase()}/join`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
})
const daten = await res.json().catch(() => ({}))
if (!res.ok || !daten.token) throw new ApiFehler(daten.fehler || 'Beitritt fehlgeschlagen')
const session: RaumSession = { code: code.toUpperCase(), token: daten.token, name }
speichereSession(session)
return { session, zustand: daten.zustand }
}
export const zustandLaden = (s: RaumSession) => anfrage('GET', `/${s.code}`, undefined, s.token)
export const readySetzen = (s: RaumSession, ready: boolean) => anfrage('POST', `/${s.code}/ready`, { ready }, s.token)
export const frageStellen = (s: RaumSession, frage: string) => anfrage('POST', `/${s.code}/question`, { frage }, s.token)
export const rankingAbgeben = (s: RaumSession, reihenfolge: string[]) =>
anfrage('POST', `/${s.code}/ranking`, { reihenfolge }, s.token)
export const naechstePhase = (s: RaumSession) => anfrage('POST', `/${s.code}/next`, {}, s.token)
+487 -386
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -5,6 +5,9 @@
# Dienste:
# onebis8-web nginx, statisches dist/ (Mobile-App), proxiet
# POST /api/mail/send → onebis8-mail-api (Token aus app.env)
# und /api/game/* → onebis8-api (Multiplayer-Spielräume)
# onebis8-api Game-API: Spielräume, Ready-Start, geheime Rankings,
# Punkteberechnung (reine Node-Standardbibliothek)
# onebis8-mail Postfix-Relay (nur intern im Docker-Netz, kein -p)
# onebis8-mail-api schlanke Mail-HTTP-API mit Outbox/Retry (Muster: mp-mail-api)
#
@@ -27,6 +30,22 @@ services:
- app.env
depends_on:
- onebis8-mail-api
- onebis8-api
networks:
- npm_default
# Game-API fuer Multiplayer-Spielräume (jeder Spieler auf seinem Handy)
onebis8-api:
image: node:22-alpine
container_name: onebis8-api
restart: unless-stopped
environment:
PORT: "8080"
DATA_DIR: /data
command: ["node", "/app/server.js"]
volumes:
- ./game/api/server.js:/app/server.js:ro
- onebis8-game-data:/data
networks:
- npm_default
@@ -71,3 +90,4 @@ networks:
volumes:
onebis8-mail-data:
onebis8-game-data:
+334
View File
@@ -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})`));
@@ -45,6 +45,16 @@ server {
proxy_read_timeout 15s;
}
# Game-API (Multiplayer-Spielräume, Container onebis8-api)
location /api/game/ {
set $game_api http://onebis8-api:8080;
proxy_pass $game_api;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
client_max_body_size 64k;
proxy_read_timeout 30s;
}
# SPA-Fallback
location / {
try_files $uri $uri/ /index.html;