This commit is contained in:
Linrador 2025-09-13 15:40:46 +02:00
parent 12b297dd32
commit 949ce18a6b

437
server.js
View File

@ -110,70 +110,396 @@ function pickVec3Any(pos) {
const NADE_DEFAULTS = {
smoke: { radius: 150, lifetimeMs: 18_000 },
molotov:{ radius: 120, lifetimeMs: 7_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 [];
// 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';
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 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 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 = [];
for (const [k, v] of Object.entries(raw)) {
// z.B. "smokes", "smokegrenade_projectile", "inferno", "hegrenade_projectile" ...
const kind = mapKind(k);
pushList(out, kind, v);
// --- 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;
}
return out;
// 🔸 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) {
@ -223,9 +549,7 @@ function normalizeBombFromGSI(body) {
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";
@ -236,7 +560,6 @@ function normalizeBombFromGSI(body) {
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;
@ -275,11 +598,9 @@ app.post(GSI_PATH, (req, res) => {
// 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
@ -299,7 +620,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 }; // grobe Augenhöhe
const eye = { x: pos.x, y: pos.y, z: pos.z + 64 };
const { all: weapons, active } = normalizeWeapons(p.weapons);
@ -334,7 +655,7 @@ app.post(GSI_PATH, (req, res) => {
if (!presentIds.has(sid)) lastBySteam.delete(sid);
}
// 4) Grenades roh (du kannst bei Bedarf normalisieren)
// 4) Grenades normalisieren (inkl. Smoke-EffectTime-Logik)
const grenades = normalizeGrenadesFromGSI(body?.grenades || {}, Date.now());
// 5) Bombe normalisieren + Events bei Statuswechsel
@ -342,19 +663,14 @@ app.post(GSI_PATH, (req, res) => {
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") {
@ -368,7 +684,6 @@ app.post(GSI_PATH, (req, res) => {
lastBomb = bomb || null;
}
// 6) Tick mit kompletter Momentaufnahme (inkl. bomb)
broadcast({
type: "tick",
@ -376,7 +691,7 @@ app.post(GSI_PATH, (req, res) => {
map: currentMap,
phase: phaseInfo,
players, // vollständiger Satz aller Spieler dieses Snapshots
grenades, // roh
grenades, // normalisiert (inkl. Smoke: effectTimeSec/lifeElapsedMs/lifeLeftMs)
bomb // für Client-Rendering der C4
});