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")); // Radar-Frontend const server = http.createServer(app); // --- WS Hub für Frontends --- const wss = new WebSocketServer({ server, path: WS_PATH }); const clients = new Set(); let currentMap = 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, reason) => { 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 try { ws.send(JSON.stringify({ type: "hello", id: ws._id, map: currentMap })); } 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) ws.send(msg); } } /** * 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 }; } /** * HTTP Endpoint für CS2 GSI POSTs * * Erwartet: * - body.map, body.phase_countdowns * - body.allplayers: { [steamid]: { name, team, position, forward, state, weapons, ... } } * - body.player: { steamid, team, activity, ... } (POV) * - body.auth.token */ app.post(GSI_PATH, (req, res) => { try { const body = req.body || {}; // 1) Auth vor Verarbeitung prüfen 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 }); } // 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 }; // ~Kopf-/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; }); // 4) Aus Cache entfernen, wer nicht mehr im allplayers-Snapshot ist for (const sid of Array.from(lastBySteam.keys())) { if (!presentIds.has(sid)) lastBySteam.delete(sid); } // 5) (Optional) Grenades roh durchreichen (du kannst hier später normalisieren) const grenades = body?.grenades || {}; // 6) Broadcast eines kompakten "tick"-Pakets für Clients broadcast({ type: "tick", t, map: currentMap, phase: phaseInfo, players, // vollständiger Satz, alle Spieler dieses Snapshots grenades // roh }); // 7) Erfolg res.json({ ok: true }); } catch (e) { console.error("[GSI] error:", e); res.status(500).json({ ok: false }); } }); // health app.get("/health", (_req, res) => res.json({ ok: true, clients: clients.size })); // stats 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 }); }); 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}`); });