2025-09-02 06:26:32 +02:00

246 lines
6.7 KiB
JavaScript

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