321 lines
11 KiB
JavaScript
321 lines
11 KiB
JavaScript
// 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}`);
|
|
});
|