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
|
# 1 bis 8 Party – Mobile Web-App
|
||||||
|
|
||||||
Das ultimative Gesellschaftsspiel über Selbsteinschätzung, Vorurteile und echte
|
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
|
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/)
|
**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/` | React + TypeScript + Vite + Tailwind (Quelle der Web-App) |
|
||||||
| `app/src/data/game.ts` | Kategorien, 20 Statement-Karten, Regelwerk-Texte |
|
| `app/src/data/game.ts` | Kategorien, 20 Statement-Karten, Regelwerk-Texte |
|
||||||
| `app/src/lib/scoring.ts` | Punkteberechnung nach offiziellen Regeln |
|
| `app/src/lib/api.ts` | Client für die Game-API (Räume, Ready, Rankings) |
|
||||||
| `server/` | Docker-Stack für den VPS (Web + Postfix-Relay + Mail-API) |
|
| `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) |
|
| `docs/` | Original-Spielregeln (PDF) |
|
||||||
|
|
||||||
## Entwicklung
|
## 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)
|
||||||
+441
-340
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,9 @@
|
|||||||
# Dienste:
|
# Dienste:
|
||||||
# onebis8-web nginx, statisches dist/ (Mobile-App), proxiet
|
# onebis8-web nginx, statisches dist/ (Mobile-App), proxiet
|
||||||
# POST /api/mail/send → onebis8-mail-api (Token aus app.env)
|
# 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 Postfix-Relay (nur intern im Docker-Netz, kein -p)
|
||||||
# onebis8-mail-api schlanke Mail-HTTP-API mit Outbox/Retry (Muster: mp-mail-api)
|
# onebis8-mail-api schlanke Mail-HTTP-API mit Outbox/Retry (Muster: mp-mail-api)
|
||||||
#
|
#
|
||||||
@@ -27,6 +30,22 @@ services:
|
|||||||
- app.env
|
- app.env
|
||||||
depends_on:
|
depends_on:
|
||||||
- onebis8-mail-api
|
- 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:
|
networks:
|
||||||
- npm_default
|
- npm_default
|
||||||
|
|
||||||
@@ -71,3 +90,4 @@ networks:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
onebis8-mail-data:
|
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;
|
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
|
# SPA-Fallback
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
|
|||||||
Reference in New Issue
Block a user