2025-09-09 23:32:22 +02:00

416 lines
14 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 },
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 [];
// Dr. Weissbrot Format: buckets (smokes, infernos, hegrenades, flashes, decoys, ...),
// evtl. auch *_projectile beides unterstützen.
const mapKind = (k) => {
const s = k.toLowerCase();
if (s.includes('smoke')) return 'smoke';
if (s.includes('inferno') || s.includes('molotov') || s.includes('incendiary')) return 'molotov';
if (s.includes('flash')) return 'flash';
if (s.includes('decoy')) return 'decoy';
if (s.includes('he')) return 'he';
return 'unknown';
};
const pushList = (acc, kind, list) => {
if (!list) return;
const arr = Array.isArray(list) ? list : Object.values(list);
let i = 0;
for (const g of arr) {
const pos = pickVec3Any(g?.position || g?.pos || g?.location || g);
const id =
String(g?.id ?? g?.entityid ?? g?.entindex ??
`${kind}#${Math.round(pos.x)}:${Math.round(pos.y)}:${i++}`);
const ownerTeam = (g?.owner?.team || g?.team || '').toString().toUpperCase();
const team = ownerTeam === 'T' || ownerTeam === 'CT' ? ownerTeam : null;
const def = NADE_DEFAULTS[kind] || { radius: 60, lifetimeMs: 2_000 };
const bornAt =
Number.isFinite(+g?.lifetime) ? (now - Math.max(0, +g.lifetime * 1000))
: Number.isFinite(+g?.spawn_time) ? +g.spawn_time
: now;
// Für aktive Effekte (z.B. smoke/inferno) gibt es teils direkte "expire"-Infos.
const radius = Number.isFinite(+g?.radius) ? +g.radius : def.radius;
const expiresAt = Number.isFinite(+g?.expiresAt)
? +g.expiresAt
: bornAt + def.lifetimeMs;
acc.push({
id,
kind,
x: pos.x, y: pos.y, z: pos.z,
radius,
expiresAt,
team,
});
}
};
const out = [];
for (const [k, v] of Object.entries(raw)) {
// z.B. "smokes", "smokegrenade_projectile", "inferno", "hegrenade_projectile" ...
const kind = mapKind(k);
pushList(out, kind, v);
}
return out;
}
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";
// "planting" bewusst NICHT als planted behandeln
// bool-Fallbacks
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) {
// 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 = normalizeGrenadesFromGSI(body?.grenades || {}, Date.now());
// 5) Bombe normalisieren + Events bei Statuswechsel
const bomb = normalizeBombFromGSI(body);
if (!sameBomb(bomb, lastBomb)) {
if (bomb) {
const prev = lastBomb;
// echte Plant-Transition
if (bomb.status === "planted" && (!prev || prev.status !== "planted")) {
broadcast({ type: "bomb_planted", bomb });
// Defuse begonnen
} else if (bomb.status === "defusing" && (!prev || prev.status !== "defusing")) {
broadcast({ type: "bomb_begindefuse", bomb });
// Defuse abgebrochen (zurück zu planted)
} else if (prev && prev.status === "defusing" && bomb.status === "planted") {
broadcast({ type: "bomb_abortdefuse", bomb });
// Erfolgreich entschärft
} else if (bomb.status === "defused" && (!prev || prev.status !== "defused")) {
broadcast({ type: "bomb_defused", bomb });
// Drop / Pickup
} 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, // 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}`);
});