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:
@@ -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
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user