/** * TRXTD game server: static frontend (dist/) + multiplayer WebSocket relay. * Single port for everything – ideal for Docker deployments. * * Dev: npm run server (ws on :3001 next to vite on :5173) * Prod: serves dist/ + ws on PORT (default 3001) * * Security hardening: * - WebSocket maxPayload limit (prevents DoS) * - Heartbeat (ping/pong) to clean dead sockets * - Rate-limiting per connection (action flood protection) * - Strict message schema validation * - Static file serving with path-traversal protection * - Inactive room cleanup timeout */ import http from 'node:http' import { promises as fs } from 'node:fs' import path from 'node:path' import crypto from 'node:crypto' import { fileURLToPath } from 'node:url' import { WebSocketServer } from 'ws' import { buyUpgrade, cleanExpiredSessions, createSession, createUserLocal, deleteSession, findOrCreateUserOidc, getSession, getUserById, getUserByUsername, hashPassword, mergeGuest, recordGameResult, verifyPassword, } from './db.mjs' import { META_UPGRADES, calcCrystalsEarned } from '../shared/meta-upgrades.mjs' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const DIST_DIR = process.env.DIST_DIR || path.join(__dirname, '..', 'dist') const PORT = Number(process.env.PORT || 3001) const MAX_PAYLOAD_BYTES = 4096 const MAX_ACTIONS_PER_SEC = 30 const ROOM_TIMEOUT_MS = 30 * 60 * 1000 // public hosting hardening const MAX_WS_TOTAL = 500 // total concurrent websocket connections const MAX_WS_PER_IP = 20 // concurrent websocket connections per client IP const MAX_ROOMS = 300 // concurrent rooms // ------------------------------------------------------------------ accounts & OIDC config const SESSION_COOKIE = 'trxtd_session' const OIDC = { enabled: String(process.env.OIDC_ENABLED || 'false').toLowerCase() === 'true', issuer: String(process.env.OIDC_ISSUER || '').replace(/\/$/, ''), clientId: String(process.env.OIDC_CLIENT_ID || ''), clientSecret: String(process.env.OIDC_CLIENT_SECRET || ''), redirectUri: String(process.env.OIDC_REDIRECT_URI || ''), buttonLabel: String(process.env.OIDC_BUTTON_LABEL || 'Mit Single Sign-On anmelden'), } /** pending OIDC authorization attempts (state -> pkce verifier), short-lived */ const oidcPending = new Map() let oidcDiscovery = null let oidcDiscoveryAt = 0 // periodic session & oidc-state cleanup setInterval(() => { cleanExpiredSessions() const now = Date.now() for (const [state, entry] of oidcPending.entries()) { if (now - entry.createdAt > 10 * 60 * 1000) oidcPending.delete(state) } }, 10 * 60 * 1000).unref() const CSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; " + "img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; " + "base-uri 'self'; frame-ancestors 'none'" // ------------------------------------------------------------------ client ip (proxy aware) function isPrivateIp(ip) { if (!ip) return false if (ip === '::1' || ip.startsWith('fc') || ip.startsWith('fd') || ip.startsWith('fe80')) return true const m = ip.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/) if (!m) return false const a = Number(m[1]) const b = Number(m[2]) return a === 10 || a === 127 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 169 && b === 254) } /** * Real client IP. X-Forwarded-For is only trusted when the direct peer is a * private address (reverse proxy / docker network), so external clients cannot * spoof it. Do not expose the port directly to the internet. */ function clientIp(req) { const socketIp = String(req.socket?.remoteAddress || 'unknown').replace(/^::ffff:/, '') const xff = req.headers['x-forwarded-for'] if (xff && isPrivateIp(socketIp)) { const first = String(xff).split(',')[0].trim().replace(/^::ffff:/, '') if (first) return first } return socketIp } /** * WebSocket origin check: blocks cross-site websocket hijacking (other * websites opening connections to this server from a victim's browser). * Allowed: same origin, local development, entries in ALLOWED_ORIGINS. */ function originAllowed(req) { const origin = req.headers.origin if (!origin) return true // non-browser clients (curl, node, bots without origin) const host = String(req.headers.host || '').toLowerCase() let originHost = '' try { originHost = new URL(origin).host.toLowerCase() } catch { return false } if (host && originHost === host) return true if (/^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/.test(originHost)) return true const extra = String(process.env.ALLOWED_ORIGINS || '') .toLowerCase() .split(',') .map((s) => s.trim()) .filter(Boolean) if (extra.some((o) => o === originHost || o === origin.toLowerCase())) return true return false } // ------------------------------------------------------------------ auth helpers function parseCookies(req) { const out = {} const header = req.headers.cookie if (!header) return out for (const part of header.split(';')) { const idx = part.indexOf('=') if (idx === -1) continue out[part.slice(0, idx).trim()] = decodeURIComponent(part.slice(idx + 1).trim()) } return out } function sessionTokenFromReq(req) { return parseCookies(req)[SESSION_COOKIE] || null } function setSessionCookie(res, token, expiresAt, req) { const proto = String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim() const secure = proto === 'https' || (req.socket?.encrypted ?? false) const sameSite = 'Lax' res.setHeader( 'Set-Cookie', `${SESSION_COOKIE}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=${sameSite}${secure ? '; Secure' : ''}; Expires=${new Date(expiresAt).toUTCString()}`, ) } function clearSessionCookie(res) { res.setHeader('Set-Cookie', `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`) } function json(res, status, obj) { const body = JSON.stringify(obj) res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff', }) res.end(body) } function readJsonBody(req, maxBytes = 8192) { return new Promise((resolve, reject) => { let size = 0 const chunks = [] req.on('data', (c) => { size += c.length if (size > maxBytes) { reject(new Error('body too large')) req.destroy() return } chunks.push(c) }) req.on('end', () => { try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}')) } catch { reject(new Error('invalid json')) } }) req.on('error', reject) }) } function publicUser(user) { if (!user) return null return { id: user.id, username: user.username, displayName: user.display_name, crystals: user.crystals, upgrades: user.upgrades, stats: user.stats, oidc: Boolean(user.oidc_sub), } } // simple per-IP rate limit for auth endpoints const authBuckets = new Map() function authRateLimited(ip) { const now = Date.now() const b = authBuckets.get(ip) if (!b || now > b.resetAt) { authBuckets.set(ip, { count: 1, resetAt: now + 60000 }) return false } b.count++ return b.count > 20 // 20 auth attempts per minute per IP } // ------------------------------------------------------------------ OIDC function base64urlDecode(s) { return Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/'), 'base64') } async function fetchJson(url) { const res = await fetch(url, { headers: { Accept: 'application/json' } }) if (!res.ok) throw new Error(`fetch ${url} -> ${res.status}`) return res.json() } async function oidcDiscover() { const now = Date.now() if (oidcDiscovery && now - oidcDiscoveryAt < 10 * 60 * 1000) return oidcDiscovery const d = await fetchJson(`${OIDC.issuer}/.well-known/openid-configuration`) if (!d.authorization_endpoint || !d.token_endpoint) throw new Error('invalid OIDC discovery') oidcDiscovery = d oidcDiscoveryAt = now return d } async function verifyIdToken(idToken, discovery) { const parts = idToken.split('.') if (parts.length !== 3) throw new Error('malformed id_token') const [h, p, sig] = parts const header = JSON.parse(base64urlDecode(h).toString('utf8')) const payload = JSON.parse(base64urlDecode(p).toString('utf8')) if (header.alg !== 'RS256') throw new Error('unsupported id_token alg') const jwks = await fetchJson(discovery.jwks_uri) const jwk = jwks.keys.find((k) => k.kid === header.kid) || jwks.keys[0] if (!jwk) throw new Error('no matching JWKS key') const publicKey = crypto.createPublicKey({ key: jwk, format: 'jwk' }) const ok = crypto.verify('RSA-SHA256', Buffer.from(`${h}.${p}`), publicKey, base64urlDecode(sig)) if (!ok) throw new Error('id_token signature invalid') if (payload.iss !== OIDC.issuer && payload.iss !== discovery.issuer) throw new Error('issuer mismatch') const aud = Array.isArray(payload.aud) ? payload.aud : [payload.aud] if (!aud.includes(OIDC.clientId)) throw new Error('audience mismatch') if (payload.exp && payload.exp * 1000 < Date.now()) throw new Error('id_token expired') return payload } // ------------------------------------------------------------------ REST API async function handleApi(req, res, url) { const ip = clientIp(req) const p = url.pathname // --- public config (does OIDC exist?) --- if (p === '/api/config' && req.method === 'GET') { return json(res, 200, { oidcEnabled: OIDC.enabled, oidcLabel: OIDC.buttonLabel, }) } // --- current session --- if (p === '/api/auth/me' && req.method === 'GET') { const sess = getSession(sessionTokenFromReq(req)) if (!sess) return json(res, 200, { user: null }) return json(res, 200, { user: publicUser(getUserById(sess.user_id)) }) } // --- logout --- if (p === '/api/auth/logout' && req.method === 'POST') { deleteSession(sessionTokenFromReq(req)) clearSessionCookie(res) return json(res, 200, { ok: true }) } // --- register (local) --- if (p === '/api/auth/register' && req.method === 'POST') { if (authRateLimited(ip)) return json(res, 429, { error: 'Zu viele Versuche. Bitte warte kurz.' }) const body = await readJsonBody(req).catch(() => null) if (!body) return json(res, 400, { error: 'Ungültige Eingabe.' }) const username = String(body.username || '').trim().toLowerCase() const password = String(body.password || '') const displayName = String(body.displayName || username).trim().slice(0, 24) || username if (!/^[a-z0-9_-]{3,16}$/.test(username)) return json(res, 400, { error: 'Benutzername: 3–16 Zeichen (a–z, 0–9, _ -).' }) if (password.length < 8) return json(res, 400, { error: 'Passwort: mindestens 8 Zeichen.' }) if (getUserByUsername(username)) return json(res, 409, { error: 'Benutzername bereits vergeben.' }) const hash = await hashPassword(password) const user = createUserLocal(username, displayName, hash) const sess = createSession(user.id) setSessionCookie(res, sess.token, sess.expiresAt, req) return json(res, 200, { user: publicUser(user) }) } // --- login (local) --- if (p === '/api/auth/login' && req.method === 'POST') { if (authRateLimited(ip)) return json(res, 429, { error: 'Zu viele Versuche. Bitte warte kurz.' }) const body = await readJsonBody(req).catch(() => null) if (!body) return json(res, 400, { error: 'Ungültige Eingabe.' }) const username = String(body.username || '').trim().toLowerCase() const password = String(body.password || '') const row = getUserByUsername(username) if (!row || !row.password_hash) return json(res, 401, { error: 'Benutzername oder Passwort falsch.' }) const ok = await verifyPassword(password, row.password_hash) if (!ok) return json(res, 401, { error: 'Benutzername oder Passwort falsch.' }) const user = getUserById(row.id) const sess = createSession(user.id) setSessionCookie(res, sess.token, sess.expiresAt, req) return json(res, 200, { user: publicUser(user) }) } // --- OIDC: start login --- if (p === '/api/auth/oidc/login' && req.method === 'GET') { if (!OIDC.enabled) return json(res, 400, { error: 'OIDC ist nicht aktiviert.' }) try { const d = await oidcDiscover() const state = crypto.randomBytes(16).toString('hex') const verifier = crypto.randomBytes(32).toString('base64url') const challenge = crypto.createHash('sha256').update(verifier).digest('base64url') oidcPending.set(state, { verifier, createdAt: Date.now() }) const q = new URLSearchParams({ response_type: 'code', client_id: OIDC.clientId, redirect_uri: OIDC.redirectUri, scope: 'openid profile email', state, code_challenge: challenge, code_challenge_method: 'S256', }) res.writeHead(302, { Location: `${d.authorization_endpoint}?${q.toString()}` }) return res.end() } catch (e) { return json(res, 502, { error: 'OIDC-Provider nicht erreichbar.' }) } } // --- OIDC: callback --- if (p === '/api/auth/oidc/callback' && req.method === 'GET') { if (!OIDC.enabled) return json(res, 400, { error: 'OIDC ist nicht aktiviert.' }) const code = url.searchParams.get('code') const state = url.searchParams.get('state') const pending = state ? oidcPending.get(state) : null if (!code || !pending) { res.writeHead(302, { Location: '/?auth=error' }) return res.end() } oidcPending.delete(state) try { const d = await oidcDiscover() const tokenRes = await fetch(d.token_endpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: OIDC.redirectUri, client_id: OIDC.clientId, client_secret: OIDC.clientSecret, code_verifier: pending.verifier, }).toString(), }) if (!tokenRes.ok) throw new Error('token exchange failed') const tokens = await tokenRes.json() const payload = await verifyIdToken(tokens.id_token, d) const user = findOrCreateUserOidc( String(payload.sub), OIDC.issuer, String(payload.preferred_username || payload.email || ''), String(payload.name || payload.preferred_username || ''), ) const sess = createSession(user.id) setSessionCookie(res, sess.token, sess.expiresAt, req) res.writeHead(302, { Location: '/?auth=ok' }) return res.end() } catch { res.writeHead(302, { Location: '/?auth=error' }) return res.end() } } // --- authenticated endpoints below --- const sess = getSession(sessionTokenFromReq(req)) if (!sess) return json(res, 401, { error: 'Nicht angemeldet.' }) const user = getUserById(sess.user_id) if (!user) return json(res, 401, { error: 'Nicht angemeldet.' }) // --- buy meta upgrade --- if (p === '/api/upgrades/buy' && req.method === 'POST') { const body = await readJsonBody(req).catch(() => null) const upgradeId = String(body?.upgradeId || '') const def = META_UPGRADES[upgradeId] if (!def) return json(res, 400, { error: 'Unbekanntes Upgrade.' }) const current = user.upgrades[upgradeId] || 0 if (current >= def.maxLevel) return json(res, 400, { error: 'Bereits auf Maximalstufe.' }) const cost = def.costs[current] const result = buyUpgrade(user.id, upgradeId, cost, def.maxLevel) if (!result.ok) return json(res, 400, { error: result.error }) return json(res, 200, { user: publicUser(result.user) }) } // --- merge local guest progress into the account (one-time) --- if (p === '/api/auth/merge-guest' && req.method === 'POST') { const body = await readJsonBody(req).catch(() => null) const maxLevels = {} for (const [id, def] of Object.entries(META_UPGRADES)) maxLevels[id] = def.maxLevel const updated = mergeGuest(user.id, body?.crystals, body?.upgrades, maxLevels) return json(res, 200, { user: publicUser(updated) }) } // --- record finished game & grant crystals (server-authoritative) --- if (p === '/api/game/finish' && req.method === 'POST') { const body = await readJsonBody(req).catch(() => null) const win = Boolean(body?.win) const wave = Math.max(0, Math.min(9999, Number(body?.wave) || 0)) const score = Math.max(0, Math.min(10_000_000, Number(body?.score) || 0)) const kills = Math.max(0, Math.min(1_000_000, Number(body?.kills) || 0)) const crystals = calcCrystalsEarned(wave, score, win) const updated = recordGameResult(user.id, { win, score, wave, kills, crystalsEarned: crystals }) return json(res, 200, { user: publicUser(updated), crystalsEarned: crystals }) } return json(res, 404, { error: 'Nicht gefunden.' }) } // ------------------------------------------------------------------ static files const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.webmanifest': 'application/manifest+json; charset=utf-8', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.woff': 'font/woff', '.woff2': 'font/woff2', '.txt': 'text/plain; charset=utf-8', '.map': 'application/json', } const DIST_ROOT = path.normalize(DIST_DIR) async function serveStatic(req, res) { const url = new URL(req.url, 'http://localhost') if (url.pathname === '/health') { res.writeHead(200, { 'Content-Type': 'text/plain' }) res.end('ok') return } if (req.method !== 'GET' && req.method !== 'HEAD') { res.writeHead(405, { 'Content-Type': 'text/plain' }) res.end('Method Not Allowed') return } let rel try { rel = decodeURIComponent(url.pathname) } catch { res.writeHead(400) res.end('Bad Request') return } if (rel.includes('\0')) { res.writeHead(400) res.end('Bad Request') return } let filePath = path.normalize(path.join(DIST_ROOT, rel)) if (filePath !== DIST_ROOT && !filePath.startsWith(DIST_ROOT + path.sep)) { res.writeHead(403) res.end('Forbidden') return } let data = await fs.readFile(filePath).catch(() => null) if (!data) { // SPA fallback: unknown paths without file extension get index.html if (!path.extname(rel)) { filePath = path.join(DIST_ROOT, 'index.html') data = await fs.readFile(filePath).catch(() => null) } if (!data) { res.writeHead(404, { 'Content-Type': 'text/plain' }) res.end('Not Found') return } } const ext = path.extname(filePath).toLowerCase() const type = MIME[ext] || 'application/octet-stream' const isHashedAsset = url.pathname.startsWith('/assets/') res.writeHead(200, { 'Content-Type': type, 'Cache-Control': isHashedAsset ? 'public, max-age=31536000, immutable' : 'no-cache', 'X-Content-Type-Options': 'nosniff', 'Content-Security-Policy': CSP, 'X-Frame-Options': 'DENY', 'Referrer-Policy': 'no-referrer', }) res.end(req.method === 'HEAD' ? undefined : data) } const httpServer = http.createServer((req, res) => { const url = new URL(req.url, 'http://localhost') if (url.pathname.startsWith('/api/')) { handleApi(req, res, url).catch(() => { if (!res.headersSent) res.writeHead(500) res.end('Internal Server Error') }) return } serveStatic(req, res).catch(() => { if (!res.headersSent) res.writeHead(500) res.end('Internal Server Error') }) }) // ------------------------------------------------------------------ game rooms const wss = new WebSocketServer({ noServer: true, maxPayload: MAX_PAYLOAD_BYTES, }) // concurrent websocket connection tracking (per IP + global) const wsPerIp = new Map() function rejectUpgrade(socket, status, reason) { try { socket.write(`HTTP/1.1 ${status} ${reason}\r\nConnection: close\r\n\r\n`) } catch { /* ignore */ } socket.destroy() } httpServer.on('upgrade', (req, socket, head) => { if (!originAllowed(req)) { console.warn(`[ws] Origin abgelehnt: ${req.headers.origin} von ${clientIp(req)}`) return rejectUpgrade(socket, 403, 'Forbidden') } const ip = clientIp(req) const perIp = wsPerIp.get(ip) || 0 if (wss.clients.size >= MAX_WS_TOTAL || perIp >= MAX_WS_PER_IP) { console.warn(`[ws] Verbindungslimit erreicht: ${ip} (${perIp}/${MAX_WS_PER_IP}, total ${wss.clients.size}/${MAX_WS_TOTAL})`) return rejectUpgrade(socket, 503, 'Service Unavailable') } wss.handleUpgrade(req, socket, head, (ws) => { ws._ip = ip wsPerIp.set(ip, perIp + 1) ws.on('close', () => { const n = (wsPerIp.get(ip) || 1) - 1 if (n <= 0) wsPerIp.delete(ip) else wsPerIp.set(ip, n) }) wss.emit('connection', ws, req) }) }) /** code -> { code, mode, created, seq, players: [{ ws, id, name }] } */ const rooms = new Map() const CODE_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' function makeCode() { let code let attempts = 0 do { code = Array.from({ length: 4 }, () => CODE_CHARS[Math.floor(Math.random() * CODE_CHARS.length)]).join('') attempts++ if (attempts > 1000) break } while (rooms.has(code)) return code } const VALID_MAPS = ['meadow', 'desert', 'frostland', 'volcano'] function sanitizeName(raw) { if (typeof raw !== 'string') return 'Spieler' const clean = raw.trim().replace(/[^\p{L}\p{N}_\- ]/gu, '').slice(0, 12) return clean || 'Spieler' } function sanitizeMapId(raw) { return VALID_MAPS.includes(raw) ? raw : 'meadow' } function send(ws, obj) { if (ws && ws.readyState === 1) { try { ws.send(JSON.stringify(obj)) } catch { /* ignore socket send failure */ } } } function roomOf(ws) { const code = ws._room return code ? rooms.get(code) : undefined } function broadcast(room, obj, exceptWs) { for (const p of room.players) { if (p.ws !== exceptWs) send(p.ws, obj) } } function playerInfo(room) { return room.players.map((p) => ({ id: p.id, name: p.name })) } function leaveRoom(ws) { const room = roomOf(ws) if (!room) return room.players = room.players.filter((p) => p.ws !== ws) ws._room = undefined if (room.players.length === 0) { rooms.delete(room.code) } else { broadcast(room, { t: 'peer-left' }) } } function validateAction(a) { if (!a || typeof a !== 'object' || Array.isArray(a)) return false switch (a.type) { case 'build': return ( ['arrow', 'cannon', 'frost', 'tesla', 'laser'].includes(a.kind) && Number.isInteger(a.tx) && a.tx >= 0 && a.tx < 20 && Number.isInteger(a.ty) && a.ty >= 0 && a.ty < 11 ) case 'upgrade': case 'sell': return Number.isInteger(a.towerId) && a.towerId > 0 case 'targeting': return ( Number.isInteger(a.towerId) && a.towerId > 0 && ['first', 'last', 'strong', 'close'].includes(a.mode) ) case 'obstacle': return Number.isInteger(a.tx) && a.tx >= 0 && a.tx < 20 && Number.isInteger(a.ty) && a.ty >= 0 && a.ty < 11 case 'wave': case 'rush': return true case 'speed': return [1, 2, 3].includes(a.s) default: return false } } // heartbeat + room timeout const pingInterval = setInterval(() => { const now = Date.now() for (const [code, room] of rooms.entries()) { if (now - room.created > ROOM_TIMEOUT_MS) { for (const p of room.players) { send(p.ws, { t: 'error', msg: 'Raum-Zeitüberschreitung.' }) p.ws._room = undefined } rooms.delete(code) } } wss.clients.forEach((ws) => { if (ws.isAlive === false) { leaveRoom(ws) ws.terminate() return } ws.isAlive = false try { ws.ping() } catch { leaveRoom(ws) } }) }, 15000) wss.on('close', () => { clearInterval(pingInterval) }) wss.on('connection', (ws) => { ws.isAlive = true ws._actBucket = { count: 0, resetAt: Date.now() + 1000 } ws.on('pong', () => { ws.isAlive = true }) ws.on('message', (data, isBinary) => { ws.isAlive = true if (isBinary) return // only JSON text frames are allowed let m try { m = JSON.parse(data.toString()) } catch { return } if (!m || typeof m !== 'object' || typeof m.t !== 'string') return // rate limiting const now = Date.now() if (now > ws._actBucket.resetAt) { ws._actBucket.count = 0 ws._actBucket.resetAt = now + 1000 } ws._actBucket.count++ if (ws._actBucket.count > MAX_ACTIONS_PER_SEC) { send(ws, { t: 'error', msg: 'Zu viele Anfragen gesendet.' }) return } switch (m.t) { case 'create': { leaveRoom(ws) if (m.mode !== 'coop' && m.mode !== 'duel') return if (rooms.size >= MAX_ROOMS) { return send(ws, { t: 'error', msg: 'Zu viele aktive Räume. Bitte später erneut versuchen.' }) } const code = makeCode() const mapId = sanitizeMapId(m.mapId) const room = { code, mode: m.mode, mapId, created: Date.now(), seq: 0, players: [{ ws, id: 0, name: sanitizeName(m.name) }] } rooms.set(code, room) ws._room = code send(ws, { t: 'room', code, mode: room.mode, mapId: room.mapId, players: playerInfo(room), you: 0 }) break } case 'set-map': { const room = roomOf(ws) if (!room || room.players.length === 0) return if (room.players[0].ws !== ws) return // only host may change map room.mapId = sanitizeMapId(m.mapId) for (const p of room.players) { send(p.ws, { t: 'room', code: room.code, mode: room.mode, mapId: room.mapId, players: playerInfo(room), you: p.id }) } break } case 'join': { leaveRoom(ws) const code = String(m.code || '').toUpperCase().trim() const room = rooms.get(code) if (!room) return send(ws, { t: 'error', msg: 'Raum nicht gefunden.' }) if (room.players.length >= 2) return send(ws, { t: 'error', msg: 'Raum ist bereits voll.' }) room.players.push({ ws, id: 1, name: sanitizeName(m.name) }) ws._room = code for (const p of room.players) { send(p.ws, { t: 'room', code: room.code, mode: room.mode, mapId: room.mapId, players: playerInfo(room), you: p.id }) } break } case 'start': { const room = roomOf(ws) if (!room || room.players.length !== 2) return if (room.players[0].ws !== ws) return // only the host may start const seed = (Math.random() * 1e9) | 0 for (const p of room.players) { send(p.ws, { t: 'start', seed, mode: room.mode, mapId: room.mapId, players: playerInfo(room), you: p.id }) } break } case 'act': { const room = roomOf(ws) if (!room || room.players.length < 2) return const from = room.players.findIndex((p) => p.ws === ws) if (from === -1) return if (!validateAction(m.a)) return if (!Number.isInteger(m.tick) || m.tick < 0) return room.seq = (room.seq || 0) + 1 for (const p of room.players) { send(p.ws, { t: 'act', seq: room.seq, from, tick: m.tick, a: m.a }) } break } case 'prog': { const room = roomOf(ws) if (!room) return const from = room.players.findIndex((p) => p.ws === ws) if (from === -1) return if (typeof m.tick !== 'number' || !Number.isFinite(m.tick)) return broadcast(room, { t: 'prog', from, tick: m.tick }) break } case 'leave': { leaveRoom(ws) break } } }) ws.on('close', () => leaveRoom(ws)) ws.on('error', () => leaveRoom(ws)) }) httpServer.listen(PORT, () => { console.log(`TRXTD-Server läuft auf http://localhost:${PORT} (Static: ${DIST_DIR})`) })