// 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}`); });