// 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 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"; // 'planted'|'dropped'|'carried'|'unknown' if (raw.includes("plant")) status = "planted"; else if (raw.includes("drop")) status = "dropped"; else if (raw.includes("carry")) status = "carried"; // bool-Fallbacks if (b?.planted === true) status = "planted"; 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) { // Start zählt, sobald es "live" wird (alternativ: "freezetime" → "live") if (curPhase === "live" && lastPhase && lastPhase !== "live") { broadcast({ type: "round_start", round: phaseInfo.round ?? null }); } // Ende, wenn "over" 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 }; // grobe Augenhöhe 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 roh (du kannst bei Bedarf normalisieren) const grenades = body?.grenades || {}; // 5) Bombe normalisieren + Events bei Statuswechsel const bomb = normalizeBombFromGSI(body); if (!sameBomb(bomb, lastBomb)) { if (bomb) { if (bomb.status === "planted" && (!lastBomb || lastBomb.status !== "planted")) { broadcast({ type: "bomb_planted", bomb }); } else if (bomb.status === "dropped") { broadcast({ type: "bomb_dropped", bomb }); } else if (bomb.status === "carried" && lastBomb && lastBomb.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, // roh 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}`); });