diff --git a/server.js b/server.js index 5e66ee7..6f6b385 100644 --- a/server.js +++ b/server.js @@ -1,3 +1,4 @@ +// server.js import express from "express"; import http from "http"; import { WebSocketServer } from "ws"; @@ -16,17 +17,23 @@ 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 +app.use(express.static("public")); // optional: dein Radar-Frontend const server = http.createServer(app); -// --- WS Hub für Frontends --- +/* ──────────────────────────────────────────────────────────── + * 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 +/** Hilfsfunktion für IDs */ let _nextId = 1; function clientId(ws) { return ws._id ?? "anon"; } @@ -40,7 +47,7 @@ wss.on("connection", (ws, req) => { ws.on("pong", () => { ws.isAlive = true; }); - ws.on("close", (code, reason) => { + ws.on("close", (code) => { clients.delete(ws); console.log(`[WS] - disconnect id=${ws._id} code=${code} total=${clients.size}`); }); @@ -49,13 +56,13 @@ wss.on("connection", (ws, req) => { console.warn(`[WS] ! error id=${ws._id}:`, err?.message || err); }); - // Begrüßung + // Begrüßung mit aktuellem Kontext (Map + ggf. Bombe) try { - ws.send(JSON.stringify({ type: "hello", id: ws._id, map: currentMap })); + ws.send(JSON.stringify({ type: "hello", id: ws._id, map: currentMap, bomb: lastBomb })); } catch {} }); -// Heartbeat, um tote Verbindungen zu killen +/** Heartbeat, um tote Verbindungen zu killen */ setInterval(() => { for (const ws of clients) { if (ws.isAlive === false) { @@ -69,17 +76,20 @@ setInterval(() => { } }, 30000); -// Broadcast an alle Clients +/** Broadcast an alle Clients */ function broadcast(obj) { const msg = JSON.stringify(obj); for (const ws of clients) { - if (ws.readyState === 1) ws.send(msg); + if (ws.readyState === 1) { // OPEN + try { ws.send(msg); } catch {} + } } } -/** +/* ──────────────────────────────────────────────────────────── * Hilfsfunktionen - */ + * ──────────────────────────────────────────────────────────── */ + const lastBySteam = new Map(); function parseVec3(str) { @@ -120,20 +130,46 @@ function normalizeWeapons(weaponsObj) { 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: - * - body.map, body.phase_countdowns - * - body.allplayers: { [steamid]: { name, team, position, forward, state, weapons, ... } } - * - body.player: { steamid, team, activity, ... } (POV) - * - body.auth.token - */ + * Erwartet typische CS:GO/CS2-GSI-Felder + * ──────────────────────────────────────────────────────────── */ app.post(GSI_PATH, (req, res) => { try { const body = req.body || {}; - // 1) Auth vor Verarbeitung prüfen + // 1) Auth const tok = body?.auth?.token || ""; if (tok !== GSI_TOKEN) { return res.status(401).json({ ok: false, err: "bad token" }); @@ -154,7 +190,22 @@ app.post(GSI_PATH, (req, res) => { broadcast({ type: "map", name: currentMap }); } - // 3) Spieler normalisieren (aus allplayers!) + // 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(); @@ -166,7 +217,7 @@ app.post(GSI_PATH, (req, res) => { 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 eye = { x: pos.x, y: pos.y, z: pos.z + 64 }; // grobe Augenhöhe const { all: weapons, active } = normalizeWeapons(p.weapons); @@ -196,22 +247,42 @@ app.post(GSI_PATH, (req, res) => { return snap; }); - // 4) Aus Cache entfernen, wer nicht mehr im allplayers-Snapshot ist + // Aus Cache entfernen, wer im Snapshot fehlt 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) + // 4) Grenades roh (du kannst bei Bedarf normalisieren) const grenades = body?.grenades || {}; - // 6) Broadcast eines kompakten "tick"-Pakets für Clients + // 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, alle Spieler dieses Snapshots - grenades // roh + players, // vollständiger Satz aller Spieler dieses Snapshots + grenades, // roh + bomb // für Client-Rendering der C4 }); // 7) Erfolg @@ -222,10 +293,11 @@ app.post(GSI_PATH, (req, res) => { } }); -// health +/* ──────────────────────────────────────────────────────────── + * Health / Stats + * ──────────────────────────────────────────────────────────── */ 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) { @@ -235,9 +307,12 @@ app.get("/stats", (_req, res) => { isAlive: !!ws.isAlive }); } - res.json({ ok: true, count: list.length, clients: list, map: currentMap }); + 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}`);