731 lines
25 KiB
JavaScript
731 lines
25 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 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}`);
|
||
});
|