using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Net; using System.Text; using System.Threading; using Newtonsoft.Json; using Oxide.Core; using Oxide.Core.Plugins; /* * MajestatTopWeb 1.2.0 * ===================== * Web dashboard dla MajestatTopCore. * WYMAGA: MajestatTopCore 3.0.1+ * * TRYBY: "json" | "http" | "both" * ENDPOINTY: * GET / — strona glowna z rankingami * GET /player/{steamid} — profil gracza * GET /api/server — JSON info o serwerze * GET /api/tabs — JSON lista zakladek * GET /api/all — JSON wszystkie dane * GET /api/top/{tab} — JSON top dla zakladki * GET /api/top/{tab}/fame — JSON Hall of Fame * GET /api/player/{id} — JSON profil gracza */ namespace Oxide.Plugins { [Info("MajestatTopWeb", "Wo0t", "1.2.1")] [Description("Web dashboard for MajestatTopCore.")] class MajestatTopWeb : RustPlugin { [PluginReference] Plugin MajestatTopCore; private void MLog(int level, string msg) {{ int effective = MajestatTopCore != null ? (int)(MajestatTopCore.Call("API_GetLogLevel") ?? 1) : 1; if (level < effective) return; // Podczas AutoReload wycisz komunikaty systemowe (1) — Core drukuje summary if (level == 1 && MajestatTopCore != null) { var ar = MajestatTopCore.Call("API_IsAutoReloading"); if (ar is bool b && b) return; } if (level >= 2) PrintWarning(msg); else Puts(msg); }} [PluginReference] Plugin MajestatTopInfo; private const string PluginVersion = "1.2.1"; private static readonly string PluginUpdateUrl = System.Text.Encoding.UTF8.GetString(System.Convert.FromBase64String("aHR0cDovL2Rvd25sb2FkLmtvbGRyaXguY29tL3J1c3QvcGx1Z2lucy9NYWplc3RhdFRvcFdlYi92ZXJzaW9uLmpzb24=")); private ConfigData _cfg; private HttpListener _listener; private Thread _thread; private bool _running = false; // ── Chat fields ────────────────────────────────────────────── private class ChatMessage { [JsonProperty("steamId")] public string SteamId; [JsonProperty("nick")] public string Nick; [JsonProperty("text")] public string Text; [JsonProperty("channel")] public string Channel = "global"; [JsonProperty("source")] public string Source; [JsonProperty("isAdmin")] public bool IsAdmin; [JsonProperty("timestamp")] public long Timestamp; [JsonProperty("teamId")] public ulong TeamId; } private ulong GetPlayerTeamId(string steamId) { if (string.IsNullOrEmpty(steamId)) return 0; if (!ulong.TryParse(steamId, out ulong uid)) return 0; var p = BasePlayer.FindByID(uid); return p?.currentTeam ?? 0; } private class ConsoleEntry { [JsonProperty("text")] public string Text; [JsonProperty("source")] public string Source; [JsonProperty("timestamp")] public long Timestamp; } private class Session { public string SteamId; public string Nick; public bool IsAdmin; public bool HasPlayed; public DateTime Expires; } private const int MaxHistory = 200; private const int MaxConsoleLogs = 500; private const string ChatDataFile = "MajestatTopWeb_chat"; private List _chatHistory = new List(); private List _chatClients = new List(); private Dictionary _chatClientSteam = new Dictionary(); private const int MAX_SSE_CLIENTS = 50; private string _publicIp = ""; // auto-fetched from ipify.org // limit jednoczesnych SSE connections private List _consoleLogs = new List(); private List _consoleClients = new List(); private Dictionary _sessions = new Dictionary(); private readonly object _chatLock = new object(); private readonly object _consoleLock = new object(); private readonly object _oxideCallLock = new object(); private Dictionary _rateLimitMap = new Dictionary(); private Dictionary _pendingRet = new Dictionary(); // ── Map module ─────────────────────────────────────────────── private bool _mapModuleReady = false; private string _mapModuleVersion = ""; [PluginReference] Plugin MajestatTopMap; private int _updateIntervalMinutes = 60; // ── Config ─────────────────────────────────────────────────── private class ConfigData { [JsonProperty("Web Mode (json, http, both)")] public string WebMode = "both"; [JsonProperty("HTTP Port")] public int Port = 28020; [JsonProperty("API Key (empty = no auth)")] public string ApiKey = ""; [JsonProperty("JSON Export Interval (seconds)")] public int JsonExportInterval = 30; [JsonProperty("Leaderboard rows per tab")] public int LeaderboardRows = 10; [JsonProperty("Check for updates on startup")] public bool CheckUpdates = true; [JsonProperty("Language (pl / en) - affects table headers")] public string Language = "pl"; [JsonProperty("Wipe Schedule (e.g. Co tydzien w czwartek)")] public string WipeSchedule = ""; [JsonProperty("Server Type (e.g. Vanilla, Modded, PvE)")] public string ServerType = "Vanilla"; [JsonProperty("Table Number Format (none, K, M, KM)")] public string TableNumberFormat = "KM"; [JsonProperty("Table Number Decimal Places")] public int TableNumberDecimals = 1; [JsonProperty("Profile XP Number Format (none, K, M, KM)")] public string ProfileXpFormat = "KM"; [JsonProperty("Profile XP Decimal Places")] public int ProfileXpDecimals = 2; // ── Chat ───────────────────────────────────────────────── [JsonProperty("Chat Auth Mode (steam, played, readonly, open)")] public string ChatAuthMode = "steam"; [JsonProperty("Chat History Lines (0=none, -1=separate tab)")] public int ChatHistory = 50; [JsonProperty("Chat cache (ram, file)")] public string ChatCache = "ram"; [JsonProperty("Chat cache file - max lines (when cache=file)")] public int ChatCacheFileMaxLines = 1000; [JsonProperty("Chat Web prefix in game")] public string ChatWebPrefix = "[WEB]"; [JsonProperty("Chat Web prefix color (hex)")] public string ChatWebPrefixColor = "#4a90d9"; [JsonProperty("Chat Session timeout (minutes)")] public int ChatSessionTimeout = 120; [JsonProperty("Chat Max message length")] public int ChatMaxMessageLength = 200; [JsonProperty("Chat Rate limit (messages per minute)")] public int ChatRateLimit = 10; } protected override void LoadDefaultConfig() { _cfg = new ConfigData(); SaveConfig(); } protected override void LoadConfig() { base.LoadConfig(); try { _cfg = Config.ReadObject() ?? new ConfigData(); } catch { _cfg = new ConfigData(); } SaveConfig(); } protected override void SaveConfig() => Config.WriteObject(_cfg); // ── Init / Unload ──────────────────────────────────────────── void OnServerInitialized() { LoadChatHistory(); if (ChatFileMode) timer.Every(60f, SaveChatHistory); StartUpdateChecker(); // Auto-detect public IP via ipify.org (used by the Connect button) webrequest.Enqueue("https://api.ipify.org", null, (code, body) => { if (code == 200 && !string.IsNullOrEmpty(body)) { _publicIp = body.Trim(); MLog(1, $"[Web] Server public IP: {_publicIp}"); } }, this); if (MajestatTopCore == null) { PrintWarning("MajestatTopCore not loaded!"); return; } if (!CheckMinVersion(MajestatTopCore.Version, 3, 2, 0)) { PrintError($"MajestatTopWeb wymaga MajestatTopCore 3.2.0+. Zainstalowana: {MajestatTopCore.Version}. Zaktualizuj Core."); return; } Start(); } private bool CheckMinVersion(Oxide.Core.VersionNumber v, int major, int minor, int patch) => v.Major > major || (v.Major == major && (v.Minor > minor || (v.Minor == minor && v.Patch >= patch))); void OnPluginLoaded(Plugin p) { if (p?.Name == "MajestatTopCore") { if (!CheckMinVersion(p.Version, 3, 2, 0)) { PrintError($"MajestatTopCore {p.Version} zbyt stary (wymagany 3.2.0+)"); return; } Start(); } if (p?.Name == "MajestatTopMap") CheckMapModule(); } void OnPluginUnloaded(Plugin p) { if (p?.Name == "MajestatTopCore") StopHttp(); if (p?.Name == "MajestatTopMap") { _mapModuleReady = false; _mapModuleVersion = ""; } } void Unload() { SaveChatHistory(); StopHttp(); MajestatTopCore?.Call("API_UnregisterModule", "MajestatTopWeb"); lock (_chatLock) { foreach (var r in _chatClients) try{r.Close();}catch{} _chatClients.Clear(); } lock (_consoleLock) { foreach (var r in _consoleClients) try{r.Close();}catch{} _consoleClients.Clear(); } } // ── Map Module API ─────────────────────────────────────────── [HookMethod("API_RegisterMapModule")] public bool API_RegisterMapModule(Dictionary info) { _mapModuleVersion = info?.ContainsKey("version") == true ? info["version"]?.ToString() : "?"; _mapModuleReady = true; MLog(1, $"MajestatTopMap v{_mapModuleVersion} zarejestrowany."); return true; } [HookMethod("API_UnregisterMapModule")] public void API_UnregisterMapModule() { _mapModuleReady = false; _mapModuleVersion = ""; MLog(1, "MajestatTopMap wyrejestrowany."); } private void CheckMapModule() { if (MajestatTopMap != null) MajestatTopMap.Call("API_RegisterInWeb"); } // ── Chat z Core przez Oxide hook ───────────────────────────── // ── Trwałość historii czatu (cfg: "Chat cache" = ram|file) ──── private bool ChatFileMode => (_cfg?.ChatCache ?? "ram").Trim().ToLower() == "file"; private int ChatCap => ChatFileMode ? Math.Max(1, _cfg.ChatCacheFileMaxLines) : MaxHistory; private void LoadChatHistory() { if (!ChatFileMode) return; try { var loaded = Interface.Oxide.DataFileSystem.ReadObject>(ChatDataFile); if (loaded != null) { lock (_chatLock) { _chatHistory = loaded; while (_chatHistory.Count > ChatCap) _chatHistory.RemoveAt(0); } MLog(1, $"[Web] Historia czatu wczytana z pliku: {_chatHistory.Count} linii."); } } catch (Exception e) { PrintWarning($"[Web] Failed to load chat history: {e.Message}"); } } private void SaveChatHistory() { if (!ChatFileMode) return; try { List snapshot; lock (_chatLock) { snapshot = new List(_chatHistory); } Interface.Oxide.DataFileSystem.WriteObject(ChatDataFile, snapshot); } catch (Exception e) { PrintWarning($"[Web] Failed to save chat history: {e.Message}"); } } void OnMajestatChatMessage(string steamId, string nick, string text, string channel, string source, bool isAdmin, long timestamp) { // Filtruj wiadomości serwerowe o rozdawaniu itemów adminów (oxide.give) // Format: "Nick gave themselves X x Item" lub "Nick gave PlayerName X x Item" if (source == "server" && !string.IsNullOrEmpty(text)) { string tl = text.ToLower(); if (tl.Contains(" gave ") && (tl.Contains(" x ") || tl.Contains(" themselves"))) return; } ulong teamId = (channel == "team" || channel == "clan") ? GetPlayerTeamId(steamId) : 0; var cm = new ChatMessage { SteamId = steamId, Nick = nick, Text = text, Channel = channel, Source = source, IsAdmin = isAdmin, Timestamp = timestamp, TeamId = teamId }; lock (_chatLock) { _chatHistory.Add(cm); while (_chatHistory.Count > ChatCap) _chatHistory.RemoveAt(0); // Team/clan: wyślij TYLKO do zalogowanych graczy z tej samej drużyny if (channel == "team" || channel == "clan") { string cmJson = JsonConvert.SerializeObject(cm); var dead = new List(); foreach (var r in _chatClients) { // steamId tego połączenia → aktualna drużyna; pomiń jeśli inna/brak string sid = _chatClientSteam.TryGetValue(r, out var s) ? s : ""; if (string.IsNullOrEmpty(sid) || teamId == 0 || GetPlayerTeamId(sid) != teamId) continue; byte[] data = System.Text.Encoding.UTF8.GetBytes("data: " + cmJson + "\n\n"); try { r.OutputStream.Write(data, 0, data.Length); r.OutputStream.Flush(); } catch { dead.Add(r); } } foreach (var d in dead) { try { d.Close(); } catch { } _chatClients.Remove(d); _chatClientSteam.Remove(d); } } else { PushSse(_chatClients, JsonConvert.SerializeObject(cm), _chatLock); } } } private void SubscribeCoreChat() { /* hooki Oxide obsługują subskrypcję automatycznie */ } void OnPluginOutput(Plugin plugin, string message) { string src = plugin != null && plugin.Name.StartsWith("MajestatTop") ? "majestat" : "server"; string prefix = plugin != null ? "[" + plugin.Name + "] " : "[Server] "; AddConsoleLog(prefix + message, src); } private void AddConsoleLog(string text, string source) { lock (_consoleLock) { _consoleLogs.Add(new ConsoleEntry { Text = text, Source = source, Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds() }); if (_consoleLogs.Count > MaxConsoleLogs) _consoleLogs.RemoveAt(0); PushSse(_consoleClients, JsonConvert.SerializeObject(new { text, source, timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds() }), _consoleLock); } } private void PushSse(List clients, string json, object lockObj) { byte[] buf = Encoding.UTF8.GetBytes("data: " + json + "\n\n"); var dead = new List(); foreach (var c in clients) try { c.OutputStream.Write(buf, 0, buf.Length); c.OutputStream.Flush(); } catch { dead.Add(c); } foreach (var d in dead) clients.Remove(d); } private void Start() { string mode = (_cfg.WebMode ?? "both").ToLower(); if (mode == "http" || mode == "both") StartHttp(); if (mode == "json" || mode == "both") { ExportJson(); timer.Every(_cfg.JsonExportInterval, ExportJson); } SubscribeCoreChat(); // Zarejestruj Web w Core MajestatTopCore?.Call("API_RegisterModule", new Dictionary { ["name"] = "MajestatTopWeb", ["version"] = PluginVersion, ["author"] = "Wo0t", ["description"] = "Web dashboard & chat" }); MajestatTopCore?.Call("API_SetModuleExtra", "MajestatTopWeb", $"HTTP :{_cfg.Port} [{_cfg.WebMode.ToUpper()}]"); MLog(1, "Started. Mode: " + _cfg.WebMode.ToUpper()); } // ── HTTP Server ────────────────────────────────────────────── private void StartHttp() { if (_running) return; try { _listener = new HttpListener(); _listener.Prefixes.Add("http://*:" + _cfg.Port + "/"); _listener.Start(); _running = true; _thread = new Thread(HttpLoop) { IsBackground = true }; _thread.Start(); MLog(1, "HTTP serwer na porcie " + _cfg.Port + "."); } catch (Exception ex) { PrintError("HTTP start error: " + ex.Message); } } private void StopHttp() { if (!_running) return; _running = false; try { _listener?.Stop(); _listener?.Close(); } catch { } _listener = null; // Poczekaj max 2s na zakończenie wątku głównego if (_thread != null && _thread.IsAlive) _thread.Join(2000); _thread = null; } private void HttpLoop() { while (_running) { try { var ctx = _listener.GetContext(); string p = ctx.Request.Url.AbsolutePath.ToLower(); if (p == "/api/chat/stream" || p == "/api/console/stream" || p == "/api/map/stream") new Thread(() => HandleSse(ctx)) { IsBackground = true }.Start(); else ThreadPool.QueueUserWorkItem(_ => HandleRequest(ctx)); } catch { if (_running) Thread.Sleep(100); } } } private void HandleSse(HttpListenerContext ctx) { var resp = ctx.Response; string urlPath = ctx.Request.Url.AbsolutePath.ToLower(); bool isConsole = urlPath.Contains("console"); bool isMap = urlPath.Contains("/api/map/stream"); try { resp.ContentType = "text/event-stream; charset=utf-8"; resp.AddHeader("Cache-Control", "no-cache"); resp.AddHeader("Access-Control-Allow-Origin", "*"); resp.SendChunked = true; if (isMap) { // Deleguj SSE mapy do MajestatTopMap if (_mapModuleReady && MajestatTopMap != null) { var session = GetSession(ctx.Request); string token = ctx.Request.QueryString["token"] ?? ""; if (session == null && !string.IsNullOrEmpty(token)) _sessions.TryGetValue(token, out session); string sid = session?.SteamId ?? ""; bool admin = session?.IsAdmin ?? false; MajestatTopMap.Call("API_HandleMapSse", resp, sid, admin); } else { resp.StatusCode = 503; resp.Close(); } return; } else if (isConsole) { var session = GetSession(ctx.Request); if (session == null) { string qt = ctx.Request.QueryString["token"] ?? ""; if (!string.IsNullOrEmpty(qt) && _sessions.TryGetValue(qt, out Session qs) && qs.Expires > DateTime.UtcNow) session = qs; } if (session == null || !session.IsAdmin) { resp.StatusCode = 403; resp.Close(); return; } lock (_consoleLock) { _consoleClients.Add(resp); } } else { var session = GetSession(ctx.Request); if (session == null) { string qt = ctx.Request.QueryString["token"] ?? ""; if (!string.IsNullOrEmpty(qt) && _sessions.TryGetValue(qt, out Session qs) && qs.Expires > DateTime.UtcNow) session = qs; } lock (_chatLock) { if (_chatClients.Count >= MAX_SSE_CLIENTS) { resp.StatusCode = 503; resp.Close(); return; } _chatClients.Add(resp); _chatClientSteam[resp] = session?.SteamId ?? ""; } } while (_running) { Thread.Sleep(5000); try { byte[] hb = Encoding.UTF8.GetBytes(": keep-alive\n\n"); resp.OutputStream.Write(hb, 0, hb.Length); resp.OutputStream.Flush(); } catch { break; } } } catch { } finally { if (!isMap) { if (isConsole) lock (_consoleLock) { _consoleClients.Remove(resp); } else lock (_chatLock) { _chatClients.Remove(resp); _chatClientSteam.Remove(resp); } } try { resp.Close(); } catch { } } } private void HandleRequest(HttpListenerContext ctx) { var req = ctx.Request; var resp = ctx.Response; try { resp.AddHeader("Access-Control-Allow-Origin", "*"); resp.AddHeader("Access-Control-Allow-Headers", "X-Api-Key,Content-Type,X-Session-Token"); if (req.HttpMethod == "OPTIONS") { resp.StatusCode = 204; resp.Close(); return; } // POST tylko dla send bool isPost = req.HttpMethod == "POST"; if (!isPost && req.HttpMethod != "GET") { SendJson(resp, 405, new { error = "Method not allowed" }); return; } if (!string.IsNullOrEmpty(_cfg.ApiKey)) { string k = req.Headers["X-Api-Key"] ?? ""; if (k != _cfg.ApiKey) { SendJson(resp, 401, new { error = "Unauthorized" }); return; } } string path = req.Url.AbsolutePath.TrimEnd('/').ToLower(); if (path == "") path = "/"; string[] pts = path.Split('/'); // Strona glowna if (path == "/" || path == "/index" || path == "/index.html") { SendHtml(resp, BuildMainPage()); return; } // Profil gracza if (pts.Length == 3 && pts[1] == "player") { string html = BuildPlayerPage(pts[2]); if (html == null) { SendJson(resp, 404, new { error = "Player not found" }); return; } SendHtml(resp, html); return; } // Avatar gracza if (pts.Length == 3 && pts[1] == "avatar") { SendAvatar(ctx, pts[2]); return; } // ── Chat & Auth routes ──────────────────────────────── if (path == "/auth/steam") { HandleSteamAuthStart(req, resp); return; } if (path == "/auth/steam/callback") { HandleSteamCallback(req, resp); return; } if (path == "/auth/logout") { HandleLogout(req, resp); return; } if (path == "/api/auth/me") { HandleAuthMe(req, resp); return; } if (path == "/api/chat/messages") { HandleGetMessages(req, resp); return; } if (path == "/api/xpinfo") { var xp = MajestatTopCore?.Call("API_GetXpInfo") as Dictionary; SendJson(resp, 200, xp ?? new Dictionary()); return; } if (path == "/api/gameinfo") { string giJson = MajestatTopInfo?.Call("API_GetInfoData") as string; if (string.IsNullOrEmpty(giJson)) { SendJson(resp, 503, new { error = "MajestatTopInfo not loaded" }); return; } // Zwróć surowy JSON bezpośrednio resp.StatusCode = 200; resp.ContentType = "application/json; charset=utf-8"; byte[] giBuf = Encoding.UTF8.GetBytes(giJson); resp.ContentLength64 = giBuf.Length; resp.OutputStream.Write(giBuf, 0, giBuf.Length); resp.OutputStream.Close(); return; } if (path == "/api/chat/send" && isPost){ HandleSendMessage(req, resp); return; } if (path == "/api/console/logs") { HandleGetLogs(req, resp); return; } // ── Map routes ──────────────────────────────────────── // /api/map/img — serwuj PNG bezpośrednio (plik z dysku przez Map plugin) if (path == "/api/map/img" && _mapModuleReady && MajestatTopMap != null) { string imgPath = MajestatTopMap.Call("API_GetMapImagePath") as string ?? ""; if (!string.IsNullOrEmpty(imgPath) && System.IO.File.Exists(imgPath)) { try { byte[] bytes = System.IO.File.ReadAllBytes(imgPath); resp.ContentType = "image/png"; resp.ContentLength64 = bytes.Length; resp.AddHeader("Cache-Control", "public, max-age=3600"); resp.OutputStream.Write(bytes, 0, bytes.Length); resp.Close(); } catch { SendJson(resp, 500, new { error = "read error" }); } } else SendJson(resp, 404, new { error = "map not ready" }); return; } // Pozostałe /api/map/* — deleguj do Map plugin przez JSON if (path.StartsWith("/api/map/") && _mapModuleReady && MajestatTopMap != null) { var session = GetSession(req); // Fallback: token z query param (dla kompatybilności z proxy) if (session == null) { string qt = req.QueryString["token"] ?? ""; if (!string.IsNullOrEmpty(qt) && _sessions.TryGetValue(qt, out Session qs) && qs.Expires > DateTime.UtcNow) session = qs; } string sid = session?.SteamId ?? ""; bool admin = session?.IsAdmin ?? false; string json; lock (_oxideCallLock) json = MajestatTopMap.Call("API_HandleMapJson", path, sid, admin) as string; if (json != null) { resp.StatusCode = 200; resp.ContentType = "application/json; charset=utf-8"; byte[] buf = Encoding.UTF8.GetBytes(json); resp.ContentLength64 = buf.Length; resp.OutputStream.Write(buf, 0, buf.Length); resp.OutputStream.Close(); return; } } // API if (path == "/api/server") { SendJson(resp, 200, BuildServerInfo()); return; } if (path == "/api/tabs") { SendJson(resp, 200, BuildTabs()); return; } if (path == "/api/all") { SendJson(resp, 200, BuildAll()); return; } if (pts.Length >= 4 && pts[1] == "api" && pts[2] == "top") { string tabId = pts[3]; bool fame = pts.Length >= 5 && pts[4] == "fame"; var data = BuildLeaderboard(tabId, fame); if (data == null) { SendJson(resp, 404, new { error = "Tab not found" }); return; } SendJson(resp, 200, data); return; } if (pts.Length == 4 && pts[1] == "api" && pts[2] == "player") { var data = BuildPlayerProfile(pts[3]); if (data == null) { SendJson(resp, 404, new { error = "Player not found" }); return; } SendJson(resp, 200, data); return; } SendJson(resp, 404, new { error = "Not found" }); } catch (Exception ex) { try { SendJson(resp, 500, new { error = ex.Message }); } catch { } } } private void SendJson(HttpListenerResponse resp, int code, object data) { resp.StatusCode = code; resp.ContentType = "application/json; charset=utf-8"; byte[] buf = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(data, Formatting.Indented)); resp.ContentLength64 = buf.Length; resp.OutputStream.Write(buf, 0, buf.Length); resp.OutputStream.Close(); } private void SendHtml(HttpListenerResponse resp, string html) { resp.StatusCode = 200; resp.ContentType = "text/html; charset=utf-8"; byte[] buf = Encoding.UTF8.GetBytes(html); resp.ContentLength64 = buf.Length; resp.OutputStream.Write(buf, 0, buf.Length); resp.OutputStream.Close(); } // ── JSON Export ────────────────────────────────────────────── private void ExportJson() { if (MajestatTopCore == null) return; try { string json = JsonConvert.SerializeObject(BuildAll(), Formatting.Indented); string path = Path.Combine(Interface.Oxide.DataDirectory, "MajestatTopWeb.json"); File.WriteAllText(path, json, Encoding.UTF8); } catch (Exception ex) { PrintError("JSON export error: " + ex.Message); } } // ── Data Builders ──────────────────────────────────────────── private object BuildServerInfo() { var info = MajestatTopCore?.Call("API_GetServerInfo") as Dictionary; if (info == null) return new { error = "Core unavailable" }; int upSec = info.ContainsKey("uptime") ? Convert.ToInt32(info["uptime"]) : 0; info["uptimeFormatted"] = (upSec / 3600) + "h " + ((upSec % 3600) / 60) + "m"; info["timestamp"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); info["timestampUtc"] = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss"); return info; } private object BuildTabs() => MajestatTopCore?.Call("API_GetTabs"); private object BuildLeaderboard(string tabId, bool fame) { var entries = MajestatTopCore?.Call("API_GetLeaderboard", tabId, fame, _cfg.LeaderboardRows) as List>; if (entries == null) return null; return new { tab = tabId, fame, count = entries.Count, updated = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), entries }; } private object BuildPlayerProfile(string steamId) { var stats = MajestatTopCore?.Call("API_GetAllStats", steamId, false) as Dictionary; var fame = MajestatTopCore?.Call("API_GetAllStats", steamId, true) as Dictionary; if (stats == null) return null; var pl = BasePlayer.FindByID(ulong.Parse(steamId)); return new { steamId, name = pl?.displayName ?? steamId, online = pl != null, stats, fameStats = fame, timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds() }; } private object BuildAll() { var tabs = MajestatTopCore?.Call("API_GetTabs") as List>; var lb = new Dictionary(); var fm = new Dictionary(); if (tabs != null) foreach (var t in tabs) { string id = t["id"].ToString(); lb[id] = BuildLeaderboard(id, false); fm[id] = BuildLeaderboard(id, true); } return new { server = BuildServerInfo(), tabs, leaderboards = lb, fame = fm, timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds() }; } // ── CSS Shared ─────────────────────────────────────────────── private string SharedCss() { return ":root{--bg:#0d0d0d;--bg2:#161616;--bg3:#1e1e1e;--border:#2a2a2a;--accent:#c8572a;--accent2:#e07840;--text:#d4c9b8;--text2:#8a8070;--green:#4a9e5c;--gold:#c8a84b;--blue:#4a7ec8;}\n" + "*{box-sizing:border-box;margin:0;padding:0;}\n" + "body{background:var(--bg);color:var(--text);font-family:'Segoe UI',sans-serif;font-size:14px;}\n" + "a{color:var(--accent2);text-decoration:none;}a:hover{text-decoration:underline;}\n" + "header{background:var(--bg2);border-bottom:2px solid var(--accent);padding:14px 24px;display:flex;align-items:center;gap:16px;justify-content:space-between;flex-wrap:nowrap;overflow:hidden;position:relative;}\n" + "header h1{font-size:18px;color:var(--accent2);white-space:nowrap;flex-shrink:0;}\n" + ".back{color:var(--text2);font-size:13px;}\n" + "main{padding:24px;max-width:1200px;margin:0 auto;}\n" + /* tabs */ ".tabs-bar{background:var(--bg2);border-bottom:1px solid var(--border);padding:0 24px;display:flex;gap:4px;align-items:stretch;}\n" + ".tab-btn{background:transparent;border:none;border-bottom:3px solid transparent;color:var(--text2);padding:12px 20px;cursor:pointer;font-size:13px;font-weight:bold;letter-spacing:1px;transition:color .2s,border-color .2s;}\n" + ".tab-btn:hover{color:var(--text);border-color:var(--border);}\n" + ".tab-btn.active{color:var(--accent2);border-color:var(--accent);}\n" + ".tab-content{display:none;}.tab-content.active{display:block;}\n" + ".tabs-bar-right{margin-left:auto;display:flex;align-items:center;padding:0 4px;gap:6px;font-size:12px;color:var(--text2);white-space:nowrap;}\n" + ".tabs-bar-right .ub-val{font-size:15px;font-weight:bold;color:var(--accent2);margin-left:4px;}\n" + ".header-auth{display:flex;align-items:center;gap:8px;flex-shrink:0;}\n" + ".btn-header-login{background:var(--accent);color:#fff;border:none;padding:6px 14px;border-radius:4px;font-size:12px;font-weight:bold;cursor:pointer;white-space:nowrap;}\n" + ".btn-header-login:hover{background:var(--accent2);}\n" + ".header-user{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text2);}\n" + ".header-user img{width:22px;height:22px;border-radius:3px;border:1px solid var(--border);}\n" + ".header-user .hn{color:var(--text);font-weight:600;max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}\n" + ".header-user .hn{transition:color .15s;cursor:pointer;}\n" + ".header-user .hn:hover{color:var(--accent2) !important;}\n" + ".btn-header-logout{background:transparent;border:1px solid var(--border);color:var(--text2);padding:4px 10px;border-radius:4px;font-size:11px;cursor:pointer;}\n" + ".btn-header-logout:hover{border-color:var(--text2);color:var(--text);}\n" + /* section */ ".section{background:var(--bg2);border:1px solid var(--border);border-radius:8px;margin-bottom:24px;overflow:hidden;}\n" + ".section-title{background:var(--bg3);border-bottom:1px solid var(--border);padding:12px 20px;font-size:12px;font-weight:bold;color:var(--text2);text-transform:uppercase;letter-spacing:1px;}\n" + ".section-title.fame{color:var(--gold);}\n" + /* table */ ".table-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch;}\n" + "table{width:100%;border-collapse:collapse;}\n" + "th{background:var(--bg3);color:var(--text2);font-size:11px;text-transform:uppercase;letter-spacing:1px;padding:10px 14px;text-align:center;border-bottom:1px solid var(--border);white-space:normal;line-height:1.3;}\n" + "th:first-child{text-align:center;}th:nth-child(2){text-align:left;}\n" + "th.xp{color:var(--gold);}\n" + "td{padding:9px 14px;border-bottom:1px solid var(--border);color:var(--text);text-align:center;}\n" + "td:first-child{text-align:center;}td:nth-child(2){text-align:left;}\n" + "tr:last-child td{border-bottom:none;}\n" + "tr:hover td{background:rgba(255,255,255,0.03);}\n" + ".rank{color:var(--text2);font-size:12px;}\n" + ".rank-1{color:#f0c040;font-weight:bold;}.rank-2{color:#b0b0b0;font-weight:bold;}.rank-3{color:#c07040;font-weight:bold;}\n" + ".player-name{font-weight:500;}\n" + ".val-xp{color:var(--gold);font-weight:bold;}.val-total{color:var(--accent2);font-weight:bold;}\n" + ".val-time{color:var(--blue);}.val-num{color:var(--text);}\n" + ".no-data{padding:24px;text-align:center;color:var(--text2);}\n" + /* header stats */ ".server-stats{display:flex;gap:16px;flex-wrap:wrap;}\n" + ".stat-badge{background:var(--bg3);border:1px solid var(--border);border-radius:6px;padding:6px 14px;text-align:center;display:flex;flex-direction:column;justify-content:center;min-height:60px;}\n" + ".stat-badge .val{font-size:20px;font-weight:bold;color:var(--accent2);line-height:1.2;}\n" + ".stat-badge .lbl{font-size:11px;color:var(--text2);text-transform:uppercase;letter-spacing:1px;margin-top:2px;}\n" + ".info-bar{background:var(--bg3);border-bottom:1px solid var(--border);padding:8px 24px;display:flex;gap:24px;flex-wrap:wrap;font-size:12px;color:var(--text2);}\n" + ".info-bar span{color:var(--text);}\n" + /* footer */ "footer{text-align:center;padding:14px 16px;font-size:11px;color:var(--text2);border-top:1px solid var(--border);margin-top:16px;line-height:1.8;display:block;width:100%;box-sizing:border-box;}\n" + "footer .footer-copy{font-size:10px;color:var(--text2);opacity:0.5;margin-top:2px;}\n" + /* chat inline */ ".chat-wrap{display:flex;flex-direction:column;overflow:hidden;background:var(--bg);height:calc(100vh - 220px);min-height:400px;}\n" + ".chat-auth-bar{background:var(--bg2);border-bottom:1px solid var(--border);padding:8px 16px;display:flex;align-items:center;gap:12px;flex-shrink:0;font-size:13px;}\n" + ".chat-auth-bar .user-nick{color:var(--text);font-weight:600;}\n" + ".chat-auth-bar .admin-badge{background:#3a1a0a;color:var(--accent2);font-size:10px;padding:2px 6px;border-radius:3px;border:1px solid var(--accent);margin-left:4px;}\n" + ".chat-auth-bar .btn-login{background:var(--accent);color:#fff;padding:5px 14px;border-radius:4px;font-size:12px;cursor:pointer;border:none;}\n" + ".chat-auth-bar .btn-login:hover{background:var(--accent2);}\n" + ".chat-auth-bar .btn-logout{color:var(--text2);font-size:12px;border:1px solid var(--border);padding:4px 12px;border-radius:4px;cursor:pointer;background:transparent;}\n" + ".chat-auth-bar .btn-logout:hover{color:var(--text);border-color:var(--text2);}\n" + ".chat-tab-nav{background:var(--bg3);border-bottom:1px solid var(--border);padding:0 12px;display:flex;gap:2px;flex-shrink:0;}\n" + ".chat-tab-btn{background:transparent;border:none;border-bottom:2px solid transparent;color:var(--text2);padding:8px 14px;cursor:pointer;font-size:12px;font-weight:bold;}\n" + ".chat-tab-btn:hover{color:var(--text);}\n" + ".chat-tab-btn.active{color:var(--accent2);border-color:var(--accent);}\n" + ".chat-section{display:none;flex:1;flex-direction:column;overflow:hidden;}\n" + ".chat-section.active{display:flex;}\n" + ".chat-messages{flex:1;overflow-y:auto;padding:12px 16px;display:flex;flex-direction:column;gap:4px;min-height:300px;}\n" + ".msg{display:flex;gap:8px;align-items:baseline;padding:4px 8px;border-radius:4px;}\n" + ".msg:hover{background:rgba(255,255,255,0.03);}\n" + ".msg-time{font-size:11px;color:var(--text2);flex-shrink:0;width:48px;}\n" + ".msg-nick{font-weight:600;flex-shrink:0;max-width:140px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}\n" + ".chat-nick-link{text-decoration:none;color:inherit;}.chat-nick-link:hover{text-decoration:underline;}\n\n" + ".msg-nick.game{color:#d4c9b8;}.msg-nick.web{color:var(--blue);}.msg-nick.server{color:var(--gold);}\n" + ".msg-nick.admin::after{content:' [A]';color:var(--accent2);font-size:10px;}\n" + ".msg-text{color:var(--text);word-break:break-word;}\n" + ".source-badge{font-size:10px;padding:1px 5px;border-radius:3px;flex-shrink:0;}\n" + ".badge-web{background:#1a2a3a;color:var(--blue);border:1px solid var(--blue);}\n" + ".badge-game{background:#1a1a1a;color:var(--text2);border:1px solid var(--border);}\n" + ".chat-input-bar{border-top:1px solid var(--border);padding:10px 16px;display:flex;gap:8px;flex-shrink:0;background:var(--bg2);}\n" + ".chat-input-bar input{flex:1;background:var(--bg3);border:1px solid var(--border);color:var(--text);padding:8px 12px;border-radius:4px;font-size:13px;}\n" + ".chat-input-bar input:focus{outline:none;border-color:var(--accent);}\n" + ".chat-input-bar button{background:var(--accent);color:#fff;border:none;padding:8px 18px;border-radius:4px;cursor:pointer;font-size:13px;font-weight:bold;flex-shrink:0;}\n" + ".chat-input-bar button:hover{background:var(--accent2);}\n" + ".chat-login-prompt{padding:12px 16px;text-align:center;color:var(--text2);border-top:1px solid var(--border);background:var(--bg2);font-size:13px;}\n" + ".chat-loading{color:var(--text2);padding:16px;text-align:center;}\n" + ".console-container{flex:1;overflow-y:auto;padding:12px 16px;font-family:'Courier New',monospace;font-size:12px;line-height:1.6;background:var(--bg);}\n" + ".log-entry{padding:2px 0;border-bottom:1px solid rgba(255,255,255,0.03);}\n" + ".log-entry.majestat{color:#7fd47f;}\n" + ".log-entry.server{color:#b0b0b0;}\n" + ".log-time{color:var(--text2);margin-right:8px;}\n" + "@media(max-width:600px){.msg-time{display:none;}.msg-nick{max-width:90px;}}\n" + /* map */ ".map-container{position:relative;width:100%;height:620px;overflow:hidden;background:#0d1a0d;transition:all .3s;}\n" + ".map-container.fullscreen{position:fixed;inset:0;width:100vw;height:100vh;z-index:9999;border-radius:0;}\n" + ".map-canvas-wrap{position:absolute;inset:0;}\n" + "#map-canvas{width:100%;height:100%;display:block;}\n" + ".map-tooltip{position:absolute;background:rgba(0,0,0,.85);color:#fff;padding:4px 8px;border-radius:4px;font-size:12px;pointer-events:none;white-space:nowrap;border:1px solid var(--border);z-index:10;}\n" + ".map-legend{position:absolute;bottom:44px;left:8px;background:rgba(0,0,0,.6);border-radius:4px;padding:4px 8px;display:flex;gap:10px;flex-wrap:wrap;font-size:11px;z-index:10;}\n" + ".leg{display:flex;align-items:center;gap:4px;color:rgba(255,255,255,0.8);}\n" + ".leg-me{color:#4a9e5c;}.leg-team{color:#4a90d9;}.leg-all{color:#d4c9b8;}\n" + ".leg-heli{color:#e07840;}.leg-bradley{color:#c8a84b;}.leg-cargo{color:#9b59b6;}.leg-ch47{color:#3498db;}\n" + /* chat overlay */ ".map-chat-overlay{position:absolute;bottom:0;left:0;right:0;z-index:20;background:linear-gradient(transparent,rgba(0,0,0,0.75) 30%);pointer-events:none;}\n" + ".map-chat-messages-overlay{max-height:90px;overflow:hidden;padding:4px 10px;display:flex;flex-direction:column;gap:1px;pointer-events:none;}\n" + ".map-chat-messages-overlay .msg{font-size:12px;line-height:1.4;text-shadow:1px 1px 2px rgba(0,0,0,0.9);pointer-events:none;}\n" + ".map-chat-input-overlay{display:flex;gap:6px;padding:6px 8px;pointer-events:all;background:rgba(0,0,0,0.5);}\n" + ".mcc-btn{background:var(--bg3);border:1px solid var(--border);color:var(--text2);padding:3px 10px;border-radius:3px;cursor:pointer;font-size:11px;font-weight:bold;}\n" + ".mcc-btn.active{background:var(--accent);color:#fff;border-color:var(--accent);}\n" + ".mcc-btn:hover:not(.active){border-color:var(--text2);color:var(--text);}\n" + // Na mapie (ciemne tło overlay) — szary nieaktywny z transparentnym tłem ".map-chat-input-overlay .mcc-btn{background:rgba(255,255,255,0.08);border-color:rgba(255,255,255,0.2);color:rgba(255,255,255,0.6);}\n" + ".map-chat-input-overlay .mcc-btn.active{background:var(--accent);color:#fff;border-color:var(--accent);}\n" + ".map-chat-input-overlay .mcc-btn:hover:not(.active){background:rgba(255,255,255,0.15);color:#fff;}\n" + ".map-chat-input-overlay input{flex:1;background:rgba(0,0,0,0.6);border:1px solid rgba(255,255,255,0.2);color:#fff;padding:5px 8px;border-radius:4px;font-size:12px;min-width:0;}\n" + ".map-chat-input-overlay input:focus{outline:none;border-color:var(--accent);}\n" + ".map-chat-input-overlay button{background:var(--accent);color:#fff;border:none;padding:5px 12px;border-radius:4px;cursor:pointer;font-size:12px;flex-shrink:0;}\n" + "@media(max-width:768px){.map-container{height:400px;}}\n" + /* responsive */ "@media(max-width:768px){.server-stats{gap:8px;}.stat-badge .val{font-size:15px;}th,td{padding:7px 8px;font-size:12px;}}\n" + /* xpinfo */ ".xpinfo-wrap{padding:20px 24px;}\n" + ".xpinfo-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:16px;}\n" + ".xpinfo-col{background:var(--bg2);border:1px solid var(--border);border-radius:8px;overflow:hidden;}\n" + ".xpinfo-col-header{background:var(--bg3);border-bottom:1px solid var(--border);padding:10px 16px;font-size:11px;font-weight:bold;color:var(--text2);text-transform:uppercase;letter-spacing:1px;display:flex;justify-content:space-between;}\n" + ".xpinfo-col-header span:last-child{color:var(--gold);}\n" + ".xpinfo-section-title{padding:6px 16px 3px;font-size:10px;color:var(--text2);text-transform:uppercase;letter-spacing:1px;}\n" + ".xpinfo-row{display:flex;justify-content:space-between;padding:5px 16px;font-size:13px;border-bottom:1px solid rgba(255,255,255,0.03);}\n" + ".xpinfo-row:hover{background:rgba(255,255,255,0.03);}\n" + ".xpinfo-row .lbl{color:var(--text);}\n" + ".xpinfo-row .val{font-weight:bold;color:var(--gold);min-width:60px;text-align:right;}\n" + ".xpinfo-row .val.zero{color:var(--text2);font-weight:normal;}\n" + /* map fullscreen */ ".map-fullscreen-btn{position:absolute;top:44px;right:8px;z-index:30;background:rgba(0,0,0,0.6);border:1px solid rgba(255,255,255,0.2);color:#fff;width:28px;height:28px;border-radius:4px;cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:14px;line-height:1;}\n" + ".map-fullscreen-btn:hover{background:rgba(0,0,0,0.85);border-color:var(--accent);}\n" + ".map-fs-overlay{display:none;position:fixed;inset:0;z-index:2000;background:#000;flex-direction:column;}\n" + ".map-fs-overlay.active{display:flex;}\n" + ".map-fs-overlay .map-container{flex:1;height:auto !important;}\n" + ".map-fs-close{position:fixed;top:10px;right:10px;z-index:2010;background:rgba(0,0,0,0.7);border:1px solid rgba(255,255,255,0.3);color:#fff;width:32px;height:32px;border-radius:4px;cursor:pointer;font-size:18px;display:flex;align-items:center;justify-content:center;}\n" + /* gameinfo */ ".gi-wrap{display:flex;min-height:520px;}\n" + ".gi-sidebar{width:200px;flex-shrink:0;background:var(--bg3);border-right:1px solid var(--border);padding:8px 0;}\n" + ".gi-sidebar-title{padding:8px 16px 4px;font-size:10px;color:var(--text2);text-transform:uppercase;letter-spacing:1px;font-weight:bold;}\n" + ".gi-tab-btn{display:block;width:100%;text-align:left;background:transparent;border:none;border-left:3px solid transparent;color:var(--text2);padding:10px 16px;cursor:pointer;font-size:13px;transition:color .15s,background .15s,border-color .15s;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}\n" + ".gi-tab-btn:hover{background:rgba(255,255,255,0.04);color:var(--text);}\n" + ".gi-tab-btn.active{color:#fff;font-weight:600;background:rgba(255,255,255,0.06);}\n" + ".gi-content{flex:1;padding:24px 28px;min-width:0;}\n" + ".gi-tab-pane{display:none;}.gi-tab-pane.active{display:block;}\n" + ".gi-tab-title{font-size:18px;font-weight:bold;margin-bottom:8px;padding-bottom:8px;border-bottom:1px solid var(--border);}\n" + ".gi-line{font-size:14px;line-height:1.75;color:var(--text);white-space:pre-wrap;word-break:break-word;}\n" + ".gi-line a{color:var(--blue);text-decoration:underline;}\n" + ".gi-line a:hover{color:var(--accent2);}\n" + ".gi-line img{max-width:480px;width:100%;border-radius:6px;margin:6px 0;display:block;border:1px solid var(--border);}\n" + ".gi-line b{color:#fff;font-weight:600;}\n" + "@media(max-width:640px){.gi-sidebar{width:130px;}.gi-content{padding:16px;}}\n" + /* ── Wersja mobilna ─────────────────────────────────────────── */ "@media(max-width:768px){" + "header{flex-wrap:wrap;overflow:visible;padding:10px 12px;gap:10px;justify-content:center;}" + "header h1{font-size:15px;white-space:normal;text-align:center;width:100%;}" + ".server-stats{justify-content:center;width:100%;gap:8px;}" + ".stat-badge{padding:5px 10px;min-height:46px;}" + "main{padding:12px;}" + ".info-bar{padding:8px 12px;gap:10px 16px;font-size:11px;}" + ".tabs-bar{padding:0 8px;overflow-x:auto;flex-wrap:nowrap;-webkit-overflow-scrolling:touch;}" + ".tab-btn{padding:10px 12px;font-size:12px;white-space:nowrap;flex-shrink:0;}" + ".tabs-bar-right{font-size:11px;}" + ".chat-tab-nav{overflow-x:auto;-webkit-overflow-scrolling:touch;}" + "table{display:block;overflow-x:auto;-webkit-overflow-scrolling:touch;white-space:nowrap;}" + "}\n" + "@media(max-width:480px){" + "header h1{font-size:14px;}" + ".stat-badge{padding:4px 8px;min-height:42px;}" + ".stat-badge .val{font-size:14px;}" + ".stat-badge .lbl{font-size:9px;}" + ".tabs-bar-right{display:none;}" + ".info-bar{gap:6px 12px;}" + "}\n"; } // ── Game Info HTML (z MajestatTopInfo) ─────────────────────── private string BuildGameInfoHtml() { // API_GetInfoData zwraca JSON string — bezpieczne przez granicę pluginów Oxide string json = MajestatTopInfo?.Call("API_GetInfoData") as string; if (string.IsNullOrEmpty(json)) return "
MajestatTopInfo niedostępny lub brak danych.
"; Dictionary data; List> rawTabs; string title; try { data = JsonConvert.DeserializeObject>(json); title = data != null && data.ContainsKey("title") ? data["title"]?.ToString() ?? "" : ""; var tabsToken = data != null && data.ContainsKey("tabs") ? data["tabs"] : null; string tabsJson = tabsToken != null ? JsonConvert.SerializeObject(tabsToken) : "[]"; rawTabs = JsonConvert.DeserializeObject>>(tabsJson) ?? new List>(); } catch { return "
Błąd parsowania danych.
"; } if (rawTabs.Count == 0) return "
Brak zakładek w konfiguracji.
"; var sb = new StringBuilder(); sb.Append("
").Append(EscHtml(title)).Append("
"); sb.Append("
"); // Sidebar z zakładkami sb.Append(""); // Treść zakładek sb.Append("
"); for (int i = 0; i < rawTabs.Count; i++) { var t = rawTabs[i]; string id = t.ContainsKey("id") ? t["id"]?.ToString() ?? "" : ""; string label = t.ContainsKey("label") ? t["label"]?.ToString() ?? "" : ""; string icon = t.ContainsKey("icon") ? t["icon"]?.ToString() ?? "" : ""; string color = t.ContainsKey("color") ? t["color"]?.ToString() ?? "" : ""; string cssColor = RgbaToCss(color); List lines = null; if (t.ContainsKey("lines") && t["lines"] != null) { try { lines = JsonConvert.DeserializeObject>( JsonConvert.SerializeObject(t["lines"])); } catch { lines = null; } } string active = i == 0 ? " active" : ""; sb.Append("
"); sb.Append("
" + EscHtml(icon) + " " + EscHtml(label) + "
"); if (lines != null) { foreach (var line in lines) { if (string.IsNullOrEmpty(line)) { sb.Append("
"); continue; } sb.Append("
" + BbCodeToHtml(line) + "
"); } } sb.Append("
"); // gi-tab-pane } sb.Append("
"); // gi-content sb.Append("
"); // gi-wrap // JS do przełączania zakładek w Game Info return sb.ToString(); } // Konwertuje BBCode Info na HTML private string BbCodeToHtml(string line) { if (string.IsNullOrEmpty(line)) return ""; // [img]url[/img] if (line.TrimStart().StartsWith("[img]") && line.Contains("[/img]")) { int si = line.IndexOf("[img]") + 5; int ei = line.IndexOf("[/img]"); if (si > 4 && ei > si) { string url = EscHtml(line.Substring(si, ei - si).Trim()); return "\"\""; } } // [url=...]tekst[/url] string result = line; int uStart = result.IndexOf("[url="); while (uStart >= 0) { int uEnd = result.IndexOf("]", uStart); if (uEnd < 0) break; string href = result.Substring(uStart + 5, uEnd - uStart - 5); int uClose = result.IndexOf("[/url]", uEnd); string text = uClose > uEnd ? result.Substring(uEnd + 1, uClose - uEnd - 1) : href; string before = result.Substring(0, uStart); string after = uClose >= 0 ? result.Substring(uClose + 6) : result.Substring(uEnd + 1); result = before + "" + EscHtml(text) + "" + after; uStart = result.IndexOf("[url="); } // [b]...[/b] result = result.Replace("[b]", "").Replace("[/b]", ""); // [color=#hex]...[/color] int cStart = result.IndexOf("[color="); while (cStart >= 0) { int cEnd = result.IndexOf("]", cStart); if (cEnd < 0) break; string hexVal = result.Substring(cStart + 7, cEnd - cStart - 7).Trim(); string cssCol = hexVal.StartsWith("#") ? hexVal : "#" + hexVal; int cClose = result.IndexOf("[/color]", cEnd); string text = cClose > cEnd ? result.Substring(cEnd + 1, cClose - cEnd - 1) : ""; string before = result.Substring(0, cStart); string after = cClose >= 0 ? result.Substring(cClose + 8) : result.Substring(cEnd + 1); result = before + "" + text + "" + after; cStart = result.IndexOf("[color="); } // [size=N]...[/size] int sStart = result.IndexOf("[size="); while (sStart >= 0) { int sEnd = result.IndexOf("]", sStart); if (sEnd < 0) break; string sizeVal = result.Substring(sStart + 6, sEnd - sStart - 6).Trim(); int sClose = result.IndexOf("[/size]", sEnd); string text = sClose > sEnd ? result.Substring(sEnd + 1, sClose - sEnd - 1) : ""; string before = result.Substring(0, sStart); string after = sClose >= 0 ? result.Substring(sClose + 7) : result.Substring(sEnd + 1); result = before + "" + text + "" + after; sStart = result.IndexOf("[size="); } return result; } // Konwertuje "R G B" (0-1 float) na CSS hex color private string RgbaToCss(string rgba) { if (string.IsNullOrEmpty(rgba)) return "var(--accent2)"; var parts = rgba.Trim().Split(' '); if (parts.Length < 3) return "var(--accent2)"; try { int r = (int)(float.Parse(parts[0], System.Globalization.CultureInfo.InvariantCulture) * 255); int g = (int)(float.Parse(parts[1], System.Globalization.CultureInfo.InvariantCulture) * 255); int b = (int)(float.Parse(parts[2], System.Globalization.CultureInfo.InvariantCulture) * 255); return string.Format("#{0:X2}{1:X2}{2:X2}", r, g, b); } catch { return "var(--accent2)"; } } // ── Chat Inline (bez iframe) ────────────────────────────────── private string BuildChatInline() { string mode = (_cfg.ChatAuthMode ?? "steam").ToLower(); var sb = new StringBuilder(); sb.Append("
"); sb.Append("
"); sb.Append("
"); sb.Append(""); sb.Append(""); sb.Append(""); sb.Append(""); sb.Append("
"); sb.Append(""); sb.Append("
"); sb.Append("
"); sb.Append(""); sb.Append("
"); sb.Append("
"); sb.Append("
Ładowanie...
"); sb.Append("
"); if (mode == "open") sb.Append("
"); else { sb.Append("
"); // Overlay dla niezalogowanych zamiast zwykłego prompta sb.Append("
"); sb.Append("
"); sb.Append("
Zaloguj się przez Steam aby pisać na chacie
"); sb.Append(""); sb.Append("
"); } sb.Append("
"); sb.Append("
"); // Sekcja Konsola sb.Append("
"); sb.Append("
"); sb.Append("
"); sb.Append("
"); return sb.ToString(); } // ── XP Info Page ───────────────────────────────────────────── private string BuildXpInfoHtml() { var xpData = MajestatTopCore?.Call("API_GetXpInfo") as Dictionary; if (xpData == null) return "
Brak danych XP.
"; bool pl = (_cfg.Language ?? "pl").ToLower() == "pl"; var pvp = xpData["pvp"] as Dictionary; var modules = xpData["modules"] as List>; var sb = new StringBuilder(); sb.Append("
"); sb.Append("
"); // Karta PVP/PVE sb.Append(BuildXpCard("⚔ PVP / PVE", pvp?.Select(kv => (kv.Key, Convert.ToDouble(kv.Value))).ToList(), pl)); // Karty modułów if (modules != null) { foreach (var mod in modules) { string modName = mod.ContainsKey("name") ? mod["name"]?.ToString() ?? "" : ""; var rows = mod.ContainsKey("rows") ? (mod["rows"] as Dictionary) ?.Select(kv => (XpRowLabel(modName + "." + kv.Key, pl), Convert.ToDouble(kv.Value))) .ToList() : null; string icon = modName == "Gather" ? "⛏" : modName == "Loot" ? "📦" : modName == "Build" ? "🏗" : "📊"; string title = icon + " " + (pl ? TranslateModuleName(modName) : modName.ToUpper()); sb.Append(BuildXpCard(title, rows, pl)); } } sb.Append("
"); return sb.ToString(); } // Tłumaczenie nazwy modułu private string TranslateModuleName(string mod) { switch (mod) { case "Gather": return "ZBIERANIE"; case "Loot": return "LOOT"; case "Build": return "BUDOWANIE"; default: return mod.ToUpper(); } } private string XpRowLabel(string fullKey, bool pl) { string raw = fullKey.Contains(".") ? fullKey.Substring(fullKey.IndexOf('.') + 1) : fullKey; switch (raw) { // BUILD case "Walls": return pl ? "Ściany" : "Walls"; case "Foundation": return pl ? "Fundament" : "Foundation"; case "Floors": return pl ? "Podłogi" : "Floors"; case "Roof": return pl ? "Dach" : "Roof"; case "Doors": return pl ? "Drzwi" : "Doors"; case "Turrets": return pl ? "Wieżyczki" : "Turrets"; case "Traps": return pl ? "Pułapki" : "Traps"; case "Electrical": return pl ? "Elektryka" : "Electrical"; case "Pipes": return pl ? "Rury" : "Pipes"; case "Total": return pl ? "Łącznie" : "Total"; // GATHER case "Wood": return pl ? "Drewno" : "Wood"; case "Stone": return pl ? "Kamień" : "Stone"; case "Metal": return "Metal"; case "Sulfur": return pl ? "Siarka" : "Sulfur"; case "Scrap": return pl ? "Złom" : "Scrap"; case "Food": return pl ? "Jedzenie" : "Food"; // LOOT case "EliteCrate": return pl ? "Elite Crate" : "Elite Crate"; case "MilitaryCrate": return pl ? "Military Crate" : "Military Crate"; case "LockedCrate": return pl ? "Locked Crate" : "Locked Crate"; case "HackableCrate": return pl ? "CH47 Crate" : "CH47 Crate"; case "Airdrop": return pl ? "Airdrop" : "Airdrop"; case "UnderwaterCrate": return pl ? "Skrzynka podwodna" : "Underwater Crate"; case "NormalCrate": return pl ? "Skrzynka" : "Normal Crate"; case "Barrel": return pl ? "Beczka" : "Barrel"; default: return raw; } } private string BuildXpCard(string title, List<(string label, double xp)> rows, bool pl) { var sb = new StringBuilder(); sb.Append("
"); sb.Append("
" + EscHtml(title) + "
"); sb.Append(""); sb.Append(""); sb.Append(""); sb.Append(""); if (rows != null) foreach (var (label, xp) in rows) { string xpStr = xp == 0 ? "" : "+" + xp + ""; sb.Append(""); } sb.Append("
" + (pl ? "Czynność" : "Action") + "XP
" + EscHtml(label) + "" + xpStr + "
"); return sb.ToString(); } private string BuildFooter() { var modules = MajestatTopCore?.Call("API_GetModules") as List>; var updates = MajestatTopCore?.Call("API_GetPendingUpdates") as Dictionary ?? new Dictionary(); var parts = new System.Text.StringBuilder(); if (modules != null) { foreach (var m in modules) { string name = m.ContainsKey("name") ? m["name"]?.ToString() : "?"; string ver = m.ContainsKey("version") ? m["version"]?.ToString() : "?"; bool upd = updates.ContainsKey(name); if (parts.Length > 0) parts.Append(" • "); if (upd) parts.Append(EscHtml(name) + " v" + EscHtml(ver) + " ▲"); else parts.Append(EscHtml(name) + " v" + EscHtml(ver) + ""); } } else parts.Append("MajestatTopWeb v" + PluginVersion + ""); return "
" + "" + parts + " • " + "
created by Wo0t © Koldrix Group 2007 –
" + "
" + ""; } // ── Main Page ──────────────────────────────────────────────── private string BuildMainPage() { var serverRaw = MajestatTopCore?.Call("API_GetServerInfo") as Dictionary; var tabsRaw = MajestatTopCore?.Call("API_GetTabs") as List>; string srvName = Get(serverRaw, "name", "Rust Server"); int players = GetInt(serverRaw, "players"); int maxPl = GetInt(serverRaw, "maxPlayers"); string map = Get(serverRaw, "map", "Unknown"); int seed = GetInt(serverRaw, "seed"); int wSize = GetInt(serverRaw, "worldSize"); int upSec = GetInt(serverRaw, "uptime"); string desc = Get(serverRaw, "description", ""); int uniquePl = GetInt(serverRaw, "uniquePlayers"); string uptime = (upSec / 3600) + "h " + ((upSec % 3600) / 60) + "m"; string mapUrl = "https://rustmaps.com/map/" + wSize + "_" + seed; var tabBtns = new StringBuilder(); var tabCont = new StringBuilder(); if (tabsRaw != null) { for (int i = 0; i < tabsRaw.Count; i++) { string id = tabsRaw[i]["id"].ToString(); string label = tabsRaw[i]["label"].ToString(); bool first = i == 0; tabBtns.Append(""); tabCont.Append("
"); var entries = MajestatTopCore?.Call("API_GetLeaderboard", id, false, _cfg.LeaderboardRows) as List>; tabCont.Append(BuildLeaderboardHtml(label, entries, false)); var fameEnt = MajestatTopCore?.Call("API_GetLeaderboard", id, true, _cfg.LeaderboardRows) as List>; tabCont.Append(BuildLeaderboardHtml(label + " — HALL OF FAME", fameEnt, true)); tabCont.Append("
"); } } // Zakładka XP INFO — przed CHAT tabBtns.Append(""); tabCont.Append("
"); tabCont.Append("
Ładowanie...
"); tabCont.Append("
"); // Zakładka GAME INFO — jeśli MajestatTopInfo załadowany if (MajestatTopInfo != null) { tabBtns.Append(""); tabCont.Append("
"); tabCont.Append(BuildGameInfoHtml()); tabCont.Append("
"); } // Zakładka CHAT — inline (bez iframe) tabBtns.Append(""); tabCont.Append("
"); tabCont.Append(BuildChatInline()); tabCont.Append("
"); if (_mapModuleReady && MajestatTopMap != null) { string mapHtml = MajestatTopMap.Call("API_GetMapTabHtml", _cfg.ChatMaxMessageLength) as string ?? ""; tabBtns.Append(""); tabCont.Append("
"); tabCont.Append(mapHtml); tabCont.Append("
"); } var sb = new StringBuilder(); sb.AppendLine(""); sb.AppendLine(""); sb.AppendLine("" + EscHtml(srvName) + " — Stats"); sb.AppendLine(""); sb.AppendLine(""); sb.AppendLine("
"); sb.AppendLine("

" + EscHtml(srvName) + "

"); sb.AppendLine("
"); sb.AppendLine("
" + players + "/" + maxPl + "
Online
"); sb.AppendLine("
" + uptime + "
Uptime
"); sb.AppendLine("
" + EscHtml(map) + "
Mapa
"); string connectIp = _publicIp; int connectPort = ConVar.Server.port; if (!string.IsNullOrEmpty(connectIp)) { bool plC = (_cfg.Language ?? "pl").ToLower() == "pl"; string steamLink = "steam://connect/" + connectIp + ":" + connectPort; string btnLbl = plC ? "▶ Połącz" : "▶ Connect"; string btnSub = plC ? "Dołącz do serwera" : "Join server"; sb.AppendLine(" " + "
" + btnLbl + "
" + "
" + btnSub + "
"); } sb.AppendLine("
"); sb.AppendLine("
"); sb.AppendLine("
"); sb.AppendLine("
Seed: " + seed + "
"); sb.AppendLine("
Rozmiar: " + wSize + "
"); sb.AppendLine(" "); string srvType = GetServerType(); string wipeSch = GetWipeSchedule(); if (!string.IsNullOrEmpty(srvType)) sb.AppendLine("
Typ: " + EscHtml(srvType) + "
"); if (!string.IsNullOrEmpty(wipeSch)) sb.AppendLine("
Wipe: " + EscHtml(wipeSch) + "
"); if (!string.IsNullOrEmpty(desc)) { string descClean = EscHtml(desc).Replace("\\n", " ").Replace("\n", " ").Trim(); sb.AppendLine("
Opis: " + descClean + "
"); } sb.AppendLine("
Odswiezanie co " + _cfg.JsonExportInterval + "s
"); sb.AppendLine("
"); // Login Steam w pasku zakładek po prawej string authHtml = "
" + "" + "
" + "" + "" + "" + "
" + "👥 Unikalni gracze:" + uniquePl + "" + "
"; sb.AppendLine("
" + tabBtns + authHtml + "
"); sb.AppendLine("
" + tabCont + "
"); sb.AppendLine(""); sb.AppendLine(BuildFooter()); sb.AppendLine(""); return sb.ToString(); } private string BuildLeaderboardHtml(string title, List> entries, bool fame) { var sb = new StringBuilder(); sb.Append("
" + title + "
"); if (entries == null || entries.Count == 0) { sb.Append("
Brak danych
"); return sb.ToString(); } var cols = entries[0].Keys.Where(k => k != "steamId" && k != "name" && k != "rank").ToList(); sb.Append("
"); sb.Append(""); foreach (var col in cols) { string lbl = FormatColumnLabel(col); string cls = col == "XP.Total" ? " class=\"xp\"" : ""; sb.Append("" + lbl + ""); } sb.Append(""); foreach (var e in entries) { int rank = Convert.ToInt32(e["rank"]); string rankCls = rank == 1 ? "rank-1" : rank == 2 ? "rank-2" : rank == 3 ? "rank-3" : "rank"; string name = EscHtml(e["name"].ToString()); string sid = e["steamId"].ToString(); sb.Append(""); sb.Append(""); foreach (var col in cols) { double val = e.ContainsKey(col) ? Convert.ToDouble(e[col]) : 0; string cls = col == "XP.Total" ? "val-xp" : col.EndsWith(".Total") ? "val-total" : col == "PlayTime.Seconds" ? "val-time" : "val-num"; string disp = col == "PlayTime.Seconds" ? FormatTime((long)val) : FormatNum(val); sb.Append(""); } sb.Append(""); } sb.Append("
#Gracz
" + rank + "" + name + "" + disp + "
"); return sb.ToString(); } // ── Player Profile Page ────────────────────────────────────── private string BuildPlayerPage(string steamId) { var stats = MajestatTopCore?.Call("API_GetAllStats", steamId, false) as Dictionary; var fame = MajestatTopCore?.Call("API_GetAllStats", steamId, true) as Dictionary; if (stats == null) return null; var serverRaw = MajestatTopCore?.Call("API_GetServerInfo") as Dictionary; string srvName = Get(serverRaw, "name", "Rust Server"); int players = GetInt(serverRaw, "players"); int maxPl = GetInt(serverRaw, "maxPlayers"); string map = Get(serverRaw, "map", "Unknown"); int upSec = GetInt(serverRaw, "uptime"); string uptime = (upSec / 3600) + "h " + ((upSec % 3600) / 60) + "m"; var player = BasePlayer.FindByID(ulong.Parse(steamId)); bool online = player != null; // Pobierz nick z cache Core (offline graczy) string name = steamId; if (player != null) name = player.displayName; else { // Pobierz z API_GetAllStats — profil zawiera Name var profileData = MajestatTopCore?.Call("API_GetPlayerName", steamId) as string; if (!string.IsNullOrEmpty(profileData)) name = profileData; else if (stats != null) { // Fallback — szukaj w leaderboardzie var tabs = MajestatTopCore?.Call("API_GetTabs") as List>; if (tabs != null) { foreach (var t in tabs) { var lb = MajestatTopCore?.Call("API_GetLeaderboard", t["id"].ToString(), false, 1000) as List>; if (lb == null) continue; var entry = lb.FirstOrDefault(e => e.ContainsKey("steamId") && e["steamId"].ToString() == steamId); if (entry != null && entry.ContainsKey("name")) { name = entry["name"].ToString(); break; } } } } } string avatarUrl = "/avatar/" + steamId; string steamUrl = "https://steamcommunity.com/profiles/" + steamId; string[] pvpKeys = { "PvP.Kills","PvP.Deaths","PvE.Deaths", "Kill.Heli","Kill.Bradley","Kill.Scientist","Kill.Zombie","Kill.Animal","PlayTime.Seconds" }; string[] pvpKeysFame = { "XP.Total","PvP.Kills","PvP.Deaths","PvE.Deaths", "Kill.Heli","Kill.Bradley","Kill.Scientist","Kill.Zombie","Kill.Animal","PlayTime.Seconds" }; string[] gatherKeys = { "Gather.Total","Gather.Wood","Gather.Stone", "Gather.Metal","Gather.Sulfur","Gather.Scrap","Gather.Food" }; string[] lootKeys = { "Loot.Total","Loot.EliteCrate","Loot.MilitaryCrate", "Loot.LockedCrate","Loot.HackableCrate","Loot.Airdrop", "Loot.UnderwaterCrate","Loot.NormalCrate","Loot.Barrel" }; string[] buildKeys = { "Build.Total","Build.Walls","Build.Floors","Build.Doors", "Build.Turrets","Build.Traps","Build.Electrical","Build.Pipes" }; string profileCss = ".profile-header{display:flex;align-items:flex-end;gap:20px;padding:20px;background:var(--bg2);border:1px solid var(--border);border-radius:8px;margin-bottom:24px;flex-wrap:wrap;}\n" + ".profile-left{display:flex;flex-direction:column;align-items:center;gap:8px;flex-shrink:0;}\n" + ".avatar-link{display:block;text-decoration:none;}\n" + ".avatar-link:hover .avatar-wrap img,.avatar-link:hover .avatar-placeholder{border-color:var(--accent2);opacity:0.85;}\n" + ".avatar-wrap{position:relative;width:96px;height:96px;}\n" + ".avatar-wrap img{width:96px;height:96px;border-radius:8px;border:2px solid var(--accent);object-fit:cover;background:var(--bg3);display:block;transition:border-color .2s,opacity .2s;}\n" + ".avatar-placeholder{width:96px;height:96px;border-radius:8px;border:2px solid var(--accent);background:var(--bg3);display:flex;align-items:center;justify-content:center;font-size:36px;color:var(--text2);transition:border-color .2s,opacity .2s;}\n" + ".online-badge{font-size:12px;font-weight:bold;padding:3px 10px;border-radius:4px;text-align:center;white-space:nowrap;}\n" + ".online-badge.online{background:#1a3a1a;color:#4a9e5c;border:1px solid #4a9e5c;}\n" + ".online-badge.offline{background:var(--bg3);color:var(--text2);border:1px solid var(--border);}\n" + ".profile-info{flex:1;min-width:180px;display:flex;flex-direction:column;justify-content:space-between;align-self:stretch;}\n" + ".profile-name-link{text-decoration:none;}\n" + ".profile-name-link:hover h2{color:var(--text);text-decoration:underline;}\n" + ".profile-info h2{font-size:22px;color:var(--accent2);margin-bottom:6px;transition:color .2s;}\n" + ".profile-info .sub{font-size:12px;color:var(--text2);margin-top:3px;}\n" + ".profile-xp-big{font-size:34px;font-weight:bold;color:var(--gold);line-height:1;letter-spacing:-1px;padding-bottom:2px;}\n" + ".profile-xp-big .xp-lbl{font-size:13px;color:var(--text2);font-weight:normal;margin-right:6px;vertical-align:middle;letter-spacing:0;}\n" + ".profile-links{display:flex;flex-direction:column;gap:8px;align-self:flex-end;}\n" + ".btn-steam{display:block;background:#1b2838;border:1px solid #4a90d9;color:#4a90d9;padding:8px 18px;border-radius:4px;font-size:12px;text-align:center;}\n" + ".btn-steam:hover{background:#2a3f5f;}\n" + ".section-label{font-size:11px;font-weight:bold;color:var(--text2);text-transform:uppercase;letter-spacing:2px;margin-bottom:12px;padding-left:2px;}\n" + ".section-label.fame{color:var(--gold);}\n" + ".stats-row-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin-bottom:28px;}\n" + "@media(max-width:900px){.stats-row-grid{grid-template-columns:repeat(2,1fr);}}\n" + "@media(max-width:500px){.stats-row-grid{grid-template-columns:1fr;}}\n" + ".stats-card{background:var(--bg2);border:1px solid var(--border);border-radius:8px;overflow:hidden;}\n" + ".stats-card.fame{border-color:#c8a84b33;}\n" + ".stats-card-title{background:var(--bg3);border-bottom:1px solid var(--border);padding:10px 16px;font-size:11px;font-weight:bold;color:var(--text2);text-transform:uppercase;letter-spacing:1px;}\n" + ".stats-card.fame .stats-card-title{color:var(--gold);}\n" + ".stats-row{display:flex;justify-content:space-between;align-items:center;padding:7px 16px;border-bottom:1px solid var(--border);font-size:13px;}\n" + ".stats-row:last-child{border-bottom:none;}\n" + ".stats-row .key{color:var(--text2);}\n" + ".v-xp{color:#c8a84b;font-weight:bold;}.v-total{color:#e07840;font-weight:bold;}.v-time{color:#4a7ec8;}.v-num{color:var(--text);}\n" + "@media(max-width:600px){" + ".profile-header{flex-direction:column;align-items:center;text-align:center;gap:12px;padding:16px;}" + ".profile-info{align-items:center;width:100%;}" + ".profile-info h2{font-size:19px;}" + ".profile-xp-big{font-size:30px;margin-top:8px;}" + ".profile-links{align-self:center;width:100%;}" + "}\n"; var sb = new StringBuilder(); sb.AppendLine(""); sb.AppendLine(""); sb.AppendLine("" + EscHtml(name) + " — " + EscHtml(srvName) + ""); sb.AppendLine(""); sb.AppendLine(""); // Header profilu: nazwa serwera (lewo) | PROFIL GRACZA (środek) | statsy (prawo) sb.AppendLine("
"); sb.AppendLine("

" + EscHtml(srvName) + "

"); sb.AppendLine("
Profil Gracza
"); sb.AppendLine("
"); sb.AppendLine("
" + players + "/" + maxPl + "
Online
"); sb.AppendLine("
" + uptime + "
Uptime
"); sb.AppendLine("
" + EscHtml(map) + "
Mapa
"); string connectIp = _publicIp; int connectPort = ConVar.Server.port; if (!string.IsNullOrEmpty(connectIp)) { bool plC = (_cfg.Language ?? "pl").ToLower() == "pl"; string steamLink = "steam://connect/" + connectIp + ":" + connectPort; string btnLbl = plC ? "▶ Połącz" : "▶ Connect"; string btnSub = plC ? "Dołącz do serwera" : "Join server"; sb.AppendLine(" " + "
" + btnLbl + "
" + "
" + btnSub + "
"); } sb.AppendLine("
"); sb.AppendLine("
"); // Info-bar z przyciskiem powrót po lewej sb.AppendLine("
"); sb.AppendLine(" ← Powrot do rankingu"); sb.AppendLine("
"); sb.AppendLine("
"); // XP gracza double xpTotal = stats != null && stats.ContainsKey("XP.Total") ? stats["XP.Total"] : 0; string xpFormatted = FormatXp(xpTotal); // Profile header sb.AppendLine("
"); // Lewa: avatar klikalny → Steam, badge pod spodem sb.AppendLine(" "); // Środek: nick klikalny → Steam na górze, XP na dole sb.AppendLine("
"); sb.AppendLine("
"); sb.AppendLine("

" + EscHtml(name) + "

"); sb.AppendLine("
SteamID64: " + steamId + "
"); sb.AppendLine("
Nick z ostatniej sesji na serwerze
"); // "Ostatnio widziany" — tylko gdy offline i znamy znacznik czasu if (!online && stats.TryGetValue("Meta.LastSeen", out double lastSeenTs) && lastSeenTs > 0) { string lastSeenStr = DateTimeOffset.FromUnixTimeSeconds((long)lastSeenTs) .ToLocalTime().ToString("dd-MM-yyyy HH:mm:ss"); sb.AppendLine("
Ostatnio widziany Online: " + lastSeenStr + "
"); } sb.AppendLine("
"); sb.AppendLine("
XP" + xpFormatted + "
"); sb.AppendLine("
"); sb.AppendLine("
"); // Wiersz 1 — sezonowe bool profilePl = (_cfg.Language ?? "pl").ToLower() == "pl"; sb.AppendLine("
" + (profilePl ? "Statystyki sezonowe" : "Season stats") + "
"); sb.AppendLine("
"); sb.Append(BuildStatsCard("PVP / PVE", pvpKeys, stats, false)); sb.Append(BuildStatsCard(profilePl ? "ZBIERANIE" : "GATHER", gatherKeys, stats, false)); sb.Append(BuildStatsCard("LOOT", lootKeys, stats, false)); sb.Append(BuildStatsCard(profilePl ? "BUDOWANIE" : "BUILD", buildKeys, stats, false)); sb.AppendLine("
"); // Wiersz 2 — Fame sb.AppendLine("
Hall of Fame
"); sb.AppendLine("
"); sb.Append(BuildStatsCard("FAME — PVP", pvpKeysFame, fame, true)); sb.Append(BuildStatsCard("FAME — " + (profilePl ? "ZBIERANIE" : "GATHER"), gatherKeys, fame, true)); sb.Append(BuildStatsCard("FAME — LOOT", lootKeys, fame, true)); sb.Append(BuildStatsCard("FAME — " + (profilePl ? "BUDOWANIE" : "BUILD"), buildKeys, fame, true)); sb.AppendLine("
"); sb.AppendLine("
"); sb.AppendLine(BuildFooter()); sb.AppendLine(""); return sb.ToString(); } private string BuildStatsCard(string title, string[] keys, Dictionary stats, bool isFame) { // Total zawsze na górze, reszta w oryginalnej kolejności var sortedKeys = keys.Where(k => k.EndsWith(".Total")) .Concat(keys.Where(k => !k.EndsWith(".Total"))).ToArray(); var sb = new StringBuilder(); sb.Append("
"); sb.Append("
" + title + "
"); foreach (var key in sortedKeys) { double val = stats != null && stats.ContainsKey(key) ? stats[key] : 0; string label = ProfileLabel(key); // Title Case, bez HTML string cls, disp; if (key == "PlayTime.Seconds") { cls = "v-time"; disp = FormatTime((long)val); } else if (key == "XP.Total") { cls = "v-xp"; disp = FormatNum(val); } else if (key.EndsWith(".Total")) { cls = "v-total"; disp = FormatNum(val); } else { cls = "v-num"; disp = FormatNum(val); } sb.Append("
"); sb.Append("" + EscHtml(label) + ""); sb.Append("" + disp + ""); sb.Append("
"); } sb.Append("
\n"); return sb.ToString(); } // Cache URL avatarów steamId → url private Dictionary _avatarCache = new Dictionary(); // Pobierz avatar gracza z Steam i odeślij jako redirect private void SendAvatar(HttpListenerContext ctx, string steamId) { // Sprawdź cache if (_avatarCache.TryGetValue(steamId, out string cachedUrl)) { ctx.Response.StatusCode = 302; ctx.Response.AddHeader("Location", cachedUrl); ctx.Response.Close(); return; } // Próbuj pobrać URL avatara ze Steam XML API string fallback = "https://avatars.steamstatic.com/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb_full.jpg"; try { string xmlUrl = "https://steamcommunity.com/profiles/" + steamId + "?xml=1"; var wc = new System.Net.WebClient(); wc.Headers["User-Agent"] = "MajestatTopWeb/1.0"; string xml = wc.DownloadString(xmlUrl); int start = xml.IndexOf("= 0) { start += "", start); if (end > start) { string avatarUrl = xml.Substring(start, end - start).Trim(); if (!string.IsNullOrEmpty(avatarUrl)) { _avatarCache[steamId] = avatarUrl; ctx.Response.StatusCode = 302; ctx.Response.AddHeader("Location", avatarUrl); ctx.Response.Close(); return; } } } } catch { } _avatarCache[steamId] = fallback; ctx.Response.StatusCode = 302; ctx.Response.AddHeader("Location", fallback); ctx.Response.Close(); } // ── Chat & Auth Methods ────────────────────────────────────── private void HandleSteamAuthStart(HttpListenerRequest req, HttpListenerResponse resp) { // Auto-wykryj bazowy URL z nagłówków (działa za reverse proxy) string baseUrl = GetBaseUrl(req); string ret = req.QueryString["ret"] ?? "/?tab=chat"; if (!ret.StartsWith("/")) ret = "/?tab=chat"; string token = Guid.NewGuid().ToString("N"); _pendingRet[token] = ret; string returnUrl = baseUrl + "/auth/steam/callback?state=" + token; string realm = baseUrl; string steamUrl = "https://steamcommunity.com/openid/login" + "?openid.ns=http://specs.openid.net/auth/2.0" + "&openid.mode=checkid_setup" + "&openid.return_to=" + Uri.EscapeDataString(returnUrl) + "&openid.realm=" + Uri.EscapeDataString(realm) + "&openid.identity=http://specs.openid.net/auth/2.0/identifier_select" + "&openid.claimed_id=http://specs.openid.net/auth/2.0/identifier_select"; resp.StatusCode = 302; resp.AddHeader("Location", steamUrl); resp.Close(); } // Zbuduj bazowy URL z nagłówków requestu (reverse proxy) private string GetBaseUrl(HttpListenerRequest req) { // X-Forwarded-Proto ustawiane przez NPM/nginx string proto = req.Headers["X-Forwarded-Proto"] ?? ""; // X-Forwarded-Host lub standardowy Host string host = req.Headers["X-Forwarded-Host"] ?? req.Headers["Host"] ?? ""; // Usuń ewentualny port jeśli proto jest znane (NPM usuwa port z Host) if (!string.IsNullOrEmpty(proto) && !string.IsNullOrEmpty(host)) return proto + "://" + host; // Fallback: local server address (the host:port the request came in on) string scheme = req.IsSecureConnection ? "https" : "http"; return scheme + "://" + req.UserHostName; } private void HandleSteamCallback(HttpListenerRequest req, HttpListenerResponse resp) { string claimedId = req.QueryString["openid.claimed_id"] ?? ""; string steamId = ""; int idx = claimedId.LastIndexOf('/'); if (idx >= 0 && idx < claimedId.Length - 1) steamId = claimedId.Substring(idx + 1); if (string.IsNullOrEmpty(steamId) || steamId.Length < 15) { SendHtml(resp, "Błąd logowania Steam."); return; } // Pobierz ret URL z pendingRet przez state token string state = req.QueryString["state"] ?? ""; string retUrl = "/?tab=chat"; if (!string.IsNullOrEmpty(state) && _pendingRet.TryGetValue(state, out string pendingUrl)) { retUrl = pendingUrl; _pendingRet.Remove(state); } bool hasPlayed = MajestatTopCore != null && MajestatTopCore.Call("API_GetPlayerName", steamId) != null; if (_cfg.ChatAuthMode == "played" && !hasPlayed) { SendHtml(resp, ErrorPage("Dostęp tylko dla graczy którzy grali na tym serwerze.")); return; } bool isAdmin = permission.UserHasPermission(steamId, "majestattopcore.admin"); string nick = MajestatTopCore?.Call("API_GetPlayerName", steamId) as string ?? steamId; string token = Guid.NewGuid().ToString("N"); _sessions[token] = new Session { SteamId = steamId, Nick = nick, IsAdmin = isAdmin, HasPlayed = hasPlayed, Expires = DateTime.UtcNow.AddMinutes(_cfg.ChatSessionTimeout) }; // Ustaw cookie — SameSite=None dla HTTPS (NPM proxy), Lax dla HTTP bool isHttps = (req.Headers["X-Forwarded-Proto"] ?? "") == "https" || req.IsSecureConnection; string sameSite = isHttps ? "SameSite=None; Secure" : "SameSite=Lax"; resp.AddHeader("Set-Cookie", $"session={token}; Path=/; Max-Age={_cfg.ChatSessionTimeout * 60}; HttpOnly; {sameSite}"); // Przekaż token w URL jako fallback dla Android Chrome / proxy które stripuje cookies string sep = retUrl.Contains("?") ? "&" : "?"; resp.StatusCode = 302; resp.AddHeader("Location", retUrl + sep + "session_token=" + token); resp.Close(); AddConsoleLog("[Auth] " + nick + " (" + steamId + ") zalogował się.", "majestat"); } private void HandleLogout(HttpListenerRequest req, HttpListenerResponse resp) { var session = GetSession(req); if (session != null) _sessions.Remove(GetSessionToken(req)); resp.AddHeader("Set-Cookie", "session=; Path=/; Max-Age=0"); resp.StatusCode = 302; resp.AddHeader("Location", "/"); resp.Close(); } private void HandleAuthMe(HttpListenerRequest req, HttpListenerResponse resp) { var s = GetSession(req); if (s == null) { SendJson(resp, 200, new { loggedIn = false }); return; } // Pobierz teamId — działa też dla offline graczy long teamId = 0; var player = BasePlayer.Find(s.SteamId); if (player != null && player.currentTeam != 0) { teamId = (long)player.currentTeam; } else if (ulong.TryParse(s.SteamId, out ulong sid)) { // Szukaj w teamach przez RelationshipManager var rm = RelationshipManager.ServerInstance; if (rm != null) foreach (var team in rm.teams.Values) if (team.members.Contains(sid)) { teamId = (long)team.teamID; break; } } SendJson(resp, 200, new { loggedIn = true, steamId = s.SteamId, nick = s.Nick, isAdmin = s.IsAdmin, teamId }); } private void HandleGetChannels(HttpListenerRequest req, HttpListenerResponse resp) { var session = GetSession(req); bool hasClan = plugins.Find("Clans") != null; bool hasFriends = plugins.Find("Friends") != null; SendJson(resp, 200, new { global = true, team = session != null, clan = session != null && hasClan, friends = session != null && hasFriends }); } private void HandleGetMessages(HttpListenerRequest req, HttpListenerResponse resp) { int limit = _cfg.ChatHistory <= 0 ? 50 : _cfg.ChatHistory; string channel = req.QueryString["channel"] ?? ""; var session = GetSession(req); List msgs; lock (_chatLock) { if (channel == "team" || channel == "clan") { // Team/clan: tylko zalogowani gracze ze swojej drużyny if (session == null) { SendJson(resp, 403, new List()); return; } ulong myTeam = GetPlayerTeamId(session.SteamId); msgs = myTeam == 0 ? new List() : _chatHistory.Where(m => m.Channel == channel && m.TeamId == myTeam).ToList(); } else { // Global/all: NIGDY nie pokazuj wiadomości prywatnych (team/clan/friends) msgs = (string.IsNullOrEmpty(channel) || channel == "all") ? _chatHistory.Where(m => m.Channel != "team" && m.Channel != "clan" && m.Channel != "friends").ToList() : _chatHistory.Where(m => m.Channel == channel).ToList(); } } if (msgs.Count > limit) msgs = msgs.Skip(msgs.Count - limit).ToList(); SendJson(resp, 200, msgs); } private void HandleSendMessage(HttpListenerRequest req, HttpListenerResponse resp) { string mode = (_cfg.ChatAuthMode ?? "steam").ToLower(); var session = GetSession(req); bool loggedIn = session != null; if (mode != "open" && mode != "readonly" && !loggedIn) { SendJson(resp, 401, new { ok = false, error = "Zaloguj się przez Steam." }); return; } if (mode == "readonly") { SendJson(resp, 403, new { ok = false, error = "Chat jest tylko do odczytu." }); return; } string body = ""; try { using (var sr = new System.IO.StreamReader(req.InputStream)) body = sr.ReadToEnd(); } catch { } Dictionary data = null; try { data = JsonConvert.DeserializeObject>(body); } catch { } string text = data != null && data.ContainsKey("text") ? data["text"]?.ToString() ?? "" : ""; string channel = data != null && data.ContainsKey("channel") ? data["channel"]?.ToString() ?? "global" : "global"; string nick = loggedIn ? session.Nick : (data != null && data.ContainsKey("nick") ? data["nick"]?.ToString() ?? "Gość" : "Gość"); if (string.IsNullOrWhiteSpace(text)) { SendJson(resp, 400, new { ok = false, error = "Pusta wiadomość." }); return; } if (text.Length > _cfg.ChatMaxMessageLength) text = text.Substring(0, _cfg.ChatMaxMessageLength); // Rate limit string rateKey = loggedIn ? session.SteamId : (req.RemoteEndPoint?.Address?.ToString() ?? "anon"); long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); if (_rateLimitMap.TryGetValue(rateKey, out long lastMsg) && (now - lastMsg) < (60 / _cfg.ChatRateLimit)) { SendJson(resp, 429, new { ok = false, error = "Za szybko. Poczekaj chwilę." }); return; } _rateLimitMap[rateKey] = now; // Wyślij przez Core API (obsługuje wysyłkę do gry + logowanie + broadcast) MajestatTopCore?.Call("API_SendWebChat", nick, text, channel, session?.SteamId ?? "", session?.IsAdmin ?? false); SendJson(resp, 200, new { ok = true }); } private void HandleGetLogs(HttpListenerRequest req, HttpListenerResponse resp) { var session = GetSession(req); if (session == null || !session.IsAdmin) { SendJson(resp, 403, new { error = "Brak uprawnień." }); return; } List logs; lock (_consoleLock) { logs = _consoleLogs.ToList(); } SendJson(resp, 200, logs); } private Session GetSession(HttpListenerRequest req) { if (req == null) return null; string token = GetSessionToken(req); if (string.IsNullOrEmpty(token)) return null; if (!_sessions.TryGetValue(token, out Session s)) return null; if (s.Expires < DateTime.UtcNow) { _sessions.Remove(token); return null; } s.Expires = DateTime.UtcNow.AddMinutes(_cfg.ChatSessionTimeout); return s; } private string GetSessionToken(HttpListenerRequest req) { if (req == null) return ""; // 1. Header X-Session-Token (fetch requests) string header = req.Headers["X-Session-Token"] ?? ""; if (!string.IsNullOrEmpty(header)) return header; // 2. Cookie session string cookie = req.Headers["Cookie"] ?? ""; foreach (var c in cookie.Split(';')) { var parts = c.Trim().Split('='); if (parts.Length >= 2 && parts[0].Trim() == "session") return string.Join("=", parts, 1, parts.Length - 1).Trim(); } // 3. Query param ?token= (Android/proxy fallback) string qt = req.QueryString["token"] ?? ""; if (!string.IsNullOrEmpty(qt)) return qt; return ""; } private string ErrorPage(string msg) { return "

Brak dostępu

" + EscHtml(msg) + "

"; } // ── Server Tags (from server.tags ConVar) ──────────────────── private string GetServerType() { try { string tags = ConVar.Server.tags ?? ""; var parts = tags.Split(','); foreach (var tag in parts) { string t = tag.Trim().ToLower(); if (t == "pve") return "PvE"; if (t == "softcore") return "Softcore"; } foreach (var tag in parts) { string t = tag.Trim().ToLower(); if (t == "modded") return "Modded"; if (t == "vanilla") return "Vanilla"; } return !string.IsNullOrEmpty(_cfg.ServerType) ? _cfg.ServerType : ""; } catch { return !string.IsNullOrEmpty(_cfg.ServerType) ? _cfg.ServerType : ""; } } private string GetWipeSchedule() { try { string tags = ConVar.Server.tags ?? ""; var parts = tags.Split(','); foreach (var tag in parts) { string t = tag.Trim().ToLower(); switch (t) { case "weekly": return "Weekly"; case "biweekly": return "Biweekly"; case "monthly": return "Monthly"; case "daily": return "Daily"; } } return !string.IsNullOrEmpty(_cfg.WipeSchedule) ? _cfg.WipeSchedule : ""; } catch { return !string.IsNullOrEmpty(_cfg.WipeSchedule) ? _cfg.WipeSchedule : ""; } } // ── Helpers ────────────────────────────────────────────────── private string Get(Dictionary d, string key, string def) => d != null && d.ContainsKey(key) ? d[key]?.ToString() ?? def : def; private int GetInt(Dictionary d, string key) => d != null && d.ContainsKey(key) ? Convert.ToInt32(d[key]) : 0; // FormatColumnLabel — nagłówek kolumny w języku PL lub EN (cfg: Language) private string FormatColumnLabel(string col) { bool pl = (_cfg.Language ?? "pl").ToLower() == "pl"; if (col == "PlayTime.Seconds") return pl ? "CZAS
GRY" : "TIME
PLAYED"; string raw = col.Contains(".") ? col.Substring(col.IndexOf('.') + 1) : col; string prefix = col.Contains(".") ? col.Substring(0, col.IndexOf('.')) : ""; switch (raw) { case "Total": return col == "XP.Total" ? "TOTAL
XP" : "TOTAL"; case "Kills": return prefix == "PvE" ? (pl ? "PvE
ZGONY" : "PvE
DEATHS") : (pl ? "PvP
ZABÓJSTWA" : "PvP
KILLS"); case "Deaths": return prefix == "PvE" ? (pl ? "PvE
ZGONY" : "PvE
DEATHS") : (pl ? "PvP
ZGONY" : "PvP
DEATHS"); case "Heli": return "HELI"; case "Bradley": return "BRADLEY"; case "Scientist": return pl ? "NAUKOWCY" : "SCIENTIST"; case "Zombie": return "ZOMBIE"; case "Animal": return pl ? "ZWIERZĘTA" : "ANIMAL"; case "Metal": return "METAL"; case "Walls": return pl ? "ŚCIANY" : "WALLS"; case "Foundation": return pl ? "FUNDAM." : "FOUND."; case "Floors": return pl ? "PODŁOGI" : "FLOORS"; case "Seconds": return pl ? "CZAS GRY" : "PLAY TIME"; // PlayTime.Seconds case "Roof": return pl ? "DACH" : "ROOF"; case "Doors": return pl ? "DRZWI" : "DOORS"; case "Turrets": return pl ? "WIEŻYCZKI" : "TURRETS"; case "Traps": return pl ? "PUŁAPKI" : "TRAPS"; case "Electrical": return pl ? "ELEKTR." : "ELECTR."; case "Pipes": return pl ? "RURY" : "PIPES"; case "Wood": return pl ? "DREWNO" : "WOOD"; case "Stone": return pl ? "KAMIEŃ" : "STONE"; case "Sulfur": return pl ? "SIARKA" : "SULFUR"; case "Scrap": return pl ? "ZŁOM" : "SCRAP"; case "Food": return pl ? "JEDZENIE" : "FOOD"; case "EliteCrate": return pl ? "ELITE
CRATE" : "ELITE
CRATE"; case "MilitaryCrate": return pl ? "MILITARY
CRATE" : "MILITARY
CRATE"; case "LockedCrate": return pl ? "LOCKED
CRATE" : "LOCKED
CRATE"; case "HackableCrate": return pl ? "CH47
CRATE" : "CH47
CRATE"; case "Airdrop": return pl ? "AIR
DROP" : "AIR
DROP"; case "UnderwaterCrate": return pl ? "POD
WODNE" : "UNDER
WATER"; case "NormalCrate": return pl ? "SKRZYNKI
NORMAL" : "NORMAL
CRATES"; case "Barrel": return pl ? "BECZKI" : "BARREL"; default: return raw.ToUpper(); } } private string EscHtml(string s) { if (string.IsNullOrEmpty(s)) return ""; return s.Replace("&", "&").Replace("<", "<").Replace(">", ">") .Replace("\"", """).Replace("'", "'"); } // Czytelna etykieta do profilu gracza — Title Case, bez
, PL lub EN private string ProfileLabel(string key) { bool pl = (_cfg.Language ?? "pl").ToLower() == "pl"; string raw = key.Contains(".") ? key.Substring(key.IndexOf('.') + 1) : key; string prefix = key.Contains(".") ? key.Substring(0, key.IndexOf('.')) : ""; switch (raw) { case "Total": return "Total"; // zawsze "Total" — orange color via v-total class case "Seconds": return pl ? "Czas gry" : "Play time"; case "Kills": return prefix == "PvE" ? (pl ? "Zgony PvE" : "PvE deaths") : (pl ? "Zabójstwa PvP" : "PvP kills"); case "Deaths": return prefix == "PvE" ? (pl ? "Zgony PvE" : "PvE deaths") : (pl ? "Zgony PvP" : "PvP deaths"); case "Heli": return pl ? "Helikopter" : "Helicopter"; case "Bradley": return "Bradley"; case "Scientist": return pl ? "Naukowcy" : "Scientists"; case "Zombie": return "Zombie"; case "Animal": return pl ? "Zwierzęta" : "Animals"; case "Wood": return pl ? "Drewno" : "Wood"; case "Stone": return pl ? "Kamień" : "Stone"; case "Metal": return "Metal"; case "Sulfur": return pl ? "Siarka" : "Sulfur"; case "Scrap": return pl ? "Złom" : "Scrap"; case "Food": return pl ? "Jedzenie" : "Food"; case "Walls": return pl ? "Ściany" : "Walls"; case "Foundation": return pl ? "Fundament" : "Foundation"; case "Floors": return pl ? "Podłogi" : "Floors"; case "Roof": return pl ? "Dach" : "Roof"; case "Doors": return pl ? "Drzwi" : "Doors"; case "Turrets": return pl ? "Wieżyczki" : "Turrets"; case "Traps": return pl ? "Pułapki" : "Traps"; case "Electrical": return pl ? "Elektryka" : "Electrical"; case "Pipes": return pl ? "Rury" : "Pipes"; case "EliteCrate": return "Elite Crate"; case "MilitaryCrate": return "Military Crate"; case "LockedCrate": return "Locked Crate"; case "HackableCrate": return "CH47 Crate"; case "Airdrop": return "Airdrop"; case "UnderwaterCrate": return pl ? "Skrzynka podwodna" : "Underwater Crate"; case "NormalCrate": return pl ? "Skrzynka" : "Normal Crate"; case "Barrel": return pl ? "Beczka" : "Barrel"; default: return raw; } } private string FormatTime(long seconds) { if (seconds <= 0) return "—"; long h = seconds / 3600; long m = (seconds % 3600) / 60; return h > 0 ? h + "h " + m.ToString("D2") + "m" : m + "m"; } private string FormatNum(double val) { string fmt = (_cfg.TableNumberFormat ?? "KM").ToUpper(); int dec = _cfg.TableNumberDecimals; string decFmt = "F" + dec; if (fmt.Contains("M") && val >= 1_000_000) return (val / 1_000_000).ToString(decFmt) + "M"; if (fmt.Contains("K") && val >= 1_000) return (val / 1_000).ToString(decFmt) + "K"; return ((long)val).ToString(); } private string FormatXp(double val) { string fmt = (_cfg.ProfileXpFormat ?? "KM").ToUpper(); int dec = _cfg.ProfileXpDecimals; string decFmt = "F" + dec; if (fmt.Contains("M") && val >= 1_000_000) return (val / 1_000_000).ToString(decFmt) + "M"; if (fmt.Contains("K") && val >= 1_000) return (val / 1_000).ToString(decFmt) + "K"; return ((long)val).ToString(); } // ── Update Checker ─────────────────────────────────────────── private void StartUpdateChecker() { try { var iv = MajestatTopCore?.Call("API_GetUpdateInterval"); _updateIntervalMinutes = iv != null ? Convert.ToInt32(iv) : 60; } catch { _updateIntervalMinutes = 60; } timer.Once(5f, CheckForUpdate); if (_updateIntervalMinutes > 0) timer.Every(_updateIntervalMinutes * 60f, CheckForUpdate); } void OnMajestatUpdateCheck() { webrequest.Enqueue(PluginUpdateUrl, null, OnUpdateResponse, this); } private void CheckForUpdate() { webrequest.Enqueue(PluginUpdateUrl, null, OnUpdateResponse, this); } private void OnUpdateResponse(int code, string response) { string pluginName = "MajestatTopWeb"; if (code == 0 || code >= 400 || string.IsNullOrEmpty(response)) { MLog(2, "[Update] Update check failed - server unavailable (HTTP " + code + ")."); MajestatTopCore?.Call("API_ReportUpdateResult", pluginName, null, null); return; } try { var data = JsonConvert.DeserializeObject>(response); string latest = data != null && data.ContainsKey("version") ? data["version"].Trim() : null; if (latest != null && IsNewerVersion(latest, PluginVersion)) { string url = data.ContainsKey("url") ? data["url"] : PluginUpdateUrl; PrintWarning("[Update] Newer version available (" + latest + ") - your version (" + PluginVersion + ")"); MajestatTopCore?.Call("API_ReportUpdateResult", pluginName, latest, url); } else { MLog(1, "[Update] Up to date (" + PluginVersion + ") - no updates."); MajestatTopCore?.Call("API_ReportUpdateResult", pluginName, null, null); } } catch (Exception ex) { MLog(2, "[Update] Error: " + ex.Message); MajestatTopCore?.Call("API_ReportUpdateResult", pluginName, null, null); } } private bool IsNewerVersion(string latest, string current) { try { string lc = latest != null && latest.Contains("-") ? latest.Substring(0, latest.IndexOf('-')) : latest; string cc = current != null && current.Contains("-") ? current.Substring(0, current.IndexOf('-')) : current; var l = System.Array.ConvertAll(lc.Split('.'), int.Parse); var c = System.Array.ConvertAll(cc.Split('.'), int.Parse); int len = System.Math.Max(l.Length, c.Length); for (int i = 0; i < len; i++) { int lv = i < l.Length ? l[i] : 0; int cv = i < c.Length ? c[i] : 0; if (lv > cv) return true; if (lv < cv) return false; } return false; } catch { return false; } } } }