2025-09-13 15:40:46 +02:00

731 lines
25 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.

// server.js
import express from "express";
import http from "http";
import { WebSocketServer } from "ws";
import cors from "cors";
import bodyParser from "body-parser";
import "dotenv/config";
/**
* ENV / Konfig
*/
const PORT = process.env.PORT || 8082;
const WS_PATH = process.env.WS_PATH || "/telemetry";
const GSI_PATH = process.env.GSI_PATH || "/gsi";
const GSI_TOKEN = process.env.GSI_TOKEN || "SUPERSECRET";
const app = express();
app.use(cors());
app.use(bodyParser.json({ limit: "512kb" }));
app.use(express.static("public")); // optional: dein Radar-Frontend
const server = http.createServer(app);
/* ────────────────────────────────────────────────────────────
* WS Hub für Frontends
* ──────────────────────────────────────────────────────────── */
const wss = new WebSocketServer({ server, path: WS_PATH });
const clients = new Set();
/** Aktueller Map-Key und Round-/Bomben-State */
let currentMap = null;
let lastPhase = null;
/** lastBomb: { x,y,z, status: 'planted'|'dropped'|'carried'|'unknown' } */
let lastBomb = null;
/** Hilfsfunktion für IDs */
let _nextId = 1;
function clientId(ws) { return ws._id ?? "anon"; }
wss.on("connection", (ws, req) => {
ws._id = String(_nextId++);
ws.isAlive = true;
clients.add(ws);
const ip = req?.socket?.remoteAddress || "unknown";
console.log(`[WS] + connect id=${ws._id} ip=${ip} total=${clients.size}`);
ws.on("pong", () => { ws.isAlive = true; });
ws.on("close", (code) => {
clients.delete(ws);
console.log(`[WS] - disconnect id=${ws._id} code=${code} total=${clients.size}`);
});
ws.on("error", (err) => {
console.warn(`[WS] ! error id=${ws._id}:`, err?.message || err);
});
// Begrüßung mit aktuellem Kontext (Map + ggf. Bombe)
try {
ws.send(JSON.stringify({ type: "hello", id: ws._id, map: currentMap, bomb: lastBomb }));
} catch {}
});
/** Heartbeat, um tote Verbindungen zu killen */
setInterval(() => {
for (const ws of clients) {
if (ws.isAlive === false) {
console.log(`[WS] x terminate stale id=${clientId(ws)}`);
try { ws.terminate(); } catch {}
clients.delete(ws);
continue;
}
ws.isAlive = false;
try { ws.ping(); } catch {}
}
}, 30000);
/** Broadcast an alle Clients */
function broadcast(obj) {
const msg = JSON.stringify(obj);
for (const ws of clients) {
if (ws.readyState === 1) { // OPEN
try { ws.send(msg); } catch {}
}
}
}
/* ────────────────────────────────────────────────────────────
* Hilfsfunktionen
* ──────────────────────────────────────────────────────────── */
const lastBySteam = new Map();
function parseVec3(str) {
if (!str || typeof str !== "string") return { x: 0, y: 0, z: 0 };
const sp = str.split(/[\s,]+/).filter(Boolean);
const x = Number(sp[0]) || 0;
const y = Number(sp[1]) || 0;
const z = Number(sp[2]) || 0;
return { x, y, z };
}
function pickVec3Any(pos) {
if (!pos) return { x: 0, y: 0, z: 0 };
if (Array.isArray(pos)) return { x: +pos[0]||0, y: +pos[1]||0, z: +pos[2]||0 };
if (typeof pos === 'string') return parseVec3(pos);
return { x: +pos.x||0, y: +pos.y||0, z: +pos.z||0 };
}
const NADE_DEFAULTS = {
smoke: { radius: 150, lifetimeMs: 18_000 },
molotov: { radius: 120, lifetimeMs: 7_000 },
incendiary: { radius: 120, lifetimeMs: 7_000 },
he: { radius: 40, lifetimeMs: 300 },
flash: { radius: 36, lifetimeMs: 300 },
decoy: { radius: 80, lifetimeMs: 15_000 },
};
function normalizeGrenadesFromGSI(raw, now) {
if (!raw || typeof raw !== 'object') return [];
const KNOWN_BUCKETS = new Set([
'smokes','smoke','smokegrenade','smokegrenades',
'smoke_effect','smokeeffects','smokecloud','smokeclouds',
'inferno','molotov','incendiary','firebomb',
'he','hegrenade','hegrenades','explosive',
'flash','flashbang','flashbangs',
'decoy','decoys',
'smokegrenade_projectile','hegrenade_projectile','flashbang_projectile','decoy_projectile',
'projectiles','grenadeprojectiles','nades','flying'
]);
const mapKindFromString = (s) => {
const k = String(s || '').toLowerCase();
if (k.includes('smoke')) return 'smoke';
if (k.includes('inferno') || k.includes('molotov') || k.includes('incendiary')) {
return k.includes('incendiary') ? 'incendiary' : 'molotov';
}
if (k.includes('flash')) return 'flash';
if (k.includes('decoy')) return 'decoy';
if (k.includes('he') || k.includes('frag')) return 'he';
if (k.includes('firebomb')) return 'molotov';
return 'unknown';
};
const parsePos = (src) => {
if (!src) return null;
if (Array.isArray(src)) {
const x = +src[0], y = +src[1], z = +(src[2] ?? 0);
return (Number.isFinite(x) && Number.isFinite(y)) ? { x, y, z } : null;
}
if (typeof src === 'string') {
const sp = src.split(/[\s,]+/).map(Number);
const x = sp[0], y = sp[1], z = +(sp[2] ?? 0);
return (Number.isFinite(x) && Number.isFinite(y)) ? { x, y, z } : null;
}
const x = +src.x, y = +src.y, z = +(src.z ?? 0);
return (Number.isFinite(x) && Number.isFinite(y)) ? { x, y, z } : null;
};
const parseVec = (src) => {
if (!src) return null;
if (typeof src === 'string') {
const sp = src.split(/[\s,]+/).map(Number);
const x = sp[0], y = sp[1], z = +(sp[2] ?? 0);
if ([x,y].every(Number.isFinite)) return { x, y, z };
return null;
}
if (typeof src === 'object') {
const x = +src.x, y = +src.y, z = +(src.z ?? 0);
if ([x,y].every(Number.isFinite)) return { x, y, z };
}
return null;
};
const circleFromFlames = (flamesObj) => {
if (!flamesObj || typeof flamesObj !== 'object') return null;
const pts = [];
for (const v of Object.values(flamesObj)) {
const p = parsePos(v);
if (p) pts.push(p);
}
if (!pts.length) return null;
const cx = pts.reduce((a,p)=>a+p.x,0) / pts.length;
const cy = pts.reduce((a,p)=>a+p.y,0) / pts.length;
let r = 0;
for (const p of pts) {
const d = Math.hypot(p.x - cx, p.y - cy);
if (d > r) r = d;
}
r = Math.max(r + 24, 60);
const cz = pts.reduce((a,p)=>a+p.z,0) / pts.length;
return { x: cx, y: cy, z: cz, r };
};
// 🔸 EffectTime Reader (Sekunden) robuster
const readEffectTimeSec = (g) => {
const v =
g?.effecttime ??
g?.effectTime ??
g?.EffectTime ??
g?.effect_time ??
g?.efftime ??
g?.lifetime ??
g?.time_since_detonation ??
g?.time_since_start;
const n = Number(v);
return Number.isFinite(n) ? n : 0;
};
const out = [];
// --- Effekt-Listen (Buckets wie 'smokes', 'inferno', ...) ---
const pushEffectList = (bucketKey, list) => {
if (!list) return;
const arr = Array.isArray(list) ? list : Object.values(list);
let seq = 0;
for (const g of arr) {
// c) Sonstiges: frag/flash/decoy/etc. und SMOKE (numeric keys)
const pos = parsePos(g.position ?? g.pos ?? g.location);
if (!pos) continue;
const kind = mapKindFromString(type);
if (kind === 'unknown') continue;
let team = null;
const ownerSid = String(g.owner ?? g.thrower ?? g.player ?? '');
if (ownerSid && lastBySteam && lastBySteam.has(Number(ownerSid))) {
const snap = lastBySteam.get(Number(ownerSid));
const t = String(snap?.team || '').toUpperCase();
team = (t === 'T' || t === 'CT') ? t : null;
}
// 🔸 NEU: smoke hier wie bei smokegrenade_projectile behandeln
if (kind === 'smoke') {
const effectTimeSec = readEffectTimeSec(g);
const state = String(g?.state ?? '').toLowerCase();
const isEffectFlag = state === 'effect' || state === 'stopped' || g?.stopped === true || g?.detonated === true;
const idBase = `${k}:${Math.round(pos.x)}:${Math.round(pos.y)}`;
if (effectTimeSec > 0 || isEffectFlag) {
const lifeMs = NADE_DEFAULTS.smoke.lifetimeMs;
const bornAt = effectTimeSec > 0 ? (now - Math.max(0, effectTimeSec * 1000)) : now;
const expiresAt = bornAt + lifeMs;
const lifeElapsedMs = Math.max(0, now - bornAt);
const lifeLeftMs = Math.max(0, expiresAt - now);
out.push({
id: `smoke#${idBase}`,
kind: 'smoke',
x: pos.x, y: pos.y, z: pos.z,
radius: NADE_DEFAULTS.smoke.radius,
expiresAt,
team,
phase: 'effect',
effectTimeSec: Math.max(effectTimeSec, lifeElapsedMs / 1000),
lifeElapsedMs,
lifeLeftMs
});
} else {
const vel = parseVec(g?.velocity ?? g?.vel ?? g?.dir ?? g?.forward);
const payload = {
id: `proj:smoke#${idBase}`,
kind: 'smoke',
x: pos.x, y: pos.y, z: pos.z,
team,
phase: 'projectile',
effectTimeSec: 0
};
if (vel) payload.vel = { x: vel.x, y: vel.y, z: vel.z };
out.push(payload);
}
continue; // smoke erledigt nicht in den generischen Zweig fallen
}
// (bestehender generischer Zweig für andere Arten)
const id = `proj:${kind}#${k}:${Math.round(pos.x)}:${Math.round(pos.y)}`;
const payload = { id, kind, x: pos.x, y: pos.y, z: pos.z, team, phase: 'projectile' };
const vel = parseVec(g.velocity);
if (vel) payload.vel = { x: vel.x, y: vel.y, z: vel.z };
out.push(payload);
}
};
// --- Spezial: 'smokegrenade_projectile' mit EffectTime-Umschaltung ---
const pushSmokeFromProjectiles = (list) => {
if (!list) return;
const arr = Array.isArray(list) ? list : Object.values(list);
let seq = 0;
for (const g of arr) {
const pos = parsePos(g?.position ?? g?.pos ?? g?.location ?? g?.origin);
if (!pos) continue;
const effectTimeSec = readEffectTimeSec(g);
// zusätzliche Flags aus manchen GSIs
const state = String(g?.state ?? '').toLowerCase();
const isEffectFlag =
state === 'effect' || state === 'stopped' || g?.stopped === true || g?.detonated === true;
const teamRaw = String(g?.owner?.team ?? g?.team ?? g?.owner_team ?? '').toUpperCase();
const team = (teamRaw === 'T' || teamRaw === 'CT') ? teamRaw : null;
const givenId = g?.id ?? g?.entityid ?? g?.entindex;
const idBase = givenId ? String(givenId) : `${Math.round(pos.x)}:${Math.round(pos.y)}:${seq++}`;
const id = `smoke#${idBase}`; // ← EINHEITLICHE ID für projectile **und** effect
if (effectTimeSec > 0 || isEffectFlag) {
const lifeMs = NADE_DEFAULTS.smoke.lifetimeMs;
const bornAt = effectTimeSec > 0 ? (now - Math.max(0, effectTimeSec * 1000)) : now;
const expiresAt = bornAt + lifeMs;
const lifeElapsedMs = Math.max(0, now - bornAt);
const lifeLeftMs = Math.max(0, expiresAt - now);
out.push({
id,
kind: 'smoke',
x: pos.x, y: pos.y, z: pos.z,
radius: NADE_DEFAULTS.smoke.radius,
expiresAt,
team,
phase: 'effect', // ← ab hier Effekt
effectTimeSec: Math.max(effectTimeSec, lifeElapsedMs / 1000),
lifeElapsedMs,
lifeLeftMs
});
} else {
const vel = parseVec(g?.velocity ?? g?.vel ?? g?.dir ?? g?.forward);
const payload = {
id, // ← gleiche ID wie beim Effekt
kind: 'smoke',
x: pos.x, y: pos.y, z: pos.z,
team,
phase: 'projectile',
effectTimeSec: 0
};
if (vel) payload.vel = { x: vel.x, y: vel.y, z: vel.z };
out.push(payload);
}
}
};
// 1) Buckets auswerten
for (const [k, v] of Object.entries(raw)) {
const key = String(k).toLowerCase();
if (key === 'smokegrenade_projectile') {
pushSmokeFromProjectiles(v);
continue;
}
if (KNOWN_BUCKETS.has(key)) {
pushEffectList(key, v);
}
}
// 2) Numerische/sonstige Keys …
for (const [k, v] of Object.entries(raw)) {
const key = String(k).toLowerCase();
if (KNOWN_BUCKETS.has(key)) continue;
const g = v;
if (!g || typeof g !== 'object') continue;
const type = String(g.type || '').toLowerCase();
// a) Projektile: firebomb → molotov/incendiary
if (type === 'firebomb') {
const pos = parsePos(g.position);
if (!pos) continue;
const vel = parseVec(g.velocity);
let team = null;
const ownerSid = String(g.owner ?? '');
if (ownerSid && lastBySteam && lastBySteam.has(Number(ownerSid))) {
const snap = lastBySteam.get(Number(ownerSid));
const t = String(snap?.team || '').toUpperCase();
team = (t === 'T' || t === 'CT') ? t : null;
}
const kind = team === 'CT' ? 'incendiary' : 'molotov';
const id = `proj:${kind}#${k}:${Math.round(pos.x)}:${Math.round(pos.y)}`;
const payload = { id, kind, x: pos.x, y: pos.y, z: pos.z, team, phase: 'projectile' };
if (vel) payload.vel = { x: vel.x, y: vel.y, z: vel.z };
out.push(payload);
continue;
}
// b) Effekt: inferno → Kreis aus flames (Herdpunkte)
if (type === 'inferno') {
const circ = circleFromFlames(g.flames);
if (!circ) continue;
let team = null;
const ownerSid = String(g.owner ?? '');
if (ownerSid && lastBySteam && lastBySteam.has(Number(ownerSid))) {
const snap = lastBySteam.get(Number(ownerSid));
const t = String(snap?.team || '').toUpperCase();
team = (t === 'T' || t === 'CT') ? t : null;
}
const bornAt = Number.isFinite(+g?.lifetime) ? (now - Math.max(0, +g.lifetime * 1000)) : now;
const lifeMs = NADE_DEFAULTS.molotov.lifetimeMs;
const expiresAt = bornAt + lifeMs;
const kind = team === 'CT' ? 'incendiary' : 'molotov';
const id = `inferno:${k}`;
out.push({
id,
kind,
x: circ.x, y: circ.y, z: circ.z,
radius: circ.r,
expiresAt,
team,
phase: 'effect'
});
continue;
}
// c) "Sonstiges": hier kommt deine smoke mit numerischer ID an
const pos = parsePos(g.position ?? g.pos ?? g.location);
if (!pos) continue;
const kind = mapKindFromString(type);
if (kind === 'unknown') continue;
let team = null;
const ownerSid = String(g.owner ?? g.thrower ?? g.player ?? '');
if (ownerSid && lastBySteam && lastBySteam.has(Number(ownerSid))) {
const snap = lastBySteam.get(Number(ownerSid));
const t = String(snap?.team || '').toUpperCase();
team = (t === 'T' || t === 'CT') ? t : null;
}
// 🔸 NEU: smoke wie im projectile-Bucket behandeln
if (kind === 'smoke') {
const effectTimeSec = readEffectTimeSec(g);
const state = String(g?.state ?? '').toLowerCase();
const isEffectFlag =
state === 'effect' || state === 'stopped' || g?.stopped === true || g?.detonated === true;
const idBase = `${k}:${Math.round(pos.x)}:${Math.round(pos.y)}`;
const id = `smoke#${idBase}`; // ← gleiche ID für beide Phasen
if (effectTimeSec > 0 || isEffectFlag) {
const lifeMs = NADE_DEFAULTS.smoke.lifetimeMs;
const bornAt = effectTimeSec > 0 ? (now - Math.max(0, effectTimeSec * 1000)) : now;
const expiresAt = bornAt + lifeMs;
const lifeElapsedMs = Math.max(0, now - bornAt);
const lifeLeftMs = Math.max(0, expiresAt - now);
out.push({
id,
kind: 'smoke',
x: pos.x, y: pos.y, z: pos.z,
radius: NADE_DEFAULTS.smoke.radius,
expiresAt,
team,
phase: 'effect', // ← Effekt
effectTimeSec: Math.max(effectTimeSec, lifeElapsedMs / 1000),
lifeElapsedMs,
lifeLeftMs
});
} else {
const vel = parseVec(g?.velocity ?? g?.vel ?? g?.dir ?? g?.forward);
const payload = {
id, // ← gleiche ID
kind: 'smoke',
x: pos.x, y: pos.y, z: pos.z,
team,
phase: 'projectile', // ← Projektil bis effecttime > 0
effectTimeSec: 0
};
if (vel) payload.vel = { x: vel.x, y: vel.y, z: vel.z };
out.push(payload);
}
continue; // smoke erledigt nicht in generischen Zweig fallen
}
// generischer Zweig für he/flash/decoy etc.
const id = `proj:${kind}#${k}:${Math.round(pos.x)}:${Math.round(pos.y)}`;
const payload = { id, kind, x: pos.x, y: pos.y, z: pos.z, team, phase: 'projectile' };
const vel = parseVec(g.velocity);
if (vel) payload.vel = { x: vel.x, y: vel.y, z: vel.z };
out.push(payload);
}
// (Optional) einfache Dedupe per ID
const byId = new Map();
for (const n of out) byId.set(n.id, n);
return Array.from(byId.values());
}
function forwardToYawPitch(fwd) {
const yaw = Math.atan2(fwd.y || 0, fwd.x || 0) * 180 / Math.PI;
const z = Math.max(-1, Math.min(1, fwd.z || 0));
const pitch = -Math.asin(z) * 180 / Math.PI;
return { yaw, pitch };
}
function normalizeWeapons(weaponsObj) {
const out = [];
let active = null;
if (weaponsObj && typeof weaponsObj === "object") {
for (const key of Object.keys(weaponsObj)) {
const w = weaponsObj[key];
if (!w) continue;
const item = {
slot: key,
name: w.name || w.weapon_name || null,
type: w.type || w.weapon_type || null,
ammo_clip: w.ammo_clip ?? null,
ammo_reserve: w.ammo_reserve ?? null,
state: w.state || null
};
out.push(item);
if (w.state === "active") active = { name: item.name, type: item.type };
}
}
return { all: out, active };
}
/** Bombe aus GSI normalisieren */
function normalizeBombFromGSI(body) {
const b = body?.bomb || body?.c4;
if (!b) return null;
const posStr = b.position || b.pos || b.location || null;
const pos = typeof posStr === "string"
? parseVec3(posStr)
: { x: Number(b?.x) || 0, y: Number(b?.y) || 0, z: Number(b?.z) || 0 };
let raw = String(b?.state || b?.status || "").toLowerCase();
let status = "unknown";
if (raw.includes("planted")) status = "planted";
else if (raw.includes("defusing")) status = "defusing";
else if (raw.includes("defused")) status = "defused";
else if (raw.includes("dropped")) status = "dropped";
else if (raw.includes("carried")) status = "carried";
if (b?.planted === true) status = "planted";
if (b?.defusing === true) status = "defusing";
if (b?.defused === true) status = "defused";
if (b?.dropped === true) status = "dropped";
if (b?.carried === true) status = "carried";
if (!Number.isFinite(pos.x) || !Number.isFinite(pos.y)) return null;
return { x: pos.x, y: pos.y, z: pos.z, status };
}
function sameBomb(a, b) {
if (!a && !b) return true;
if (!a || !b) return false;
return a.status === b.status && a.x === b.x && a.y === b.y;
}
/* ────────────────────────────────────────────────────────────
* HTTP Endpoint für CS2 GSI POSTs
* Erwartet typische CS:GO/CS2-GSI-Felder
* ──────────────────────────────────────────────────────────── */
app.post(GSI_PATH, (req, res) => {
try {
const body = req.body || {};
// 1) Auth
const tok = body?.auth?.token || "";
if (tok !== GSI_TOKEN) {
return res.status(401).json({ ok: false, err: "bad token" });
}
// 2) Map / Phase aktualisieren
const mapName = (body?.map?.name || "").toLowerCase() || null;
const phaseInfo = {
phase: body?.map?.phase || body?.phase_countdowns?.phase || null,
round: body?.map?.round ?? null,
phase_ends_in: body?.phase_countdowns?.phase_ends_in ?? null,
team_ct: body?.map?.team_ct || null,
team_t: body?.map?.team_t || null
};
if (mapName && mapName !== currentMap) {
currentMap = mapName;
broadcast({ type: "map", name: currentMap });
}
// Round-Events anhand Phase-Transition
const curPhase = (phaseInfo.phase || "").toLowerCase();
if (curPhase !== lastPhase) {
if (curPhase === "live" && lastPhase && lastPhase !== "live") {
broadcast({ type: "round_start", round: phaseInfo.round ?? null });
}
if (curPhase === "over" && lastPhase !== "over") {
broadcast({ type: "round_end", round: phaseInfo.round ?? null });
lastBomb = null; // round-reset
}
lastPhase = curPhase;
}
// 3) Spieler normalisieren (aus allplayers)
const t = Date.now();
const allplayers = body?.allplayers || {};
const presentIds = new Set();
const players = Object.entries(allplayers).map(([sid, p]) => {
const steamId = Number(sid) || 0;
presentIds.add(steamId);
const pos = parseVec3(p.position);
const fwd = parseVec3(p.forward);
const { yaw, pitch } = forwardToYawPitch(fwd);
const eye = { x: pos.x, y: pos.y, z: pos.z + 64 };
const { all: weapons, active } = normalizeWeapons(p.weapons);
const snap = {
type: "player",
t,
steamId,
name: p.name || "",
team: p.team || "",
observer_slot: p.observer_slot ?? null,
pos,
eye,
yaw, pitch,
fwd,
hp: p.state?.health ?? null,
armor: p.state?.armor ?? null,
flashed: p.state?.flashed ?? null,
burning: p.state?.burning ?? null,
money: p.state?.money ?? null,
defusekit: p.state?.defusekit ?? null,
helmet: p.state?.helmet ?? null,
weapons,
activeWeapon: active
};
lastBySteam.set(steamId, snap);
return snap;
});
// Aus Cache entfernen, wer im Snapshot fehlt
for (const sid of Array.from(lastBySteam.keys())) {
if (!presentIds.has(sid)) lastBySteam.delete(sid);
}
// 4) Grenades normalisieren (inkl. Smoke-EffectTime-Logik)
const grenades = normalizeGrenadesFromGSI(body?.grenades || {}, Date.now());
// 5) Bombe normalisieren + Events bei Statuswechsel
const bomb = normalizeBombFromGSI(body);
if (!sameBomb(bomb, lastBomb)) {
if (bomb) {
const prev = lastBomb;
if (bomb.status === "planted" && (!prev || prev.status !== "planted")) {
broadcast({ type: "bomb_planted", bomb });
} else if (bomb.status === "defusing" && (!prev || prev.status !== "defusing")) {
broadcast({ type: "bomb_begindefuse", bomb });
} else if (prev && prev.status === "defusing" && bomb.status === "planted") {
broadcast({ type: "bomb_abortdefuse", bomb });
} else if (bomb.status === "defused" && (!prev || prev.status !== "defused")) {
broadcast({ type: "bomb_defused", bomb });
} else if (bomb.status === "dropped") {
broadcast({ type: "bomb_dropped", bomb });
} else if (bomb.status === "carried" && prev && prev.status === "dropped") {
broadcast({ type: "bomb_pickup", bomb });
} else {
broadcast({ type: "bomb", bomb });
}
} else if (lastBomb) {
broadcast({ type: "bomb_cleared" });
}
lastBomb = bomb || null;
}
// 6) Tick mit kompletter Momentaufnahme (inkl. bomb)
broadcast({
type: "tick",
t,
map: currentMap,
phase: phaseInfo,
players, // vollständiger Satz aller Spieler dieses Snapshots
grenades, // normalisiert (inkl. Smoke: effectTimeSec/lifeElapsedMs/lifeLeftMs)
bomb // für Client-Rendering der C4
});
// 7) Erfolg
res.json({ ok: true });
} catch (e) {
console.error("[GSI] error:", e);
res.status(500).json({ ok: false });
}
});
/* ────────────────────────────────────────────────────────────
* Health / Stats
* ──────────────────────────────────────────────────────────── */
app.get("/health", (_req, res) => res.json({ ok: true, clients: clients.size }));
app.get("/stats", (_req, res) => {
const list = [];
for (const ws of clients) {
list.push({
id: clientId(ws),
readyState: ws.readyState, // 1 = OPEN
isAlive: !!ws.isAlive
});
}
res.json({ ok: true, count: list.length, clients: list, map: currentMap, bomb: lastBomb, phase: lastPhase });
});
/* ────────────────────────────────────────────────────────────
* Start
* ──────────────────────────────────────────────────────────── */
server.listen(PORT, () => {
console.log(`[HTTP] listening on http://0.0.0.0:${PORT}`);
console.log(`[WS] ws://HOST:${PORT}${WS_PATH}`);
console.log(`[GSI] POST to http://HOST:${PORT}${GSI_PATH} with token=${GSI_TOKEN}`);
});