1783 lines
62 KiB
C#
1783 lines
62 KiB
C#
// CS2WebSocketTelemetryPlugin.cs
|
||
|
||
using System;
|
||
using System.Collections;
|
||
using System.Collections.Concurrent;
|
||
using System.Collections.Generic;
|
||
using System.IO;
|
||
using System.Linq;
|
||
using System.Net;
|
||
using System.Net.Sockets;
|
||
using System.Net.Security;
|
||
using System.Reflection;
|
||
using System.Security.Authentication;
|
||
using System.Security.Cryptography;
|
||
using System.Security.Cryptography.X509Certificates;
|
||
using System.Text;
|
||
using System.Text.Json;
|
||
using System.Threading;
|
||
using System.Threading.Tasks;
|
||
using CounterStrikeSharp.API;
|
||
using CounterStrikeSharp.API.Core;
|
||
using CounterStrikeSharp.API.Core.Attributes;
|
||
using CounterStrikeSharp.API.Core.Attributes.Registration;
|
||
using CounterStrikeSharp.API.Modules.Commands;
|
||
using CounterStrikeSharp.API.Modules.Events;
|
||
using Microsoft.Extensions.Logging;
|
||
|
||
namespace WsTelemetry;
|
||
|
||
[MinimumApiVersion(175)]
|
||
public class WebSocketTelemetryPlugin : BasePlugin
|
||
{
|
||
public override string ModuleName => "WS Telemetry";
|
||
public override string ModuleVersion => "1.5.2";
|
||
public override string ModuleAuthor => "you + ChatGPT";
|
||
public override string ModuleDescription => "WS(S)-Server: Spielerpos/aim + Granaten (Tick-Trace/Bounce/Detonate/Path) + Map + Smoke/Fire-Status";
|
||
|
||
// --- Konfiguration ---
|
||
private volatile bool _enabled = false;
|
||
private volatile int _sendHz = 10;
|
||
private volatile string _mapName = "";
|
||
|
||
// WS Bind-Info
|
||
private volatile string _bindHost = "0.0.0.0";
|
||
private volatile int _bindPort = 8081;
|
||
private volatile string _bindPath = "/telemetry";
|
||
private volatile bool _useTls = false;
|
||
|
||
// TLS Zertifikat (PFX)
|
||
private volatile string _certPath = "";
|
||
private volatile string _certPassword = "";
|
||
private X509Certificate2? _serverCert;
|
||
|
||
// --- Server / Clients ---
|
||
private TcpListener? _listener;
|
||
private CancellationTokenSource? _serverCts;
|
||
private Task? _acceptTask;
|
||
private volatile bool _serverRunning = false;
|
||
|
||
// --- Konfigurations-Laden ---
|
||
private const string ConfigFileName = "config.json";
|
||
|
||
private sealed class Cfg
|
||
{
|
||
public string? Url { get; set; }
|
||
public string? CertPath { get; set; }
|
||
public string? CertPassword { get; set; }
|
||
public int? SendHz { get; set; }
|
||
}
|
||
|
||
|
||
private void LoadAndApplyConfig(bool generateIfMissing = true)
|
||
{
|
||
try
|
||
{
|
||
var path = Path.Combine(ModuleDirectory, ConfigFileName);
|
||
|
||
if (!File.Exists(path) && generateIfMissing)
|
||
{
|
||
var example = new Cfg
|
||
{
|
||
Url = $"{(_useTls ? "wss" : "ws")}://{_bindHost}:{_bindPort}{_bindPath}",
|
||
CertPath = string.IsNullOrWhiteSpace(_certPath) ? "cert.pfx" : _certPath,
|
||
CertPassword = _certPassword,
|
||
SendHz = _sendHz
|
||
};
|
||
var jsonEx = JsonSerializer.Serialize(example, new JsonSerializerOptions { WriteIndented = true });
|
||
File.WriteAllText(path, jsonEx, Encoding.UTF8);
|
||
Logger.LogInformation($"[WS] Beispiel-Konfiguration erzeugt: {path}");
|
||
}
|
||
|
||
if (!File.Exists(path))
|
||
{
|
||
Logger.LogWarning($"[WS] Keine {ConfigFileName} gefunden. Verwende Defaults.");
|
||
return;
|
||
}
|
||
|
||
var json = File.ReadAllText(path, Encoding.UTF8);
|
||
var cfg = JsonSerializer.Deserialize<Cfg>(
|
||
json,
|
||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }
|
||
) ?? new Cfg();
|
||
|
||
// URL anwenden
|
||
if (!string.IsNullOrWhiteSpace(cfg.Url))
|
||
{
|
||
if (Uri.TryCreate(cfg.Url, UriKind.Absolute, out var uri) && (uri.Scheme == "ws" || uri.Scheme == "wss"))
|
||
{
|
||
_useTls = uri.Scheme == "wss";
|
||
_bindHost = string.IsNullOrWhiteSpace(uri.Host) ? "0.0.0.0" : uri.Host;
|
||
_bindPort = uri.IsDefaultPort ? (_useTls ? 443 : 80) : uri.Port;
|
||
_bindPath = string.IsNullOrEmpty(uri.AbsolutePath) ? "/" : uri.AbsolutePath;
|
||
if (_bindHost == "127.0.0.1") _bindHost = "0.0.0.0";
|
||
}
|
||
else
|
||
{
|
||
Logger.LogWarning($"[WS] Ungültige URL in {ConfigFileName}: '{cfg.Url}'");
|
||
}
|
||
}
|
||
|
||
// Zertifikat anwenden
|
||
if (!string.IsNullOrWhiteSpace(cfg.CertPath))
|
||
{
|
||
_certPath = Path.IsPathRooted(cfg.CertPath)
|
||
? cfg.CertPath
|
||
: Path.Combine(ModuleDirectory, cfg.CertPath);
|
||
_serverCert = null; // beim nächsten Start neu laden
|
||
}
|
||
if (cfg.CertPassword != null)
|
||
{
|
||
_certPassword = cfg.CertPassword;
|
||
_serverCert = null;
|
||
}
|
||
|
||
// Sendefrequenz
|
||
if (cfg.SendHz is int hz && hz >= 1 && hz <= 128) _sendHz = hz;
|
||
|
||
// Durations bleiben hart verdrahtet
|
||
_smokeDurationSec = SMOKE_DURATION_SEC;
|
||
_fireDurationSec = FIRE_DURATION_SEC;
|
||
|
||
Logger.LogInformation($"[WS] Konfiguration geladen ({_bindHost}:{_bindPort}{_bindPath}, tls={_useTls}, hz={_sendHz})");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Logger.LogError($"[WS] Konnte {ConfigFileName} nicht laden/anwenden: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
private void SaveConfig()
|
||
{
|
||
try
|
||
{
|
||
var path = Path.Combine(ModuleDirectory, ConfigFileName);
|
||
|
||
// URL aus aktuellen Feldern zusammensetzen
|
||
var url = $"{(_useTls ? "wss" : "ws")}://{_bindHost}:{_bindPort}{_bindPath}";
|
||
|
||
// CertPath möglichst relativ speichern
|
||
var cp = _certPath;
|
||
try
|
||
{
|
||
if (!string.IsNullOrWhiteSpace(cp))
|
||
{
|
||
if (Path.IsPathRooted(cp) && cp.StartsWith(ModuleDirectory, StringComparison.OrdinalIgnoreCase))
|
||
cp = Path.GetRelativePath(ModuleDirectory, cp);
|
||
}
|
||
}
|
||
catch { /* not fatal */ }
|
||
|
||
var cfg = new Cfg
|
||
{
|
||
Url = url,
|
||
CertPath = cp,
|
||
CertPassword = _certPassword,
|
||
SendHz = _sendHz
|
||
};
|
||
|
||
var json = JsonSerializer.Serialize(cfg, new JsonSerializerOptions { WriteIndented = true });
|
||
File.WriteAllText(path, json, Encoding.UTF8);
|
||
Logger.LogInformation($"[WS] Konfiguration gespeichert: {path}");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Logger.LogError($"[WS] Konnte Konfiguration nicht speichern: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
private class ClientState
|
||
{
|
||
public required TcpClient Tcp;
|
||
public required Stream Stream; // NetworkStream oder SslStream
|
||
public readonly object SendLock = new();
|
||
public readonly CancellationTokenSource Cts = new();
|
||
}
|
||
|
||
private readonly ConcurrentDictionary<int, ClientState> _clients = new();
|
||
private int _clientSeq = 0;
|
||
|
||
// --- Outgoing Queue ---
|
||
private readonly ConcurrentQueue<string> _outbox = new();
|
||
private readonly AutoResetEvent _sendSignal = new(false);
|
||
|
||
// --- Tick / Sampling ---
|
||
private double _accumulator = 0.0;
|
||
private const double MaxFrameDt = 0.25;
|
||
private DateTime _lastTick = DateTime.UtcNow;
|
||
|
||
// --- Nade Runtime-Status ---
|
||
private int _seq = 0; // globale Sequenz (für Nade/Trace IDs)
|
||
|
||
// Fest verdrahtete Zeiten (nicht änderbar)
|
||
private const double SMOKE_DURATION_SEC = 20.0;
|
||
private const double FIRE_DURATION_SEC = 7.0;
|
||
|
||
// interner Zugriff bleibt über Felder, die auf die Konstanten gesetzt werden
|
||
private double _smokeDurationSec = SMOKE_DURATION_SEC;
|
||
private double _fireDurationSec = FIRE_DURATION_SEC;
|
||
|
||
private class AoeState
|
||
{
|
||
public required DateTimeOffset EndAt;
|
||
public required float X;
|
||
public required float Y;
|
||
public required float Z;
|
||
}
|
||
|
||
private readonly ConcurrentDictionary<int, AoeState> _activeSmokes = new();
|
||
private readonly ConcurrentDictionary<int, AoeState> _activeFires = new();
|
||
|
||
// --- Trajektorien-Tracking ---
|
||
private class NadeTrack
|
||
{
|
||
public int Id;
|
||
public ulong SteamId;
|
||
public string Nade = "";
|
||
public DateTimeOffset Start;
|
||
public DateTimeOffset Last;
|
||
public bool Finalized;
|
||
|
||
public object? BoundEntity;
|
||
public string? BoundName;
|
||
public float LastX, LastY, LastZ;
|
||
|
||
public int Sent = 0; // bereits gesendete Punkte
|
||
public int CurrentSeg = 0; // aktuelle Segment-ID (0-basiert)
|
||
|
||
public readonly List<Point> Points = new();
|
||
public class Point { public float X; public float Y; public float Z; public long T; public int S; }
|
||
}
|
||
|
||
// Alle Tracks by id
|
||
private readonly ConcurrentDictionary<int, NadeTrack> _tracks = new();
|
||
// Letzter aktiver Track je Spieler (für grenade_bounce ohne Typ)
|
||
private readonly ConcurrentDictionary<ulong, int> _lastTrackByPlayer = new();
|
||
// Aktiver Track je Spieler+Nade (für eindeutiges Finalisieren)
|
||
private readonly ConcurrentDictionary<string, int> _activeTrackByPlayerNade = new();
|
||
|
||
private static string PNKey(ulong steamId, string nade) => $"{steamId}:{nade}";
|
||
|
||
// --- Entity-Suche via Reflection ---
|
||
private static MethodInfo? _miFindByDesigner;
|
||
private static MethodInfo? _miFindByClass;
|
||
|
||
private static IEnumerable<object> FindEntitiesByName(string name)
|
||
{
|
||
_miFindByDesigner ??= typeof(Utilities).GetMethod("FindAllEntitiesByDesignerName", BindingFlags.Public | BindingFlags.Static);
|
||
_miFindByClass ??= typeof(Utilities).GetMethod("FindAllEntitiesByClassname", BindingFlags.Public | BindingFlags.Static);
|
||
|
||
IEnumerable? ie = null;
|
||
try
|
||
{
|
||
if (_miFindByDesigner != null)
|
||
ie = _miFindByDesigner.Invoke(null, new object[] { name }) as IEnumerable;
|
||
if (ie == null && _miFindByClass != null)
|
||
ie = _miFindByClass.Invoke(null, new object[] { name }) as IEnumerable;
|
||
}
|
||
catch { }
|
||
|
||
if (ie == null) yield break;
|
||
foreach (var e in ie) if (e != null) yield return e;
|
||
}
|
||
|
||
private static bool TryGetPos(object ent, out float x, out float y, out float z)
|
||
{
|
||
try
|
||
{
|
||
dynamic d = ent;
|
||
var pos = d.AbsOrigin;
|
||
x = (float)pos.X; y = (float)pos.Y; z = (float)pos.Z;
|
||
return true;
|
||
}
|
||
catch
|
||
{
|
||
x = y = z = 0;
|
||
return false;
|
||
}
|
||
}
|
||
private static bool IsEntValid(object ent)
|
||
{
|
||
try { dynamic d = ent; return d != null && d.IsValid; }
|
||
catch { return ent != null; }
|
||
}
|
||
|
||
// --- Stabiler Aim ---
|
||
private readonly ConcurrentDictionary<ulong, (float pitch, float yaw)> _lastAimByPlayer = new();
|
||
|
||
private static long NowMs() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||
private static bool IsFinite(float v) => !(float.IsNaN(v) || float.IsInfinity(v)) && Math.Abs(v) < 1e6;
|
||
|
||
private static float NormalizeYaw(float yaw)
|
||
{
|
||
yaw %= 360f;
|
||
if (yaw < 0) yaw += 360f;
|
||
return yaw;
|
||
}
|
||
private static float ClampPitch(float pitch)
|
||
{
|
||
if (pitch < -89f) pitch = -89f;
|
||
if (pitch > 89f) pitch = 89f;
|
||
return pitch;
|
||
}
|
||
|
||
// -- Camera-gesteuerte Blickrichtung --
|
||
private static bool TryReadAngles(dynamic src, out float pitch, out float yaw, out float roll)
|
||
{
|
||
// Fallback-Init, damit out immer gesetzt ist
|
||
pitch = 0f; yaw = 0f; roll = 0f;
|
||
|
||
try { var a = src.EyeAngles; pitch = (float)a.X; yaw = (float)a.Y; roll = (float)a.Z; if (IsFinite(pitch) && IsFinite(yaw)) return true; } catch { }
|
||
try { var a = src.AbsRotation; pitch = (float)a.X; yaw = (float)a.Y; roll = (float)a.Z; if (IsFinite(pitch) && IsFinite(yaw)) return true; } catch { }
|
||
try { var a = src.ViewAngles; pitch = (float)a.X; yaw = (float)a.Y; roll = (float)a.Z; if (IsFinite(pitch) && IsFinite(yaw)) return true; } catch { }
|
||
try { pitch = (float)src.Pitch; yaw = (float)src.Yaw; roll = 0f; if (IsFinite(pitch) && IsFinite(yaw)) return true; } catch { }
|
||
|
||
// bleibt false; out sind bereits gesetzt
|
||
return false;
|
||
}
|
||
|
||
private static bool TryGetCameraAnglesFromRoot(dynamic root, out float pitch, out float yaw, out float roll)
|
||
{
|
||
// out-Parameter VOR jeglicher Logik initialisieren (fix für CS0177)
|
||
pitch = 0f; yaw = 0f; roll = 0f;
|
||
|
||
// 1) Aktive Kamera-Entity über CameraServices
|
||
try
|
||
{
|
||
dynamic cs = root.CameraServices;
|
||
dynamic h = (cs != null) ? cs.m_hActiveCamera : null;
|
||
dynamic cam = (h != null ? h.Value : null) ?? cs?.ActiveCamera ?? root.Camera;
|
||
if (cam != null && TryReadAngles(cam, out pitch, out yaw, out roll))
|
||
return true;
|
||
}
|
||
catch { }
|
||
|
||
// 2) Winkel direkt aus CameraServices
|
||
try
|
||
{
|
||
dynamic cs = root.CameraServices;
|
||
if (cs != null && TryReadAngles(cs, out pitch, out yaw, out roll))
|
||
return true;
|
||
}
|
||
catch { }
|
||
|
||
// 3) Letzter Versuch: direkt am Root
|
||
return TryReadAngles(root, out pitch, out yaw, out roll);
|
||
}
|
||
|
||
private static bool TryGetCameraAngles(CCSPlayerController p, dynamic pawn, out float pitch, out float yaw, out float roll)
|
||
{
|
||
// out-Defaults
|
||
pitch = 0f; yaw = 0f; roll = 0f;
|
||
|
||
if (pawn != null && TryGetCameraAnglesFromRoot(pawn, out pitch, out yaw, out roll))
|
||
return true;
|
||
|
||
if (TryGetCameraAnglesFromRoot(p, out pitch, out yaw, out roll))
|
||
return true;
|
||
|
||
return false;
|
||
}
|
||
|
||
private (float pitch, float yaw) StoreAim(CCSPlayerController p, float pitch, float yaw)
|
||
{
|
||
var sid = p.AuthorizedSteamID?.SteamId64 ?? 0UL;
|
||
_lastAimByPlayer[sid] = (pitch, yaw);
|
||
return (pitch, yaw);
|
||
}
|
||
|
||
private (float pitch, float yaw) GetStableAim(CCSPlayerController p, dynamic pawn)
|
||
{
|
||
// 0) Kamera-Winkel bevorzugen
|
||
float cp, cy, cr;
|
||
if (TryGetCameraAngles(p, pawn, out cp, out cy, out cr))
|
||
return StoreAim(p, ClampPitch(cp), NormalizeYaw(cy));
|
||
|
||
// 1) Fallback: Pawn EyeAngles
|
||
try
|
||
{
|
||
dynamic a = pawn?.EyeAngles;
|
||
float px = (float)a.X, py = (float)a.Y;
|
||
if (IsFinite(px) && IsFinite(py))
|
||
return StoreAim(p, ClampPitch(px), NormalizeYaw(py));
|
||
}
|
||
catch { }
|
||
|
||
// 2) Fallback: AbsRotation (Yaw), Pitch aus letztem Wert
|
||
try
|
||
{
|
||
dynamic r = pawn?.AbsRotation;
|
||
float yaw = (float)r.Y;
|
||
if (IsFinite(yaw))
|
||
{
|
||
var sid = p.AuthorizedSteamID?.SteamId64 ?? 0UL;
|
||
float pitch = _lastAimByPlayer.TryGetValue(sid, out var last) ? last.pitch : 0f;
|
||
return StoreAim(p, pitch, NormalizeYaw(yaw));
|
||
}
|
||
}
|
||
catch { }
|
||
|
||
// 3) Letzter gültiger Aim oder neutral
|
||
{
|
||
var sid = p.AuthorizedSteamID?.SteamId64 ?? 0UL;
|
||
if (_lastAimByPlayer.TryGetValue(sid, out var last)) return last;
|
||
return (0f, 0f);
|
||
}
|
||
}
|
||
|
||
public override void Load(bool hotReload)
|
||
{
|
||
Logger.LogInformation("[WS] Plugin geladen. Kommandos: css_ws_enable, css_ws_reloadcfg, css_ws_url, css_ws_rate, css_ws_cert, css_ws_certpwd, css_ws_sendmap");
|
||
|
||
RegisterListener<Listeners.OnTick>(OnTick);
|
||
|
||
_mapName = Server.MapName ?? "";
|
||
RegisterListener<Listeners.OnMapStart>(OnMapStart);
|
||
|
||
// Konfiguration einlesen (ohne enabled) …
|
||
LoadAndApplyConfig();
|
||
|
||
// … und automatisch starten (abschaltbar per css_ws_enable 0)
|
||
_enabled = true;
|
||
StartWebSocket();
|
||
}
|
||
|
||
public override void Unload(bool hotReload)
|
||
{
|
||
StopWebSocket();
|
||
}
|
||
|
||
private void OnMapStart(string newMap)
|
||
{
|
||
_mapName = newMap ?? Server.MapName ?? "";
|
||
var payload = JsonSerializer.Serialize(new
|
||
{
|
||
type = "map",
|
||
name = _mapName,
|
||
t = NowMs()
|
||
});
|
||
Broadcast(payload);
|
||
Logger.LogInformation($"[WS] Map gewechselt: '{_mapName}' – an Clients gesendet.");
|
||
}
|
||
|
||
// =========================
|
||
// Konsolen-Kommandos
|
||
// =========================
|
||
|
||
[ConsoleCommand("css_ws_enable", "Aktiviert/Deaktiviert den integrierten WS(S)-Server: css_ws_enable 1|0")]
|
||
[CommandHelper(minArgs: 1, usage: "<1|0>")]
|
||
public void CmdEnable(CCSPlayerController? caller, CommandInfo cmd)
|
||
{
|
||
var val = cmd.GetArg(1);
|
||
bool enable = val == "1" || val.Equals("true", StringComparison.OrdinalIgnoreCase);
|
||
|
||
if (enable == _enabled)
|
||
{
|
||
cmd.ReplyToCommand($"[WS] Bereits {_enabled}.");
|
||
return;
|
||
}
|
||
|
||
_enabled = enable;
|
||
cmd.ReplyToCommand($"[WS] Enabled = {_enabled}");
|
||
|
||
if (_enabled) StartWebSocket();
|
||
else StopWebSocket();
|
||
}
|
||
|
||
[ConsoleCommand("css_ws_reloadcfg", "Lädt die config.json neu und startet den WS(S)-Server ggf. neu")]
|
||
public void CmdReloadCfg(CCSPlayerController? caller, CommandInfo cmd)
|
||
{
|
||
var wasEnabled = _enabled;
|
||
|
||
LoadAndApplyConfig(generateIfMissing: false);
|
||
|
||
if (wasEnabled)
|
||
{
|
||
StopWebSocket();
|
||
StartWebSocket();
|
||
cmd.ReplyToCommand("[WS] Konfiguration neu geladen und Server neu gestartet.");
|
||
}
|
||
else
|
||
{
|
||
cmd.ReplyToCommand("[WS] Konfiguration neu geladen. Server ist deaktiviert (css_ws_enable 1 zum Starten).");
|
||
}
|
||
}
|
||
|
||
[ConsoleCommand("css_ws_sendmap", "Sendet die aktuelle Karte an alle verbundenen Clients")]
|
||
public void CmdSendMap(CCSPlayerController? caller, CommandInfo cmd)
|
||
{
|
||
var payload = JsonSerializer.Serialize(new
|
||
{
|
||
type = "map",
|
||
name = _mapName,
|
||
t = NowMs()
|
||
});
|
||
Broadcast(payload);
|
||
cmd.ReplyToCommand($"[WS] Map '{_mapName}' an Clients gesendet.");
|
||
}
|
||
|
||
[ConsoleCommand("css_ws_url", "Setzt Bind-Host/Port/Pfad als ws[s]://host:port/path")]
|
||
[CommandHelper(minArgs: 1, usage: "<ws[s]://host:port/path>")]
|
||
public void CmdUrl(CCSPlayerController? caller, CommandInfo cmd)
|
||
{
|
||
var url = cmd.GetArg(1);
|
||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || (uri.Scheme != "ws" && uri.Scheme != "wss"))
|
||
{
|
||
cmd.ReplyToCommand($"[WS] Ungültige URL: {url}");
|
||
return;
|
||
}
|
||
|
||
_useTls = uri.Scheme == "wss";
|
||
_bindHost = string.IsNullOrWhiteSpace(uri.Host) ? "0.0.0.0" : uri.Host;
|
||
_bindPort = uri.IsDefaultPort ? (_useTls ? 443 : 80) : uri.Port;
|
||
_bindPath = string.IsNullOrEmpty(uri.AbsolutePath) ? "/" : uri.AbsolutePath;
|
||
|
||
if (_bindHost == "127.0.0.1") _bindHost = "0.0.0.0";
|
||
|
||
var scheme = _useTls ? "wss" : "ws";
|
||
cmd.ReplyToCommand($"[WS] Bind = {scheme}://{_bindHost}:{_bindPort}{_bindPath}");
|
||
SaveConfig();
|
||
if (_enabled) { StopWebSocket(); StartWebSocket(); }
|
||
}
|
||
|
||
[ConsoleCommand("css_ws_rate", "Sendefrequenz in Hz (Standard 10)")]
|
||
[CommandHelper(minArgs: 1, usage: "<hz>")]
|
||
public void CmdRate(CCSPlayerController? caller, CommandInfo cmd)
|
||
{
|
||
if (int.TryParse(cmd.GetArg(1), out var hz) && hz > 0 && hz <= 128)
|
||
{
|
||
_sendHz = hz;
|
||
SaveConfig();
|
||
cmd.ReplyToCommand($"[WS] Sendefrequenz = {_sendHz} Hz");
|
||
}
|
||
else cmd.ReplyToCommand("[WS] Ungültig. Bereich: 1..128");
|
||
}
|
||
|
||
[ConsoleCommand("css_ws_cert", "Setzt das TLS-Zertifikat (PFX-Datei)")]
|
||
[CommandHelper(minArgs: 1, usage: "<pfad-zum.pfx>")]
|
||
public void CmdCert(CCSPlayerController? caller, CommandInfo cmd)
|
||
{
|
||
var input = cmd.GetArg(1);
|
||
_certPath = Path.IsPathRooted(input) ? input : Path.Combine(ModuleDirectory, input);
|
||
_serverCert = null; // neu laden beim Start
|
||
cmd.ReplyToCommand($"[WS] Zertifikatspfad gesetzt: '{_certPath}'");
|
||
SaveConfig();
|
||
if (_enabled && _useTls) { StopWebSocket(); StartWebSocket(); }
|
||
}
|
||
|
||
[ConsoleCommand("css_ws_certpwd", "Setzt das Passwort für das PFX-Zertifikat (oder '-' zum Leeren)")]
|
||
[CommandHelper(minArgs: 1, usage: "<passwort|->")]
|
||
public void CmdCertPwd(CCSPlayerController? caller, CommandInfo cmd)
|
||
{
|
||
var pwd = cmd.GetArg(1);
|
||
_certPassword = pwd == "-" ? "" : pwd;
|
||
_serverCert = null; // neu laden beim Start
|
||
cmd.ReplyToCommand($"[WS] Zertifikatspasswort {(string.IsNullOrEmpty(_certPassword) ? "gelöscht" : "gesetzt")}.");
|
||
SaveConfig();
|
||
if (_enabled && _useTls) { StopWebSocket(); StartWebSocket(); }
|
||
}
|
||
|
||
// =========================
|
||
// Tick / Spieler-Snapshot
|
||
// =========================
|
||
|
||
private void OnTick()
|
||
{
|
||
if (!_enabled || !_serverRunning) return;
|
||
|
||
var now = DateTime.UtcNow;
|
||
var dt = (now - _lastTick).TotalSeconds;
|
||
_lastTick = now;
|
||
if (dt > MaxFrameDt) dt = MaxFrameDt;
|
||
|
||
_accumulator += dt;
|
||
var targetDt = 1.0 / Math.Max(1, _sendHz);
|
||
if (_accumulator < targetDt) return;
|
||
_accumulator = 0;
|
||
|
||
// verwaiste Tracks aufräumen (Failsafe, 15s)
|
||
foreach (var kv in _tracks)
|
||
{
|
||
var tr = kv.Value;
|
||
if (!tr.Finalized && (DateTimeOffset.UtcNow - tr.Last).TotalSeconds > 15)
|
||
{
|
||
TryFinalizeTrackById(tr.Id, sendEvenIfEmpty: true, nadeOverride: tr.Nade);
|
||
}
|
||
}
|
||
|
||
// --- Spieler sammeln ---
|
||
var playersList = new List<object>();
|
||
foreach (var p in Utilities.GetPlayers())
|
||
{
|
||
try
|
||
{
|
||
if (p == null || !p.IsValid || p.IsBot || p.IsHLTV) continue;
|
||
|
||
var pawnHandle = p.PlayerPawn;
|
||
if (pawnHandle == null || !pawnHandle.IsValid) continue;
|
||
|
||
var pawn = pawnHandle.Value;
|
||
if (pawn == null) continue;
|
||
|
||
var pos = pawn.AbsOrigin;
|
||
|
||
// stabile Blickrichtung
|
||
var (aimPitch, aimYaw) = GetStableAim(p, pawn);
|
||
|
||
// Bewegungsrichtung aus Velocity (2D)
|
||
float dirYaw = 0f, speed2D = 0f;
|
||
float vx = 0f, vy = 0f, vz = 0f;
|
||
try
|
||
{
|
||
var vel = pawn.AbsVelocity;
|
||
vx = vel.X; vy = vel.Y; vz = vel.Z;
|
||
speed2D = MathF.Sqrt(vx * vx + vy * vy);
|
||
if (speed2D > 1f)
|
||
dirYaw = NormalizeYaw((float)(Math.Atan2(vy, vx) * 180.0 / Math.PI));
|
||
}
|
||
catch { }
|
||
|
||
// rohe Pawn-Angles (Debug/Kompat)
|
||
float rawPitch = 0f, rawYaw = 0f, rawRoll = 0f;
|
||
try
|
||
{
|
||
var ar = pawn.EyeAngles;
|
||
rawPitch = ar.X; rawYaw = ar.Y; rawRoll = ar.Z;
|
||
}
|
||
catch { }
|
||
|
||
playersList.Add(new
|
||
{
|
||
steamId = p.AuthorizedSteamID?.SteamId64 ?? 0UL,
|
||
name = p.PlayerName,
|
||
team = p.TeamNum,
|
||
pos = new { x = pos.X, y = pos.Y, z = pos.Z },
|
||
|
||
// stabilisierte Blickrichtung (nutzen!)
|
||
view = new { pitch = aimPitch, yaw = aimYaw, roll = 0f },
|
||
aim = new { pitch = aimPitch, yaw = aimYaw },
|
||
|
||
// optional: rohe Werte
|
||
viewRaw = new { pitch = rawPitch, yaw = rawYaw, roll = rawRoll },
|
||
|
||
move = new
|
||
{
|
||
dirYaw = speed2D > 1f ? dirYaw : (float?)null,
|
||
speed = speed2D,
|
||
vel = new { x = vx, y = vy, z = vz }
|
||
}
|
||
});
|
||
}
|
||
catch { }
|
||
}
|
||
|
||
// --- Nade-Trace pro Tick sammeln ---
|
||
var traceList = new List<object>();
|
||
|
||
// Sammeln/Updaten der aktiven Projektile (füllt nur Track-Punkte)
|
||
SampleActiveNadeTracks();
|
||
|
||
// --- Nade-Path Realtime-Streaming pro Tick ---
|
||
foreach (var tr in _tracks.Values)
|
||
{
|
||
if (tr.Finalized) continue;
|
||
|
||
int from, to;
|
||
List<object> delta = null!;
|
||
lock (tr.Points)
|
||
{
|
||
from = tr.Sent;
|
||
to = tr.Points.Count;
|
||
if (to > from)
|
||
{
|
||
delta = new List<object>(to - from);
|
||
for (int i = from; i < to; i++)
|
||
{
|
||
var p = tr.Points[i];
|
||
delta.Add(new { x = p.X, y = p.Y, z = p.Z, t = p.T, s = p.S });
|
||
}
|
||
tr.Sent = to;
|
||
}
|
||
}
|
||
|
||
if (delta != null && delta.Count > 0)
|
||
{
|
||
Enqueue(new
|
||
{
|
||
type = "nade",
|
||
sub = "path",
|
||
id = tr.Id,
|
||
nade = tr.Nade,
|
||
steamId = tr.SteamId,
|
||
t = NowMs(),
|
||
points = delta,
|
||
final = false
|
||
});
|
||
}
|
||
}
|
||
|
||
// aktive Nades (letzte bekannte Position)
|
||
var activeList = new List<object>();
|
||
foreach (var tr in _tracks.Values)
|
||
{
|
||
if (tr.Finalized) continue;
|
||
activeList.Add(new
|
||
{
|
||
id = tr.Id,
|
||
nade = tr.Nade,
|
||
steamId = tr.SteamId,
|
||
pos = new { x = tr.LastX, y = tr.LastY, z = tr.LastZ }
|
||
});
|
||
}
|
||
|
||
if (playersList.Count == 0 && traceList.Count == 0 && activeList.Count == 0) return;
|
||
|
||
var payload = new
|
||
{
|
||
type = "tick",
|
||
t = NowMs(),
|
||
players = playersList,
|
||
nades = (activeList.Count > 0 || traceList.Count > 0)
|
||
? new { trace = traceList, active = activeList }
|
||
: null
|
||
};
|
||
|
||
Broadcast(JsonSerializer.Serialize(payload));
|
||
}
|
||
|
||
// =========================
|
||
// Sampling der aktiven Projektile
|
||
// =========================
|
||
|
||
private static readonly Dictionary<string, string[]> _kindToNames = new()
|
||
{
|
||
["he"] = new[] { "hegrenade_projectile" },
|
||
["flash"] = new[] { "flashbang_projectile" },
|
||
["smoke"] = new[] { "smokegrenade_projectile" },
|
||
["decoy"] = new[] { "decoy_projectile" },
|
||
["molotov"] = new[] { "molotov_projectile", "incgrenade_projectile" },
|
||
["other"] = new[] { "grenade_projectile" } // Fallback
|
||
};
|
||
|
||
private IEnumerable<(object ent, string name, float x, float y, float z)> EnumerateProjectilesFor(string kind)
|
||
{
|
||
if (!_kindToNames.TryGetValue(kind, out var names)) names = _kindToNames["other"];
|
||
|
||
foreach (var nm in names)
|
||
{
|
||
foreach (var e in FindEntitiesByName(nm))
|
||
{
|
||
if (e == null || !IsEntValid(e)) continue;
|
||
if (TryGetPos(e, out var x, out var y, out var z))
|
||
yield return (e, nm, x, y, z);
|
||
}
|
||
}
|
||
}
|
||
|
||
private void SampleActiveNadeTracks()
|
||
{
|
||
var neededKinds = _tracks.Values.Where(t => !t.Finalized).Select(t => t.Nade).Distinct().ToList();
|
||
var poolByKind = new Dictionary<string, List<(object ent, string name, float x, float y, float z)>>();
|
||
foreach (var k in neededKinds)
|
||
poolByKind[k] = EnumerateProjectilesFor(k).ToList();
|
||
|
||
var usedEnts = new HashSet<object>(ReferenceEqualityComparer.Instance);
|
||
|
||
foreach (var kv in _tracks)
|
||
{
|
||
var tr = kv.Value;
|
||
if (tr.Finalized) continue;
|
||
|
||
if (tr.BoundEntity == null)
|
||
{
|
||
if (!poolByKind.TryGetValue(tr.Nade, out var list) || list.Count == 0) continue;
|
||
|
||
var best = list
|
||
.Where(tpl => !usedEnts.Contains(tpl.ent))
|
||
.Select(tpl => new { tpl.ent, tpl.name, tpl.x, tpl.y, tpl.z, dist2 = Dist2(tr.LastX, tr.LastY, tr.LastZ, tpl.x, tpl.y, tpl.z) })
|
||
.OrderBy(a => a.dist2)
|
||
.FirstOrDefault();
|
||
|
||
// NEU: altersabhängiger Fangradius + sanfter Fallback
|
||
var ageSec = (float)(DateTimeOffset.UtcNow - tr.Start).TotalSeconds;
|
||
|
||
// wächst schnell an, deckelt bei 4500u (ca. halbe Mapbreite)
|
||
var maxDist = MathF.Min(4500f, 300f + ageSec * 2500f);
|
||
var maxDist2 = maxDist * maxDist;
|
||
|
||
if (best != null && best.dist2 <= maxDist2)
|
||
{
|
||
tr.BoundEntity = best.ent;
|
||
tr.BoundName = best.name;
|
||
tr.LastX = best.x; tr.LastY = best.y; tr.LastZ = best.z;
|
||
tr.Last = DateTimeOffset.UtcNow;
|
||
|
||
var tms = NowMs();
|
||
lock (tr.Points)
|
||
tr.Points.Add(new NadeTrack.Point { X = best.x, Y = best.y, Z = best.z, T = tms, S = tr.CurrentSeg });
|
||
|
||
usedEnts.Add(best.ent);
|
||
}
|
||
else if (best != null && tr.Points.Count <= 1)
|
||
{
|
||
// Fallback: ganz am Anfang binden wir notfalls trotzdem den nächsten Kandidaten,
|
||
// damit sofort Bewegungsdaten fließen (vermeidet "nur Wurf+Detonate").
|
||
tr.BoundEntity = best.ent;
|
||
tr.BoundName = best.name;
|
||
tr.LastX = best.x; tr.LastY = best.y; tr.LastZ = best.z;
|
||
tr.Last = DateTimeOffset.UtcNow;
|
||
|
||
var tms = NowMs();
|
||
lock (tr.Points)
|
||
tr.Points.Add(new NadeTrack.Point { X = best.x, Y = best.y, Z = best.z, T = tms, S = tr.CurrentSeg });
|
||
|
||
usedEnts.Add(best.ent);
|
||
}
|
||
}
|
||
else
|
||
{
|
||
var ent = tr.BoundEntity;
|
||
if (ent == null || !IsEntValid(ent)) continue;
|
||
|
||
if (TryGetPos(ent, out var ex, out var ey, out var ez))
|
||
{
|
||
if (Dist2(tr.LastX, tr.LastY, tr.LastZ, ex, ey, ez) > 0.1f * 0.1f)
|
||
{
|
||
var tms = NowMs();
|
||
lock (tr.Points)
|
||
tr.Points.Add(new NadeTrack.Point { X = ex, Y = ey, Z = ez, T = tms, S = tr.CurrentSeg });
|
||
|
||
tr.LastX = ex; tr.LastY = ey; tr.LastZ = ez;
|
||
tr.Last = DateTimeOffset.UtcNow;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private static float Dist2(float ax, float ay, float az, float bx, float by, float bz)
|
||
{
|
||
float dx = ax - bx, dy = ay - by, dz = az - bz;
|
||
return dx * dx + dy * dy + dz * dz;
|
||
}
|
||
|
||
private static bool LooksAirburst(NadeTrack tr)
|
||
{
|
||
if (tr.Points.Count < 2) return false;
|
||
var a = tr.Points[^1];
|
||
var b = tr.Points[^2];
|
||
var dtMs = Math.Max(1, a.T - b.T);
|
||
var dist = MathF.Sqrt(Dist2(a.X, a.Y, a.Z, b.X, b.Y, b.Z));
|
||
var speed3D = 1000f * dist / dtMs; // units/s
|
||
var vz = 1000f * Math.Abs(a.Z - b.Z) / dtMs; // vertikale speed
|
||
return speed3D > 250f || vz > 120f;
|
||
}
|
||
|
||
// =========================
|
||
// Game-Events: Granaten
|
||
// =========================
|
||
|
||
private static string WeaponToKind(string weapon)
|
||
{
|
||
var w = (weapon ?? "").ToLowerInvariant();
|
||
return w switch
|
||
{
|
||
"hegrenade" or "weapon_hegrenade" => "he",
|
||
"flashbang" or "weapon_flashbang" => "flash",
|
||
"smokegrenade" or "weapon_smokegrenade" => "smoke",
|
||
"decoy" or "weapon_decoy" => "decoy",
|
||
"molotov" or "weapon_molotov" or "incgrenade" or "weapon_incgrenade" => "molotov",
|
||
_ => "other"
|
||
};
|
||
}
|
||
|
||
private int StartTrack(ulong steamId, string nade, float x, float y, float z)
|
||
{
|
||
var id = Interlocked.Increment(ref _seq);
|
||
var tr = new NadeTrack
|
||
{
|
||
Id = id,
|
||
SteamId = steamId,
|
||
Nade = nade,
|
||
Start = DateTimeOffset.UtcNow,
|
||
Last = DateTimeOffset.UtcNow,
|
||
LastX = x, LastY = y, LastZ = z,
|
||
Sent = 0,
|
||
CurrentSeg = 0
|
||
};
|
||
tr.Points.Add(new NadeTrack.Point { X = x, Y = y, Z = z, T = NowMs(), S = tr.CurrentSeg });
|
||
_tracks[id] = tr;
|
||
_lastTrackByPlayer[steamId] = id;
|
||
_activeTrackByPlayerNade[PNKey(steamId, nade)] = id;
|
||
return id;
|
||
}
|
||
|
||
private void AppendPointByPlayer(ulong steamId, float x, float y, float z)
|
||
{
|
||
if (_lastTrackByPlayer.TryGetValue(steamId, out var id) &&
|
||
_tracks.TryGetValue(id, out var tr) && !tr.Finalized)
|
||
{
|
||
lock (tr.Points)
|
||
{
|
||
var tms = NowMs();
|
||
tr.Points.Add(new NadeTrack.Point { X = x, Y = y, Z = z, T = tms, S = tr.CurrentSeg });
|
||
tr.LastX = x; tr.LastY = y; tr.LastZ = z;
|
||
tr.Last = DateTimeOffset.UtcNow;
|
||
}
|
||
}
|
||
}
|
||
|
||
private void AppendPointByPlayerBounce(ulong steamId, float x, float y, float z)
|
||
{
|
||
if (_lastTrackByPlayer.TryGetValue(steamId, out var id) &&
|
||
_tracks.TryGetValue(id, out var tr) && !tr.Finalized)
|
||
{
|
||
lock (tr.Points)
|
||
{
|
||
tr.CurrentSeg++; // neues Liniensegment beginnen
|
||
var tms = NowMs();
|
||
tr.Points.Add(new NadeTrack.Point { X = x, Y = y, Z = z, T = tms, S = tr.CurrentSeg });
|
||
tr.LastX = x; tr.LastY = y; tr.LastZ = z;
|
||
tr.Last = DateTimeOffset.UtcNow;
|
||
}
|
||
}
|
||
}
|
||
|
||
private void TryFinalizeTrack(ulong steamId, string nade, float? x, float? y, float? z)
|
||
{
|
||
var key = PNKey(steamId, nade);
|
||
if (!_activeTrackByPlayerNade.TryRemove(key, out var id)) return;
|
||
TryFinalizeTrackById(id, true, nade, x, y, z);
|
||
_lastTrackByPlayer.TryGetValue(steamId, out var lastId);
|
||
if (lastId == id) _lastTrackByPlayer.TryRemove(steamId, out _);
|
||
}
|
||
|
||
private void TryFinalizeTrackById(int id, bool sendEvenIfEmpty, string? nadeOverride = null, float? x = null, float? y = null, float? z = null)
|
||
{
|
||
if (!_tracks.TryGetValue(id, out var tr) || tr.Finalized) return;
|
||
|
||
if (x.HasValue && y.HasValue && z.HasValue)
|
||
{
|
||
lock (tr.Points)
|
||
{
|
||
tr.Points.Add(new NadeTrack.Point { X = x.Value, Y = y.Value, Z = z.Value, T = NowMs(), S = tr.CurrentSeg });
|
||
}
|
||
}
|
||
|
||
// Rest-Delta senden (falls noch nicht raus)
|
||
List<object>? delta = null;
|
||
lock (tr.Points)
|
||
{
|
||
if (tr.Points.Count > tr.Sent)
|
||
{
|
||
delta = new List<object>(tr.Points.Count - tr.Sent);
|
||
for (int i = tr.Sent; i < tr.Points.Count; i++)
|
||
{
|
||
var p = tr.Points[i];
|
||
delta.Add(new { x = p.X, y = p.Y, z = p.Z, t = p.T, s = p.S });
|
||
}
|
||
tr.Sent = tr.Points.Count;
|
||
}
|
||
}
|
||
|
||
tr.Finalized = true;
|
||
|
||
// Finale Nachricht
|
||
Enqueue(new
|
||
{
|
||
type = "nade",
|
||
sub = "path",
|
||
id = tr.Id,
|
||
nade = nadeOverride ?? tr.Nade,
|
||
steamId = tr.SteamId,
|
||
t = NowMs(),
|
||
points = (delta != null && delta.Count > 0) ? delta : null,
|
||
final = true
|
||
});
|
||
|
||
_tracks.TryRemove(id, out _);
|
||
}
|
||
|
||
[GameEventHandler]
|
||
public HookResult OnGrenadeThrown(EventGrenadeThrown ev, GameEventInfo info)
|
||
{
|
||
try
|
||
{
|
||
var p = ev.Userid;
|
||
var pawn = p?.PlayerPawn?.Value;
|
||
var pos = pawn?.AbsOrigin;
|
||
var (gaPitch, gaYaw) = GetStableAim(p, pawn);
|
||
|
||
string weapon = ev.Weapon ?? "";
|
||
string kind = WeaponToKind(weapon);
|
||
|
||
float tx = pos?.X ?? 0f, ty = pos?.Y ?? 0f, tz = pos?.Z ?? 0f;
|
||
var id = StartTrack(p?.AuthorizedSteamID?.SteamId64 ?? 0UL, kind, tx, ty, tz);
|
||
|
||
try { SampleActiveNadeTracks(); } catch { }
|
||
|
||
Enqueue(new
|
||
{
|
||
type = "nade",
|
||
sub = "thrown",
|
||
id,
|
||
nade = kind,
|
||
t = NowMs(),
|
||
steamId = p?.AuthorizedSteamID?.SteamId64 ?? 0UL,
|
||
name = p?.PlayerName ?? "",
|
||
weapon = weapon,
|
||
throwPos = new { x = tx, y = ty, z = tz },
|
||
throwAim = new { pitch = gaPitch, yaw = NormalizeYaw(gaYaw) }
|
||
});
|
||
}
|
||
catch { }
|
||
return HookResult.Continue;
|
||
}
|
||
|
||
// Bounces -> Trajektorie weiter aufzeichnen (Punkt erscheint im nächsten Tick)
|
||
[GameEventHandler]
|
||
public HookResult OnGrenadeBounce(EventGrenadeBounce ev, GameEventInfo info)
|
||
{
|
||
try
|
||
{
|
||
dynamic d = ev;
|
||
var p = d.Userid as CCSPlayerController;
|
||
AppendPointByPlayerBounce(p?.AuthorizedSteamID?.SteamId64 ?? 0UL, (float)d.X, (float)d.Y, (float)d.Z);
|
||
}
|
||
catch { }
|
||
return HookResult.Continue;
|
||
}
|
||
|
||
|
||
[GameEventHandler] public HookResult OnHeDetonate (EventHegrenadeDetonate ev, GameEventInfo info)
|
||
{ TryFinalizeDet(ev, "he"); return EnqueueNadeDet(ev, "he"); }
|
||
|
||
[GameEventHandler] public HookResult OnFlashDetonate (EventFlashbangDetonate ev, GameEventInfo info)
|
||
{ TryFinalizeDet(ev, "flash"); return EnqueueNadeDet(ev, "flash"); }
|
||
|
||
[GameEventHandler] public HookResult OnDecoyDetonate (EventDecoyDetonate ev, GameEventInfo info)
|
||
{ TryFinalizeDet(ev, "decoy"); return EnqueueNadeDet(ev, "decoy"); }
|
||
|
||
private void TryFinalizeDet(GameEvent ev, string kind)
|
||
{
|
||
try
|
||
{
|
||
dynamic d = ev;
|
||
var p = d.Userid as CCSPlayerController;
|
||
TryFinalizeTrack(p?.AuthorizedSteamID?.SteamId64 ?? 0UL, kind, (float)d.X, (float)d.Y, (float)d.Z);
|
||
}
|
||
catch { }
|
||
}
|
||
|
||
// Smoke: Detonate + start/end mit ETA (+ Pfadabschluss)
|
||
[GameEventHandler]
|
||
public HookResult OnSmokeDetonate(EventSmokegrenadeDetonate ev, GameEventInfo info)
|
||
{
|
||
try
|
||
{
|
||
dynamic d = ev;
|
||
var p = d.Userid as CCSPlayerController;
|
||
|
||
TryFinalizeTrack(p?.AuthorizedSteamID?.SteamId64 ?? 0UL, "smoke", (float)d.X, (float)d.Y, (float)d.Z);
|
||
|
||
Enqueue(new
|
||
{
|
||
type = "nade",
|
||
sub = "detonate",
|
||
nade = "smoke",
|
||
t = NowMs(),
|
||
steamId = p?.AuthorizedSteamID?.SteamId64 ?? 0UL,
|
||
name = p?.PlayerName ?? "",
|
||
pos = new { x = (float)d.X, y = (float)d.Y, z = (float)d.Z }
|
||
});
|
||
|
||
var id = Interlocked.Increment(ref _seq);
|
||
var endAt = DateTimeOffset.UtcNow.AddSeconds(_smokeDurationSec);
|
||
var state = new AoeState { EndAt = endAt, X = (float)d.X, Y = (float)d.Y, Z = (float)d.Z };
|
||
_activeSmokes[id] = state;
|
||
|
||
Enqueue(new
|
||
{
|
||
type = "smoke",
|
||
state = "start",
|
||
id,
|
||
t = NowMs(),
|
||
endAt = endAt.ToUnixTimeMilliseconds(),
|
||
pos = new { x = state.X, y = state.Y, z = state.Z }
|
||
});
|
||
|
||
var token = _serverCts?.Token ?? CancellationToken.None;
|
||
_ = Task.Run(async () =>
|
||
{
|
||
try
|
||
{
|
||
await Task.Delay(TimeSpan.FromSeconds(_smokeDurationSec), token);
|
||
if (_activeSmokes.TryRemove(id, out _))
|
||
{
|
||
Enqueue(new { type = "smoke", state = "end", id, t = NowMs() });
|
||
}
|
||
}
|
||
catch { }
|
||
}, token);
|
||
}
|
||
catch { }
|
||
return HookResult.Continue;
|
||
}
|
||
|
||
// Molotov: Detonate + start/end mit ETA (+ Pfadabschluss) – nur start, wenn kein Airburst
|
||
[GameEventHandler]
|
||
public HookResult OnMolotovDetonate(EventMolotovDetonate ev, GameEventInfo info)
|
||
{
|
||
try
|
||
{
|
||
dynamic d = ev;
|
||
var p = d.Userid as CCSPlayerController;
|
||
var sid = p?.AuthorizedSteamID?.SteamId64 ?? 0UL;
|
||
|
||
bool airburst = false;
|
||
if (_activeTrackByPlayerNade.TryGetValue(PNKey(sid, "molotov"), out var tid) &&
|
||
_tracks.TryGetValue(tid, out var trProbe))
|
||
{
|
||
airburst = LooksAirburst(trProbe);
|
||
}
|
||
|
||
TryFinalizeTrack(sid, "molotov", (float)d.X, (float)d.Y, (float)d.Z);
|
||
|
||
Enqueue(new
|
||
{
|
||
type = "nade",
|
||
sub = "detonate",
|
||
nade = "molotov",
|
||
t = NowMs(),
|
||
steamId = sid,
|
||
name = p?.PlayerName ?? "",
|
||
pos = new { x = (float)d.X, y = (float)d.Y, z = (float)d.Z }
|
||
});
|
||
|
||
if (!airburst)
|
||
{
|
||
var id = Interlocked.Increment(ref _seq);
|
||
var endAt = DateTimeOffset.UtcNow.AddSeconds(_fireDurationSec);
|
||
var state = new AoeState { EndAt = endAt, X = (float)d.X, Y = (float)d.Y, Z = (float)d.Z };
|
||
_activeFires[id] = state;
|
||
|
||
Enqueue(new
|
||
{
|
||
type = "fire",
|
||
state = "start",
|
||
id,
|
||
t = NowMs(),
|
||
endAt = endAt.ToUnixTimeMilliseconds(),
|
||
pos = new { x = state.X, y = state.Y, z = state.Z }
|
||
});
|
||
|
||
var token = _serverCts?.Token ?? CancellationToken.None;
|
||
_ = Task.Run(async () =>
|
||
{
|
||
try
|
||
{
|
||
await Task.Delay(TimeSpan.FromSeconds(_fireDurationSec), token);
|
||
if (_activeFires.TryRemove(id, out _))
|
||
{
|
||
Enqueue(new { type = "fire", state = "end", id, t = NowMs() });
|
||
}
|
||
}
|
||
catch { }
|
||
}, token);
|
||
}
|
||
}
|
||
catch { }
|
||
return HookResult.Continue;
|
||
}
|
||
|
||
// Generischer Detonate-Helper für HE/Flash/Decoy (Einzel-Event bleibt)
|
||
private HookResult EnqueueNadeDet(GameEvent ev, string kind)
|
||
{
|
||
try
|
||
{
|
||
dynamic d = ev;
|
||
var p = d.Userid as CCSPlayerController;
|
||
|
||
Enqueue(new
|
||
{
|
||
type = "nade",
|
||
sub = "detonate",
|
||
nade = kind,
|
||
t = NowMs(),
|
||
steamId = p?.AuthorizedSteamID?.SteamId64 ?? 0UL,
|
||
name = p?.PlayerName ?? "",
|
||
pos = new { x = (float)d.X, y = (float)d.Y, z = (float)d.Z }
|
||
});
|
||
}
|
||
catch { }
|
||
return HookResult.Continue;
|
||
}
|
||
|
||
private HookResult Enqueue(object obj)
|
||
{
|
||
if (!_enabled || !_serverRunning) return HookResult.Continue;
|
||
_outbox.Enqueue(JsonSerializer.Serialize(obj));
|
||
_sendSignal.Set();
|
||
return HookResult.Continue;
|
||
}
|
||
|
||
private static bool LooksWrongPfxPassword(Exception ex)
|
||
{
|
||
try
|
||
{
|
||
var s = (ex?.Message ?? "").ToLowerInvariant();
|
||
return s.Contains("password") || s.Contains("kennwort");
|
||
}
|
||
catch { return false; }
|
||
}
|
||
|
||
private bool TryLoadCertificate(out string usedPath)
|
||
{
|
||
usedPath = _certPath;
|
||
|
||
try
|
||
{
|
||
string pluginDir = ModuleDirectory;
|
||
|
||
// Pfad ermitteln
|
||
if (string.IsNullOrWhiteSpace(usedPath))
|
||
{
|
||
var def = Path.Combine(pluginDir, "cert.pfx");
|
||
if (File.Exists(def))
|
||
{
|
||
usedPath = def;
|
||
}
|
||
else
|
||
{
|
||
var files = Directory.GetFiles(pluginDir, "*.pfx", SearchOption.TopDirectoryOnly);
|
||
if (files.Length > 0) usedPath = files[0];
|
||
}
|
||
}
|
||
else if (!Path.IsPathRooted(usedPath))
|
||
{
|
||
usedPath = Path.Combine(pluginDir, usedPath);
|
||
}
|
||
|
||
if (string.IsNullOrWhiteSpace(usedPath) || !File.Exists(usedPath))
|
||
{
|
||
Logger.LogWarning($"[WS] Kein PFX gefunden im Plugin-Ordner ({pluginDir}). Lege z.B. 'cert.pfx' dort ab oder setze mit css_ws_cert <pfad>.");
|
||
return false;
|
||
}
|
||
|
||
try
|
||
{
|
||
_serverCert = new X509Certificate2(
|
||
usedPath,
|
||
string.IsNullOrEmpty(_certPassword) ? null : _certPassword,
|
||
X509KeyStorageFlags.EphemeralKeySet | X509KeyStorageFlags.Exportable
|
||
);
|
||
}
|
||
catch (CryptographicException cex) when (LooksWrongPfxPassword(cex))
|
||
{
|
||
var pwdState = string.IsNullOrEmpty(_certPassword) ? "leer" : "gesetzt";
|
||
Logger.LogError($"[WS] TLS-PFX konnte nicht geöffnet werden: vermutlich falsches Passwort. " +
|
||
$"Pfad: {usedPath}. CertPassword (aus config.json) ist {pwdState}. " +
|
||
$"Setze es mit 'css_ws_certpwd <pass>' oder trage es in {ConfigFileName} unter 'CertPassword' ein.");
|
||
_serverCert = null;
|
||
return false;
|
||
}
|
||
catch (CryptographicException cex)
|
||
{
|
||
Logger.LogError($"[WS] TLS-PFX Fehler ({usedPath}): {cex.Message}");
|
||
_serverCert = null;
|
||
return false;
|
||
}
|
||
|
||
if (_serverCert == null)
|
||
return false;
|
||
|
||
if (!_serverCert.HasPrivateKey)
|
||
{
|
||
Logger.LogError($"[WS] Zertifikat geladen, aber ohne Private Key: {Path.GetFileName(usedPath)}. " +
|
||
$"Bitte PFX mit privatem Schlüssel verwenden.");
|
||
_serverCert = null;
|
||
return false;
|
||
}
|
||
|
||
try
|
||
{
|
||
Logger.LogInformation($"[WS] TLS-Zertifikat geladen: {Path.GetFileName(usedPath)} | Subject: {_serverCert.Subject} | Gültig bis: {_serverCert.NotAfter:u}");
|
||
}
|
||
catch
|
||
{
|
||
Logger.LogInformation($"[WS] TLS-Zertifikat geladen: {Path.GetFileName(usedPath)}");
|
||
}
|
||
|
||
return true;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Logger.LogError($"[WS] Zertifikat konnte nicht geladen werden: {ex.Message}");
|
||
_serverCert = null;
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// =========================
|
||
// WS(S)-Server / Broadcast
|
||
// =========================
|
||
|
||
private void StartWebSocket()
|
||
{
|
||
StopWebSocket();
|
||
|
||
try
|
||
{
|
||
if (_useTls)
|
||
{
|
||
if (!TryLoadCertificate(out var _))
|
||
throw new Exception("TLS aktiv, aber kein funktionsfähiges PFX gefunden.");
|
||
}
|
||
|
||
IPAddress ip;
|
||
if (!IPAddress.TryParse(_bindHost, out ip))
|
||
ip = IPAddress.Any;
|
||
|
||
_listener = new TcpListener(ip, _bindPort);
|
||
_listener.Start();
|
||
_serverCts = new CancellationTokenSource();
|
||
_serverRunning = true;
|
||
|
||
var scheme = _useTls ? "wss" : "ws";
|
||
Logger.LogInformation($"[WS] Server lauscht auf {scheme}://{_bindHost}:{_bindPort}{_bindPath}");
|
||
|
||
_mapName = string.IsNullOrEmpty(Server.MapName) ? _mapName : Server.MapName!;
|
||
Broadcast(JsonSerializer.Serialize(new { type = "map", name = _mapName, t = NowMs() }));
|
||
|
||
_acceptTask = Task.Run(async () =>
|
||
{
|
||
var ct = _serverCts!.Token;
|
||
while (!ct.IsCancellationRequested)
|
||
{
|
||
TcpClient? tcp = null;
|
||
try
|
||
{
|
||
tcp = await _listener!.AcceptTcpClientAsync(ct);
|
||
_ = HandleClientAsync(tcp, ct);
|
||
}
|
||
catch (OperationCanceledException) { break; }
|
||
catch (Exception ex)
|
||
{
|
||
Logger.LogWarning($"[WS] Accept-Fehler: {ex.Message}");
|
||
tcp?.Close();
|
||
await Task.Delay(250, ct);
|
||
}
|
||
}
|
||
});
|
||
|
||
_ = Task.Run(async () =>
|
||
{
|
||
var ct = _serverCts!.Token;
|
||
while (!ct.IsCancellationRequested)
|
||
{
|
||
if (_outbox.IsEmpty) _sendSignal.WaitOne(200);
|
||
while (_outbox.TryDequeue(out var msg))
|
||
Broadcast(msg);
|
||
await Task.Delay(1, ct);
|
||
}
|
||
});
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Logger.LogError($"[WS] Start fehlgeschlagen: {ex.Message}");
|
||
StopWebSocket();
|
||
}
|
||
}
|
||
|
||
private void StopWebSocket()
|
||
{
|
||
_serverRunning = false;
|
||
|
||
try { _serverCts?.Cancel(); } catch { }
|
||
|
||
try { _listener?.Stop(); } catch { }
|
||
_listener = null;
|
||
|
||
foreach (var kv in _clients)
|
||
{
|
||
try { kv.Value.Cts.Cancel(); } catch { }
|
||
try { kv.Value.Stream.Close(); } catch { }
|
||
try { kv.Value.Tcp.Close(); } catch { }
|
||
}
|
||
_clients.Clear();
|
||
|
||
_serverCts = null;
|
||
_acceptTask = null;
|
||
}
|
||
|
||
private void Broadcast(string text)
|
||
{
|
||
foreach (var kv in _clients)
|
||
{
|
||
var id = kv.Key;
|
||
var c = kv.Value;
|
||
try
|
||
{
|
||
SendTextFrame(c, text);
|
||
}
|
||
catch
|
||
{
|
||
_clients.TryRemove(id, out _);
|
||
try { c.Cts.Cancel(); } catch { }
|
||
try { c.Stream.Close(); } catch { }
|
||
try { c.Tcp.Close(); } catch { }
|
||
}
|
||
}
|
||
}
|
||
|
||
private async Task HandleClientAsync(TcpClient tcp, CancellationToken serverCt)
|
||
{
|
||
var id = Interlocked.Increment(ref _clientSeq);
|
||
tcp.NoDelay = true;
|
||
|
||
var baseStream = tcp.GetStream();
|
||
baseStream.ReadTimeout = 10000;
|
||
baseStream.WriteTimeout = 10000;
|
||
|
||
Stream stream = baseStream;
|
||
SslStream? ssl = null;
|
||
|
||
try
|
||
{
|
||
if (_useTls)
|
||
{
|
||
ssl = new SslStream(baseStream, leaveInnerStreamOpen: false);
|
||
try
|
||
{
|
||
await ssl.AuthenticateAsServerAsync(
|
||
_serverCert!,
|
||
clientCertificateRequired: false,
|
||
enabledSslProtocols: SslProtocols.Tls13 | SslProtocols.Tls12,
|
||
checkCertificateRevocation: false
|
||
);
|
||
}
|
||
catch (AuthenticationException aex)
|
||
{
|
||
Logger.LogError($"[WS] TLS-Handshake fehlgeschlagen: {aex.Message}. " +
|
||
$"Tipp: Stimmt das PFX und das CertPassword aus {ConfigFileName}?");
|
||
throw;
|
||
}
|
||
stream = ssl;
|
||
}
|
||
|
||
if (!await DoHandshakeAsync(stream, serverCt))
|
||
{
|
||
tcp.Close();
|
||
return;
|
||
}
|
||
|
||
var state = new ClientState { Tcp = tcp, Stream = stream };
|
||
_clients[id] = state;
|
||
|
||
Logger.LogInformation($"[WS] Client #{id} verbunden. Aktive: {_clients.Count}");
|
||
|
||
try
|
||
{
|
||
var nowMs = NowMs();
|
||
SendTextFrame(state, JsonSerializer.Serialize(new { type = "map", name = _mapName, t = nowMs }));
|
||
|
||
foreach (var kv in _activeSmokes)
|
||
{
|
||
var aoe = kv.Value;
|
||
if (aoe.EndAt > DateTimeOffset.UtcNow)
|
||
{
|
||
SendTextFrame(state, JsonSerializer.Serialize(new
|
||
{
|
||
type = "smoke",
|
||
state = "start",
|
||
id = kv.Key,
|
||
t = nowMs,
|
||
endAt = aoe.EndAt.ToUnixTimeMilliseconds(),
|
||
pos = new { x = aoe.X, y = aoe.Y, z = aoe.Z }
|
||
}));
|
||
}
|
||
}
|
||
|
||
foreach (var kv in _activeFires)
|
||
{
|
||
var aoe = kv.Value;
|
||
if (aoe.EndAt > DateTimeOffset.UtcNow)
|
||
{
|
||
SendTextFrame(state, JsonSerializer.Serialize(new
|
||
{
|
||
type = "fire",
|
||
state = "start",
|
||
id = kv.Key,
|
||
t = nowMs,
|
||
endAt = aoe.EndAt.ToUnixTimeMilliseconds(),
|
||
pos = new { x = aoe.X, y = aoe.Y, z = aoe.Z }
|
||
}));
|
||
}
|
||
}
|
||
}
|
||
catch { }
|
||
|
||
await ReceiveLoop(state, serverCt);
|
||
}
|
||
catch (OperationCanceledException) { }
|
||
catch (Exception ex)
|
||
{
|
||
Logger.LogWarning($"[WS] Client #{id} Fehler: {ex.Message}");
|
||
}
|
||
finally
|
||
{
|
||
_clients.TryRemove(id, out _);
|
||
try { stream.Close(); } catch { }
|
||
try { ssl?.Dispose(); } catch { }
|
||
try { baseStream.Close(); } catch { }
|
||
try { tcp.Close(); } catch { }
|
||
Logger.LogInformation($"[WS] Client #{id} getrennt. Aktive: {_clients.Count}");
|
||
}
|
||
}
|
||
|
||
// --- Minimaler WebSocket-Server: Handshake + Frames ---
|
||
|
||
private static async Task<string> ReadHeadersAsync(Stream s, CancellationToken ct)
|
||
{
|
||
var buf = new byte[8192];
|
||
using var ms = new MemoryStream();
|
||
while (true)
|
||
{
|
||
int n = await s.ReadAsync(buf.AsMemory(0, buf.Length), ct);
|
||
if (n <= 0) break;
|
||
ms.Write(buf, 0, n);
|
||
if (ms.Length >= 4)
|
||
{
|
||
var b = ms.GetBuffer();
|
||
for (int i = 3; i < ms.Length; i++)
|
||
{
|
||
if (b[i - 3] == '\r' && b[i - 2] == '\n' && b[i - 1] == '\r' && b[i] == '\n')
|
||
{
|
||
return Encoding.ASCII.GetString(b, 0, i + 1);
|
||
}
|
||
}
|
||
}
|
||
if (ms.Length > 65536) throw new Exception("Header zu groß");
|
||
}
|
||
return Encoding.ASCII.GetString(ms.ToArray());
|
||
}
|
||
|
||
private async Task<bool> DoHandshakeAsync(Stream stream, CancellationToken ct)
|
||
{
|
||
var header = await ReadHeadersAsync(stream, ct);
|
||
if (!header.StartsWith("GET ", StringComparison.OrdinalIgnoreCase))
|
||
return false;
|
||
|
||
var firstLineEnd = header.IndexOf("\r\n", StringComparison.Ordinal);
|
||
var firstLine = firstLineEnd > 0 ? header[..firstLineEnd] : header;
|
||
var parts = firstLine.Split(' ');
|
||
if (parts.Length < 2) return false;
|
||
|
||
var path = parts[1];
|
||
if (!path.StartsWith(_bindPath, StringComparison.Ordinal))
|
||
{
|
||
var notFound = "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n";
|
||
var bytes = Encoding.ASCII.GetBytes(notFound);
|
||
await stream.WriteAsync(bytes, ct);
|
||
await stream.FlushAsync(ct);
|
||
return false;
|
||
}
|
||
|
||
string? wsKey = null;
|
||
foreach (var line in header.Split("\r\n"))
|
||
{
|
||
var idx = line.IndexOf(':');
|
||
if (idx <= 0) continue;
|
||
var name = line[..idx].Trim();
|
||
var value = line[(idx + 1)..].Trim();
|
||
if (name.Equals("Sec-WebSocket-Key", StringComparison.OrdinalIgnoreCase))
|
||
wsKey = value;
|
||
}
|
||
if (string.IsNullOrEmpty(wsKey))
|
||
return false;
|
||
|
||
var accept = ComputeWebSocketAccept(wsKey);
|
||
var resp =
|
||
"HTTP/1.1 101 Switching Protocols\r\n" +
|
||
"Upgrade: websocket\r\n" +
|
||
"Connection: Upgrade\r\n" +
|
||
$"Sec-WebSocket-Accept: {accept}\r\n" +
|
||
"\r\n";
|
||
var respBytes = Encoding.ASCII.GetBytes(resp);
|
||
await stream.WriteAsync(respBytes, ct);
|
||
await stream.FlushAsync(ct);
|
||
return true;
|
||
}
|
||
|
||
private static string ComputeWebSocketAccept(string key)
|
||
{
|
||
const string guid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
||
var bytes = Encoding.ASCII.GetBytes(key + guid);
|
||
var hash = SHA1.HashData(bytes);
|
||
return Convert.ToBase64String(hash);
|
||
}
|
||
|
||
private async Task ReceiveLoop(ClientState state, CancellationToken serverCt)
|
||
{
|
||
var s = state.Stream;
|
||
var buf2 = new byte[2];
|
||
|
||
while (!serverCt.IsCancellationRequested && !state.Cts.IsCancellationRequested)
|
||
{
|
||
int r = await ReadExactAsync(s, buf2, 0, 2, serverCt);
|
||
if (r == 0) break;
|
||
|
||
byte b0 = buf2[0]; // FIN + opcode
|
||
byte b1 = buf2[1]; // MASK + payload len
|
||
|
||
byte opcode = (byte)(b0 & 0x0F);
|
||
bool masked = (b1 & 0x80) != 0;
|
||
ulong len = (ulong)(b1 & 0x7F);
|
||
|
||
if (len == 126)
|
||
{
|
||
r = await ReadExactAsync(s, buf2, 0, 2, serverCt);
|
||
if (r == 0) break;
|
||
len = (ulong)IPAddress.NetworkToHostOrder(BitConverter.ToInt16(buf2, 0));
|
||
}
|
||
else if (len == 127)
|
||
{
|
||
var b8 = new byte[8];
|
||
r = await ReadExactAsync(s, b8, 0, 8, serverCt);
|
||
if (r == 0) break;
|
||
if (BitConverter.IsLittleEndian) Array.Reverse(b8);
|
||
len = BitConverter.ToUInt64(b8, 0);
|
||
}
|
||
|
||
byte[] mask = Array.Empty<byte>();
|
||
if (masked)
|
||
{
|
||
mask = new byte[4];
|
||
r = await ReadExactAsync(s, mask, 0, 4, serverCt);
|
||
if (r == 0) break;
|
||
}
|
||
|
||
byte[] payload = Array.Empty<byte>();
|
||
if (len > 0)
|
||
{
|
||
payload = new byte[len];
|
||
r = await ReadExactAsync(s, payload, 0, (int)len, serverCt);
|
||
if (r == 0) break;
|
||
|
||
if (masked)
|
||
for (int i = 0; i < payload.Length; i++)
|
||
payload[i] = (byte)(payload[i] ^ mask[i % 4]);
|
||
}
|
||
|
||
if (opcode == 0x8) // Close
|
||
{
|
||
await SendCloseFrame(state);
|
||
break;
|
||
}
|
||
else if (opcode == 0x9) // Ping -> Pong
|
||
{
|
||
await SendPongFrame(state, payload);
|
||
}
|
||
// Textframes (0x1) werden ignoriert
|
||
}
|
||
}
|
||
|
||
private static async Task<int> ReadExactAsync(Stream s, byte[] buf, int off, int len, CancellationToken ct)
|
||
{
|
||
int got = 0;
|
||
while (got < len)
|
||
{
|
||
int n = await s.ReadAsync(buf.AsMemory(off + got, len - got), ct);
|
||
if (n <= 0) return got;
|
||
got += n;
|
||
}
|
||
return got;
|
||
}
|
||
|
||
private void SendTextFrame(ClientState c, string text)
|
||
{
|
||
var payload = Encoding.UTF8.GetBytes(text);
|
||
using var ms = new MemoryStream(capacity: 2 + payload.Length + 10);
|
||
ms.WriteByte(0x81); // FIN + Text
|
||
|
||
if (payload.Length <= 125)
|
||
{
|
||
ms.WriteByte((byte)payload.Length);
|
||
}
|
||
else if (payload.Length <= ushort.MaxValue)
|
||
{
|
||
ms.WriteByte(126);
|
||
var lenBytes = BitConverter.GetBytes((ushort)payload.Length);
|
||
if (BitConverter.IsLittleEndian) Array.Reverse(lenBytes);
|
||
ms.Write(lenBytes, 0, 2);
|
||
}
|
||
else
|
||
{
|
||
ms.WriteByte(127);
|
||
var lenBytes = BitConverter.GetBytes((ulong)payload.Length);
|
||
if (BitConverter.IsLittleEndian) Array.Reverse(lenBytes);
|
||
ms.Write(lenBytes, 0, 8);
|
||
}
|
||
|
||
ms.Write(payload, 0, payload.Length);
|
||
|
||
lock (c.SendLock)
|
||
{
|
||
var buf = ms.GetBuffer();
|
||
c.Stream.Write(buf, 0, (int)ms.Length);
|
||
c.Stream.Flush();
|
||
}
|
||
}
|
||
|
||
private static Task SendPongFrame(ClientState c, byte[] pingPayload)
|
||
{
|
||
var header = new MemoryStream(2 + pingPayload.Length);
|
||
header.WriteByte(0x8A); // FIN + Pong
|
||
if (pingPayload.Length <= 125)
|
||
{
|
||
header.WriteByte((byte)pingPayload.Length);
|
||
}
|
||
else if (pingPayload.Length <= ushort.MaxValue)
|
||
{
|
||
header.WriteByte(126);
|
||
var lenBytes = BitConverter.GetBytes((ushort)pingPayload.Length);
|
||
if (BitConverter.IsLittleEndian) Array.Reverse(lenBytes);
|
||
header.Write(lenBytes, 0, 2);
|
||
}
|
||
else
|
||
{
|
||
header.WriteByte(127);
|
||
var lenBytes = BitConverter.GetBytes((ulong)pingPayload.Length);
|
||
if (BitConverter.IsLittleEndian) Array.Reverse(lenBytes);
|
||
header.Write(lenBytes, 0, 8);
|
||
}
|
||
var buf = header.ToArray();
|
||
lock (c.SendLock)
|
||
{
|
||
c.Stream.Write(buf, 0, buf.Length);
|
||
if (pingPayload.Length > 0) c.Stream.Write(pingPayload, 0, pingPayload.Length);
|
||
c.Stream.Flush();
|
||
}
|
||
return Task.CompletedTask;
|
||
}
|
||
|
||
private static Task SendCloseFrame(ClientState c)
|
||
{
|
||
var frame = new byte[] { 0x88, 0x00 }; // Close, no payload
|
||
lock (c.SendLock)
|
||
{
|
||
c.Stream.Write(frame, 0, frame.Length);
|
||
c.Stream.Flush();
|
||
}
|
||
return Task.CompletedTask;
|
||
}
|
||
|
||
// --- Hilfsklasse für Referenz-HashSet ---
|
||
private sealed class ReferenceEqualityComparer : IEqualityComparer<object>
|
||
{
|
||
public static readonly ReferenceEqualityComparer Instance = new();
|
||
public new bool Equals(object x, object y) => ReferenceEquals(x, y);
|
||
public int GetHashCode(object obj) => System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(obj);
|
||
}
|
||
}
|