using System; using System.Collections.Generic; using Newtonsoft.Json; using Oxide.Core; using Oxide.Core.Libraries; using Oxide.Core.Plugins; using Oxide.Game.Rust.Cui; using UnityEngine; namespace Oxide.Plugins { [Info("MajestatTopNet", "Wo0t", "1.1.0")] [Description("Network layer for Majestat Network: heartbeat + PVP reporter + in-game global PVP panel. Repo edition.")] public class MajestatTopNet : RustPlugin { // https://master.rustserverlist.online/ private static readonly string MasterUrl = System.Text.Encoding.UTF8.GetString( System.Convert.FromBase64String("aHR0cHM6Ly9tYXN0ZXIucnVzdHNlcnZlcmxpc3Qub25saW5lLw==")); // https://master.rustserverlist.online/pvp.php private static readonly string PvpUrl = System.Text.Encoding.UTF8.GetString( System.Convert.FromBase64String("aHR0cHM6Ly9tYXN0ZXIucnVzdHNlcnZlcmxpc3Qub25saW5lL3B2cC5waHA=")); // https://master.rustserverlist.online/global.php private static readonly string GlobalUrl = System.Text.Encoding.UTF8.GetString( System.Convert.FromBase64String("aHR0cHM6Ly9tYXN0ZXIucnVzdHNlcnZlcmxpc3Qub25saW5lL2dsb2JhbC5waHA=")); // https://master.rustserverlist.online/rank.php private static readonly string RankUrl = System.Text.Encoding.UTF8.GetString( System.Convert.FromBase64String("aHR0cHM6Ly9tYXN0ZXIucnVzdHNlcnZlcmxpc3Qub25saW5lL3JhbmsucGhw")); // Baza adresow sprawdzania aktualizacji (zahashowana base64). // Pelny adres = UpdateBase + "/version.json" private static readonly string UpdateBase = System.Text.Encoding.UTF8.GetString( System.Convert.FromBase64String( "aHR0cHM6Ly9kb3dubG9hZC5rb2xkcml4LmNvbS9ydXN0L3BsdWdpbnMv")); private const float Interval = 60f; private const float PvpInterval = 300f; private const float GlobalTtl = 120f; // cache panelu /global (s) private const int GlobalTop = 15; // ile pozycji w panelu private const string Build = "1.1.0"; // wersja + timestamp DDMMYYYYHHMM (TYLKO tu; w [Info] czysto, bo Carbon waliduje) private const string K_KILLS = "PvP.Kills"; private const string K_PDEATHS = "PvP.Deaths"; private const string K_EDEATHS = "PvE.Deaths"; private const string K_HELI = "Kill.Heli"; private const string K_BRADLEY = "Kill.Bradley"; private const string K_SCI = "Kill.Scientist"; private const string K_ZOMBIE = "Kill.Zombie"; private const string K_ANIMAL = "Kill.Animal"; private const string UI_ROOT = "MajestatGlobal"; private const string UI_CARD = "MajestatGlobalCard"; private const string UI_VERIFIED = "MajestatVerified"; [PluginReference] private Plugin MajestatTopCore; private ConfigData _cfg; private Timer _timer; private Timer _updateTimer; private Timer _pvpTimer; private bool _active = false; private bool _pvpActive = false; private bool _verified = false; // czy master uznaje serwer za zweryfikowany private string _publicIp = ""; private string _serverId = ""; private string _legacyServerId = null; private readonly Dictionary _pvpBaseline = new Dictionary(); private readonly HashSet _bannerShown = new HashSet(); // cache globalnego rankingu (wspolny dla wszystkich graczy) private List _globalRows = null; private int _globalServers = 0; private int _globalTotal = 0; private float _globalAt = -999f; private bool _globalFetching = false; private class GRow { public int rank { get; set; } public string steamId { get; set; } public string name { get; set; } public long kills { get; set; } public long deaths { get; set; } public double kd { get; set; } public long heli { get; set; } public long bradley { get; set; } public long xp { get; set; } public string avatar { get; set; } } private class GlobalResp { public bool ok { get; set; } public int servers { get; set; } public int total { get; set; } public List rows { get; set; } } private class MeRank { public bool ok { get; set; } public int rank { get; set; } public long kills { get; set; } public long deaths { get; set; } public double kd { get; set; } public long heli { get; set; } public long bradley { get; set; } public long xp { get; set; } public string avatar { get; set; } } // -- Config ----------------------------------------- private class ConfigData { [JsonProperty("Token")] public string Token = ""; [JsonProperty("Game Port")] public int GamePort = 0; // pl / en - jezyk komunikatow dla graczy (baner, broadcast, panel) [JsonProperty("Language (pl/en)")] public string Language = "pl"; // both / broadcast / banner / off [JsonProperty("Verified notice (both/broadcast/banner/off)")] public string VerifiedNotice = "both"; // SteamID64 konta, ktorego avatar pojawi sie przy broadcascie (0 = brak) [JsonProperty("Chat icon SteamID64 (0 = none)")] public ulong ChatIcon = 0; // SteamID64 wlasciciela serwera - wysylany do mastera (ustawia wlasciciela konta). Puste = brak. [JsonProperty("Admin / Owner SteamID64")] public string OwnerSteamId = ""; // ID serwera. PUSTE = generowane automatycznie do oxide/data (jak dotad). // Jesli wpiszesz (np. ID nadane przez master dla serwera vanilla), zostanie zapisane do data i uzyte // - dzieki temu serwer vanilla po wgraniu pluginu sklei sie z istniejacym wpisem zamiast tworzyc nowy. [JsonProperty("Server ID (puste = auto)")] public string ServerId = ""; // -- Heartbeat → master (przejęte z Core) --------------------- // training/battlefield/broyale/builds/tut/premium/modded/oxide/carbon [JsonProperty("Server type")] public string ServerType = "modded"; // monthly / biweekly / weekly [JsonProperty("Wipe schedule")] public string Wipe = "monthly"; [JsonProperty("Web panel port")] public int WebPort = 28020; // Puste = master zbuduje PublicIP:webPort. Ustaw gdy panel jest za proxy/domeną. [JsonProperty("Web panel URL (puste = auto)")] public string WebUrl = ""; } protected override void LoadDefaultConfig() => _cfg = new ConfigData(); protected override void LoadConfig() { base.LoadConfig(); try { _legacyServerId = Config["Server ID (auto, nie ruszaj)"] as string; } catch { _legacyServerId = null; } try { _cfg = Config.ReadObject(); if (_cfg == null) throw new Exception("empty config"); SaveConfig(); } catch (Exception ex) { _cfg = new ConfigData(); try { string path = Config.Filename; if (System.IO.File.Exists(path)) { string bak = path + ".broken-" + DateTime.Now.ToString("yyyyMMdd-HHmmss"); System.IO.File.Copy(path, bak, true); PrintError($"[Config] Corrupt ({ex.Message}). Backup: {bak}. " + "Loaded defaults in memory and did NOT overwrite the file - fix it and reload."); } } catch { PrintError("[Config] Corrupt - loaded defaults (backup failed)."); } } } protected override void SaveConfig() => Config.WriteObject(_cfg); // -- Lokalizacja (pl/en) -------------------------------------- private bool En() => string.Equals(_cfg?.Language, "en", StringComparison.OrdinalIgnoreCase); private string L(string key) { bool en = En(); switch (key) { case "panel_title": return en ? "GLOBAL PVP RANKING" : "GLOBALNY RANKING PVP"; case "players": return en ? "PLAYERS" : "GRACZY"; case "col_player": return en ? "PLAYER" : "GRACZ"; case "your_pos": return en ? "YOUR RANK" : "TWOJA POZYCJA"; case "no_pos": return en ? "No rank yet - play to enter the ranking" : "Brak pozycji - zagraj, by trafic do rankingu"; case "no_data": return en ? "No ranking data yet." : "Brak danych do rankingu."; case "verified_srv": return en ? "Verified servers in network: " : "Serwery zweryfikowane w sieci: "; case "close": return en ? "CLOSE" : "ZAMKNIJ"; case "not_member": return en ? "This server is not part of the Majestat Network (unverified)." : "Ten serwer nie jest czescia sieci Majestat Network (niezweryfikowany)."; case "unavailable": return en ? "Ranking temporarily unavailable." : "Ranking chwilowo niedostepny."; case "verified_title": return en ? "VERIFIED SERVER" : "SERWER ZWERYFIKOWANY"; case "verified_sub": return en ? "Majestat Network - trusted network server" : "Majestat Network - zaufany serwer sieci"; case "broadcast": return en ? "Server verified" : "Serwer zweryfikowany"; default: return key; } } // -- Lifecycle ------------------------------------------------ void OnServerInitialized() { Puts($"[Net] MajestatTopNet build {Build}"); Activate(); // heartbeat ZAWSZE — Net jest jedynym komunikatorem z masterem if (plugins.Find("MajestatTopCore") != null) ActivatePvp(); // PVP reporter czyta staty z Core przez API } // Build z timestampem (do stopki Web). [Info] pozostaje czyste, bo Carbon waliduje wersje. [HookMethod("API_GetBuild")] public string API_GetBuild() => Build; void OnPluginLoaded(Plugin plugin) { if (plugin != null && plugin.Name == "MajestatTopCore") ActivatePvp(); // Core wrocil -> wlacz raportowanie PVP (heartbeat i tak dziala) } void OnPluginUnloaded(Plugin plugin) { if (plugin != null && plugin.Name == "MajestatTopCore") DeactivatePvp(); // Core zniknal -> tylko stop PVP; heartbeat leci dalej } void Unload() { _timer?.Destroy(); _updateTimer?.Destroy(); _pvpTimer?.Destroy(); foreach (var p in BasePlayer.activePlayerList) { if (p == null) continue; CuiHelper.DestroyUi(p, UI_ROOT); CuiHelper.DestroyUi(p, UI_VERIFIED); } } private void Activate() { if (_active) return; _active = true; if (string.IsNullOrEmpty(_serverId)) _serverId = LoadOrCreateServerId(); FetchPublicIp(); _timer?.Destroy(); timer.Once(2f, SendHeartbeat); _timer = timer.Every(Interval, SendHeartbeat); _updateTimer?.Destroy(); timer.Once(15f, CheckForUpdate); _updateTimer = timer.Every(21600f, CheckForUpdate); } private void Deactivate() { _active = false; _timer?.Destroy(); _timer = null; _updateTimer?.Destroy(); _updateTimer = null; PrintWarning("[Net] MajestatTopCore detected - standalone heartbeat DEACTIVATED (Core handles it)."); } // -- PVP reporter --------------------------------------------- private void ActivatePvp() { if (_pvpActive) return; _pvpActive = true; if (string.IsNullOrEmpty(_serverId)) _serverId = LoadOrCreateServerId(); _pvpBaseline.Clear(); foreach (var p in BasePlayer.activePlayerList) if (p != null) SnapshotPlayer(p.UserIDString); _pvpTimer?.Destroy(); _pvpTimer = timer.Every(PvpInterval, PvpTick); timer.Once(5f, CheckVerified); // pozn. status weryfikacji (do panelu /global) PrintWarning("[Net] PVP reporter ACTIVE (reading MajestatTopCore via API)."); } private void DeactivatePvp() { _pvpActive = false; _pvpTimer?.Destroy(); _pvpTimer = null; _pvpBaseline.Clear(); } private double[] ReadCoreStats(string steamId) => ReadCoreStats(steamId, out _); // loaded=false gdy Core nieobecny LUB gracz jeszcze nie wczytany z bazy (pusty slownik). // To kluczowe: zapobiega ustawieniu baseline=0 w trakcie asynchronicznego ladowania, // co wczesniej wrzucalo caly lokalny total gracza do globala jako delte. private double[] ReadCoreStats(string steamId, out bool loaded) { loaded = false; var res = MajestatTopCore?.Call("API_GetAllStats", steamId, false) as Dictionary; if (res == null || res.Count == 0) return new double[7]; loaded = true; double pd = res.TryGetValue(K_PDEATHS, out var b1) ? b1 : 0.0; double ed = res.TryGetValue(K_EDEATHS, out var b2) ? b2 : 0.0; return new double[] { res.TryGetValue(K_KILLS, out var a) ? a : 0.0, // 0 PvP kills pd + ed, // 1 deaths TOTAL (PvP+PvE, w tym selfkill) res.TryGetValue(K_HELI, out var c) ? c : 0.0, // 2 heli res.TryGetValue(K_BRADLEY, out var d) ? d : 0.0, // 3 bradley res.TryGetValue(K_SCI, out var e) ? e : 0.0, // 4 scientist res.TryGetValue(K_ZOMBIE, out var f) ? f : 0.0, // 5 zombie res.TryGetValue(K_ANIMAL, out var g) ? g : 0.0, // 6 animal }; } private void SnapshotPlayer(string steamId) { if (string.IsNullOrEmpty(steamId)) return; var now = ReadCoreStats(steamId, out bool loaded); if (loaded) _pvpBaseline[steamId] = now; // baseline TYLKO gdy Core wczytal staty gracza } void OnPlayerConnected(BasePlayer player) { if (player == null) return; if (_pvpActive) SnapshotPlayer(player.UserIDString); } // Baner pokazujemy dopiero, gdy gracz sie WYBUDZI (skonczy ladowac swiat) - // wtedy na pewno widzi UI. Slaby komputer laduje dlugo, wiec liczenie czasu // od polaczenia bylo bledne. Trzymamy 5 s po wybudzeniu, raz na sesje. void OnPlayerSleepEnded(BasePlayer player) { if (player == null || !_verified) return; ulong uid = player.userID; if (_bannerShown.Contains(uid)) return; _bannerShown.Add(uid); string mode = (_cfg?.VerifiedNotice ?? "both").ToLower(); if (mode == "off") return; if (mode == "both" || mode == "banner") ShowVerifiedBanner(player); if (mode == "both" || mode == "broadcast") ShowVerifiedBroadcast(player); } // Broadcast w oknie czatu: zielony ptaszek + tekst. Jesli ustawiono // Chat icon SteamID64 - wiadomosc dostaje avatar tego konta (np. ptaszek w kolku). private void ShowVerifiedBroadcast(BasePlayer player) { string msg = "\u2713 " + L("broadcast") + ""; if (_cfg != null && _cfg.ChatIcon != 0) player.SendConsoleCommand("chat.add", 2, _cfg.ChatIcon.ToString(), msg); else player.ChatMessage(msg); } private void ShowVerifiedBanner(BasePlayer player) { CuiHelper.DestroyUi(player, UI_VERIFIED); var c = new CuiElementContainer(); // pasek (wysrodkowany u gory, pod kompasem) c.Add(new CuiPanel { Image = { Color = "0.09 0.14 0.10 0.97" }, RectTransform = { AnchorMin = "0.5 0.86", AnchorMax = "0.5 0.86", OffsetMin = "-210 -28", OffsetMax = "210 28" } }, "Overlay", UI_VERIFIED); // dolny zielony akcent c.Add(new CuiPanel { Image = { Color = "0.27 0.74 0.36 0.9" }, RectTransform = { AnchorMin = "0 0", AnchorMax = "1 0.04" } }, UI_VERIFIED); // zielony badge z ptaszkiem - czesc wysrodkowanej grupy c.Add(new CuiPanel { Image = { Color = "0.27 0.74 0.36 1" }, RectTransform = { AnchorMin = "0.255 0.43", AnchorMax = "0.315 0.83" } }, UI_VERIFIED, UI_VERIFIED + "Badge"); c.Add(new CuiLabel { Text = { Text = "✓", FontSize = 18, Align = TextAnchor.MiddleCenter, Color = "1 1 1 1", Font = "robotocondensed-bold.ttf" }, RectTransform = { AnchorMin = "0 0", AnchorMax = "1 1" } }, UI_VERIFIED + "Badge"); // tytul tuz obok badge (grupa wizualnie wysrodkowana) c.Add(new CuiLabel { Text = { Text = L("verified_title"), FontSize = 14, Align = TextAnchor.MiddleLeft, Color = "0.93 0.97 0.92 1", Font = "robotocondensed-bold.ttf" }, RectTransform = { AnchorMin = "0.33 0.42", AnchorMax = "0.86 0.86" } }, UI_VERIFIED); // maly druczek w prawym dolnym rogu c.Add(new CuiLabel { Text = { Text = L("verified_sub"), FontSize = 8, Align = TextAnchor.MiddleRight, Color = "0.5 0.7 0.52 1" }, RectTransform = { AnchorMin = "0.40 0.09", AnchorMax = "0.972 0.32" } }, UI_VERIFIED); CuiHelper.AddUi(player, c); timer.Once(5f, () => { if (player != null) CuiHelper.DestroyUi(player, UI_VERIFIED); }); } void OnPlayerDisconnected(BasePlayer player) { if (player != null) { CuiHelper.DestroyUi(player, UI_ROOT); CuiHelper.DestroyUi(player, UI_VERIFIED); _bannerShown.Remove(player.userID); } if (!_pvpActive || player == null) return; string sid = player.UserIDString; if (!_pvpBaseline.TryGetValue(sid, out var baseV)) return; var now = ReadCoreStats(sid, out bool loaded); _pvpBaseline.Remove(sid); if (!loaded) return; // brak pewnych danych Core — nie raportuj koncowej delty var row = MakeDelta(sid, player.displayName, baseV, now); if (row != null) PushPvp(new List { row }); } private void PvpTick() { if (!_pvpActive) return; var batch = new List(); foreach (var p in BasePlayer.activePlayerList) { if (p == null) continue; string sid = p.UserIDString; var now = ReadCoreStats(sid, out bool loaded); if (!loaded) continue; // Core jeszcze nie wczytal gracza — nie baseline, nie raportuj if (!_pvpBaseline.TryGetValue(sid, out var baseV)) { _pvpBaseline[sid] = now; continue; } // 1. wczytany odczyt = baseline (delta 0) var row = MakeDelta(sid, p.displayName, baseV, now); _pvpBaseline[sid] = now; if (row != null) batch.Add(row); } PushPvp(batch); } private Dictionary MakeDelta(string sid, string name, double[] baseV, double[] now) { double dk = Math.Max(0, now[0] - baseV[0]); double dd = Math.Max(0, now[1] - baseV[1]); double dh = Math.Max(0, now[2] - baseV[2]); double db = Math.Max(0, now[3] - baseV[3]); double ds = Math.Max(0, now[4] - baseV[4]); double dz = Math.Max(0, now[5] - baseV[5]); double da = Math.Max(0, now[6] - baseV[6]); if (dk == 0 && dd == 0 && dh == 0 && db == 0 && ds == 0 && dz == 0 && da == 0) return null; return new Dictionary { ["steamId"] = sid, ["name"] = name ?? "", ["k"] = dk, ["d"] = dd, ["heli"] = dh, ["brad"] = db, ["sci"] = ds, ["zomb"] = dz, ["anim"] = da, }; } private void PushPvp(List players) { if (players == null || players.Count == 0) return; if (string.IsNullOrEmpty(_serverId)) _serverId = LoadOrCreateServerId(); var payload = new Dictionary { ["serverId"] = _serverId, ["token"] = _cfg.Token ?? "", ["players"] = players, }; string body = JsonConvert.SerializeObject(payload); var headers = new Dictionary { ["Content-Type"] = "application/json" }; webrequest.Enqueue(PvpUrl, body, (code, response) => { if (code == 200) { _verified = true; Puts($"[Net] PVP report sent ({players.Count} players)."); } else if (code == 403 || code == 401) { _verified = false; PrintWarning($"[Net] PVP rejected ({code}) - server not verified / bad token."); } else if (code == 0) Puts("[Net] PVP master unreachable (timeout / no network)."); else PrintWarning($"[Net] PVP rejected ({code}): {response}"); }, this, RequestMethod.POST, headers, 10f); } // Pusty push = sonda statusu weryfikacji (200 = verified, 403/401 = nie). private void CheckVerified() { if (string.IsNullOrEmpty(_serverId)) _serverId = LoadOrCreateServerId(); var payload = new Dictionary { ["serverId"] = _serverId, ["token"] = _cfg.Token ?? "", ["players"] = new List(), }; string body = JsonConvert.SerializeObject(payload); var headers = new Dictionary { ["Content-Type"] = "application/json" }; webrequest.Enqueue(PvpUrl, body, (code, response) => { if (code == 200) _verified = true; else if (code == 403 || code == 401) _verified = false; }, this, RequestMethod.POST, headers, 10f); } // -- Panel /global (CUI) -------------------------------------- [ChatCommand("global")] private void CmdGlobal(BasePlayer player, string command, string[] args) { if (player == null) return; if (!_verified) { SendReply(player, "[Majestat] " + L("not_member")); return; } string sid = player.UserIDString; EnsureTopList(() => { if (_globalRows == null) { SendReply(player, "[Majestat] " + L("unavailable")); return; } FetchMyRank(sid, (me) => { if (player != null && player.IsConnected) BuildGlobalPanel(player, me); }); }); } // Lista TOP z cache (fetch tylko gdy nieswieza). private void EnsureTopList(Action onDone) { if (_globalRows != null && (UnityEngine.Time.realtimeSinceStartup - _globalAt) < GlobalTtl) { onDone?.Invoke(); return; } FetchGlobal(onDone); } // Pozycja konkretnego gracza (per otwarcie, bez cache — rzadkie). private void FetchMyRank(string steamId, Action onDone) { string url = RankUrl + "?id=" + steamId; webrequest.Enqueue(url, null, (code, response) => { MeRank me = null; if (code == 200 && !string.IsNullOrEmpty(response)) { try { me = JsonConvert.DeserializeObject(response); } catch { me = null; } } onDone?.Invoke(me); }, this, RequestMethod.GET, null, 8f); } [ConsoleCommand("mtglobal.close")] private void CcGlobalClose(ConsoleSystem.Arg arg) { var p = arg?.Connection?.player as BasePlayer; if (p != null) CuiHelper.DestroyUi(p, UI_ROOT); } private void FetchGlobal(Action onDone) { if (_globalFetching) { timer.Once(0.6f, () => onDone?.Invoke()); return; } _globalFetching = true; string url = GlobalUrl + "?top=" + GlobalTop; webrequest.Enqueue(url, null, (code, response) => { _globalFetching = false; if (code == 200 && !string.IsNullOrEmpty(response)) { try { var resp = JsonConvert.DeserializeObject(response); if (resp != null && resp.ok && resp.rows != null) { _globalRows = resp.rows; _globalServers = resp.servers; _globalTotal = resp.total; _globalAt = UnityEngine.Time.realtimeSinceStartup; } } catch { } } onDone?.Invoke(); }, this, RequestMethod.GET, null, 8f); } private void Lbl(CuiElementContainer c, string parent, string text, double xmin, double xmax, double ymin, double ymax, TextAnchor align, int size, string color, bool bold = false) { c.Add(new CuiLabel { Text = { Text = text, FontSize = size, Align = align, Color = color, Font = bold ? "robotocondensed-bold.ttf" : "robotocondensed-regular.ttf" }, RectTransform = { AnchorMin = $"{xmin} {ymin}", AnchorMax = $"{xmax} {ymax}" } }, parent); } private void AvatarImg(CuiElementContainer c, string parent, string url, double x0, double x1, double y0, double y1) { // ramka/tlo (widoczna tez gdy brak awatara) c.Add(new CuiPanel { Image = { Color = "0.22 0.19 0.16 1" }, RectTransform = { AnchorMin = $"{x0} {y0}", AnchorMax = $"{x1} {y1}" } }, parent); if (string.IsNullOrEmpty(url)) return; c.Add(new CuiElement { Parent = parent, Components = { new CuiRawImageComponent { Url = url }, new CuiRectTransformComponent { AnchorMin = $"{x0} {y0}", AnchorMax = $"{x1} {y1}" } } }); } private void BuildGlobalPanel(BasePlayer player, MeRank me) { CuiHelper.DestroyUi(player, UI_ROOT); var c = new CuiElementContainer(); // tlo + blokada kursora c.Add(new CuiPanel { Image = { Color = "0 0 0 0.65" }, RectTransform = { AnchorMin = "0 0", AnchorMax = "1 1" }, CursorEnabled = true }, "Overlay", UI_ROOT); // karta (szersza - miesci avatar + kolumne XP) c.Add(new CuiPanel { Image = { Color = "0.13 0.11 0.09 0.99" }, RectTransform = { AnchorMin = "0.5 0.5", AnchorMax = "0.5 0.5", OffsetMin = "-460 -255", OffsetMax = "460 255" } }, UI_ROOT, UI_CARD); // lewy pasek akcentu c.Add(new CuiPanel { Image = { Color = "0.83 0.33 0.12 1" }, RectTransform = { AnchorMin = "0 0", AnchorMax = "0.005 1" } }, UI_CARD); // naglowek - jedna linia: tytul (lewo) | MAJESTAT NETWORK (srodek) | PLAYERS N (prawo) const double hdy0 = 0.905, hdy1 = 0.965; Lbl(c, UI_CARD, L("panel_title"), 0.025, 0.46, hdy0, hdy1, TextAnchor.MiddleLeft, 18, "0.91 0.89 0.85 1", true); Lbl(c, UI_CARD, "MAJESTAT NETWORK", 0.30, 0.70, hdy0, hdy1, TextAnchor.MiddleCenter, 12, "0.70 0.66 0.58 1", true); Lbl(c, UI_CARD, L("players"), 0.70, 0.90, hdy0, hdy1, TextAnchor.MiddleRight, 11, "0.55 0.51 0.45 1"); Lbl(c, UI_CARD, _globalTotal.ToString(), 0.905, 0.985, hdy0, hdy1, TextAnchor.MiddleRight, 17, "0.94 0.44 0.18 1", true); // linia oddzielajaca pod naglowkiem (taka sama jak nad "Twoja pozycja") c.Add(new CuiPanel { Image = { Color = "0.83 0.33 0.12 0.5" }, RectTransform = { AnchorMin = "0.012 0.892", AnchorMax = "0.99 0.895" } }, UI_CARD); // kolumny: avatar | # | GRACZ | [XP] | KILLS DEATHS K/D | HELI BRAD // rowne szerokosci, grupy oddzielone wiekszym odstepem double cAvL=0.016, cAvR=0.044, cNumL=0.048, cNumR=0.090, cNmL=0.095, cNmR=0.44, cXpL=0.454, cXpR=0.532, cKL=0.562, cKR=0.640, cDL=0.640, cDR=0.718, cKdL=0.718, cKdR=0.796, cHeL=0.826, cHeR=0.904, cBrL=0.904, cBrR=0.982; const double hy0 = 0.85, hy1 = 0.886; string hCol = "0.55 0.51 0.45 1"; Lbl(c, UI_CARD, "#", cNumL, cNumR, hy0, hy1, TextAnchor.MiddleLeft, 10, hCol); Lbl(c, UI_CARD, L("col_player"), cNmL, cNmR, hy0, hy1, TextAnchor.MiddleLeft, 10, hCol); Lbl(c, UI_CARD, "XP", cXpL, cXpR, hy0, hy1, TextAnchor.MiddleCenter, 10, hCol); Lbl(c, UI_CARD, "KILLS", cKL, cKR, hy0, hy1, TextAnchor.MiddleCenter, 10, hCol); Lbl(c, UI_CARD, "DEATHS", cDL, cDR, hy0, hy1, TextAnchor.MiddleCenter, 10, hCol); Lbl(c, UI_CARD, "K/D", cKdL, cKdR, hy0, hy1, TextAnchor.MiddleCenter, 10, hCol); Lbl(c, UI_CARD, "HELI", cHeL, cHeR, hy0, hy1, TextAnchor.MiddleCenter, 10, hCol); Lbl(c, UI_CARD, "BRAD", cBrL, cBrR, hy0, hy1, TextAnchor.MiddleCenter, 10, hCol); // wiersze TOP (stala wysokosc -> od gory) int n = Math.Min(_globalRows.Count, GlobalTop); const double top = 0.82, bot = 0.205; double rowH = (top - bot) / GlobalTop; string myId = player.UserIDString; for (int i = 0; i < n; i++) { var r = _globalRows[i]; double yTop = top - i * rowH; double yBot = yTop - rowH + 0.003; bool mine = r.steamId == myId; if (mine) c.Add(new CuiPanel { Image = { Color = "0.83 0.33 0.12 0.16" }, RectTransform = { AnchorMin = $"0.012 {yBot}", AnchorMax = $"0.99 {yTop}" } }, UI_CARD); else if (i % 2 == 1) c.Add(new CuiPanel { Image = { Color = "1 1 1 0.025" }, RectTransform = { AnchorMin = $"0.012 {yBot}", AnchorMax = $"0.99 {yTop}" } }, UI_CARD); string rankColor = i == 0 ? "0.78 0.66 0.29 1" : (i == 1 ? "0.80 0.82 0.85 1" : (i == 2 ? "0.78 0.53 0.29 1" : "0.55 0.51 0.45 1")); string nm = string.IsNullOrEmpty(r.name) ? r.steamId : r.name; if (nm.Length > 24) nm = nm.Substring(0, 22) + ".."; string nmCol = mine ? "0.97 0.86 0.7 1" : "0.91 0.89 0.85 1"; AvatarImg(c, UI_CARD, r.avatar, cAvL, cAvR, yBot, yTop); Lbl(c, UI_CARD, (i + 1).ToString(), cNumL, cNumR, yBot, yTop, TextAnchor.MiddleLeft, 13, rankColor, true); Lbl(c, UI_CARD, nm, cNmL, cNmR, yBot, yTop, TextAnchor.MiddleLeft, 12, nmCol); Lbl(c, UI_CARD, r.xp.ToString(), cXpL, cXpR, yBot, yTop, TextAnchor.MiddleCenter, 13, "0.95 0.78 0.30 1", true); Lbl(c, UI_CARD, r.kills.ToString(), cKL, cKR, yBot, yTop, TextAnchor.MiddleCenter, 13, "0.94 0.44 0.18 1", true); Lbl(c, UI_CARD, r.deaths.ToString(), cDL, cDR, yBot, yTop, TextAnchor.MiddleCenter, 12, "0.85 0.82 0.76 1"); Lbl(c, UI_CARD, r.kd.ToString("0.00"), cKdL, cKdR, yBot, yTop, TextAnchor.MiddleCenter, 12, "0.91 0.64 0.24 1"); Lbl(c, UI_CARD, r.heli.ToString(), cHeL, cHeR, yBot, yTop, TextAnchor.MiddleCenter, 12, "0.66 0.62 0.55 1"); Lbl(c, UI_CARD, r.bradley.ToString(), cBrL, cBrR, yBot, yTop, TextAnchor.MiddleCenter, 12, "0.66 0.62 0.55 1"); } if (n == 0) Lbl(c, UI_CARD, L("no_data"), 0.03, 0.985, 0.45, 0.6, TextAnchor.MiddleCenter, 13, "0.66 0.62 0.55 1"); // separator + podpis "TWOJA POZYCJA" (jak w Core /top) c.Add(new CuiPanel { Image = { Color = "0.83 0.33 0.12 0.5" }, RectTransform = { AnchorMin = "0.012 0.198", AnchorMax = "0.99 0.200" } }, UI_CARD); Lbl(c, UI_CARD, L("your_pos"), cNmL, 0.6, 0.166, 0.194, TextAnchor.MiddleLeft, 10, "0.83 0.55 0.32 1", true); // wiersz gracza - IDENTYCZNY format i wysokosc jak na liscie double myTop = 0.162, myBot = myTop - rowH + 0.003; c.Add(new CuiPanel { Image = { Color = "0.83 0.33 0.12 0.16" }, RectTransform = { AnchorMin = $"0.012 {myBot}", AnchorMax = $"0.99 {myTop}" } }, UI_CARD); if (me != null && me.rank > 0) { string myName = player.displayName ?? ""; if (string.IsNullOrEmpty(myName)) myName = myId; if (myName.Length > 24) myName = myName.Substring(0, 22) + ".."; AvatarImg(c, UI_CARD, me.avatar, cAvL, cAvR, myBot, myTop); Lbl(c, UI_CARD, me.rank.ToString(), cNumL, cNumR, myBot, myTop, TextAnchor.MiddleLeft, 13, "0.94 0.44 0.18 1", true); Lbl(c, UI_CARD, myName, cNmL, cNmR, myBot, myTop, TextAnchor.MiddleLeft, 12, "0.97 0.86 0.7 1"); Lbl(c, UI_CARD, me.xp.ToString(), cXpL, cXpR, myBot, myTop, TextAnchor.MiddleCenter, 13, "0.95 0.78 0.30 1", true); Lbl(c, UI_CARD, me.kills.ToString(), cKL, cKR, myBot, myTop, TextAnchor.MiddleCenter, 13, "0.94 0.44 0.18 1", true); Lbl(c, UI_CARD, me.deaths.ToString(), cDL, cDR, myBot, myTop, TextAnchor.MiddleCenter, 12, "0.85 0.82 0.76 1"); Lbl(c, UI_CARD, me.kd.ToString("0.00"), cKdL, cKdR, myBot, myTop, TextAnchor.MiddleCenter, 12, "0.91 0.64 0.24 1"); Lbl(c, UI_CARD, me.heli.ToString(), cHeL, cHeR, myBot, myTop, TextAnchor.MiddleCenter, 12, "0.66 0.62 0.55 1"); Lbl(c, UI_CARD, me.bradley.ToString(), cBrL, cBrR, myBot, myTop, TextAnchor.MiddleCenter, 12, "0.66 0.62 0.55 1"); } else { Lbl(c, UI_CARD, L("no_pos"), cNmL, 0.985, myBot, myTop, TextAnchor.MiddleLeft, 11, "0.8 0.74 0.66 1"); } // dol: po lewej info o sieci, po prawej ZAMKNIJ Lbl(c, UI_CARD, L("verified_srv") + _globalServers, 0.025, 0.78, 0.04, 0.10, TextAnchor.MiddleLeft, 11, "0.6 0.56 0.5 1"); c.Add(new CuiButton { Button = { Command = "mtglobal.close", Color = "0.83 0.33 0.12 1" }, Text = { Text = L("close"), FontSize = 13, Align = TextAnchor.MiddleCenter, Color = "1 1 1 1", Font = "robotocondensed-bold.ttf" }, RectTransform = { AnchorMin = "0.82 0.035", AnchorMax = "0.985 0.105" } }, UI_CARD); CuiHelper.AddUi(player, c); } // -- Update checker ------------------------------------------- private void CheckForUpdate() { if (!_active) return; // Zbierz wszystkie zaladowane pluginy z grupy MajestatTop (Core, Net, moduly). var group = new List(); foreach (var p in plugins.GetAll()) if (p != null && !string.IsNullOrEmpty(p.Name) && p.Name.StartsWith("MajestatTop")) group.Add(p); if (group.Count == 0) return; Puts($"[Update] Sprawdzam {group.Count} plugin(ow) MajestatTop... (Net build {Build})"); int pending = group.Count; int updates = 0; foreach (var p in group) { string name = p.Name; // lokalne kopie do domkniecia string cur = p.Version.ToString(); string url = UpdateBase + name + "/version.json"; webrequest.Enqueue(url, null, (code, response) => { try { if (code == 200 && !string.IsNullOrEmpty(response)) { var data = JsonConvert.DeserializeObject>(response); string latest = (data != null && data.ContainsKey("version")) ? data["version"] : null; if (latest != null && IsNewerVersion(latest, cur)) { updates++; PrintWarning($"[Update] {name}: dostepna nowa wersja {latest} (masz {cur})."); MajestatTopCore?.Call("API_ReportUpdateResult", name, latest, ""); } else { Puts($"[Update] {name}: aktualny ({cur})."); MajestatTopCore?.Call("API_ReportUpdateResult", name, "", ""); } } else { Puts($"[Update] {name}: sprawdzenie nieudane (HTTP {code})."); } } catch { Puts($"[Update] {name}: blad odczytu odpowiedzi."); } finally { pending--; if (pending <= 0) Puts($"[Update] Zakonczono. Dostepnych aktualizacji: {updates}. (Net {Build})"); } }, this); } } 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 = Array.ConvertAll(lc.Split('.'), int.Parse); var c = Array.ConvertAll(cc.Split('.'), int.Parse); int len = 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; } } // -- Heartbeat ------------------------------------------------ private const string IdDataFile = "MajestatServerId"; private const string IdLegacyCore = "MajestatTopCore_id"; private const string IdLegacyNet = "MajestatTopNet_id"; private string ReadIdFile(string name) { try { var d = Interface.Oxide.DataFileSystem.ReadObject>(name); if (d != null && d.TryGetValue("serverId", out var v) && !string.IsNullOrEmpty(v)) return v; } catch { } return null; } private string LoadOrCreateServerId() { // 1) cfg.ServerId ma PRIORYTET: jesli ustawione (np. id 'v...' nadane przez master dla vanilla), // zapisz je do oxide/data (nadpisujac dotychczasowe) i uzyj - serwer sklei sie z istniejacym wpisem. string cfgId = _cfg?.ServerId == null ? "" : _cfg.ServerId.Trim(); if (!string.IsNullOrEmpty(cfgId)) { string current = ReadIdFile(IdDataFile); if (!string.Equals(current, cfgId, StringComparison.Ordinal)) { try { Interface.Oxide.DataFileSystem.WriteObject(IdDataFile, new Dictionary { ["serverId"] = cfgId }); PrintWarning($"[Net] Server ID set from config: {cfgId} (saved to data, overriding previous)."); } catch { } } return cfgId; } // 2) brak w cfg -> jak dotad: data -> legacy -> generacja, z zapisem do data string id = ReadIdFile(IdDataFile); if (string.IsNullOrEmpty(id)) { id = ReadIdFile(IdLegacyCore); if (string.IsNullOrEmpty(id)) id = ReadIdFile(IdLegacyNet); if (string.IsNullOrEmpty(id) && !string.IsNullOrEmpty(_legacyServerId)) id = _legacyServerId; if (string.IsNullOrEmpty(id)) id = Guid.NewGuid().ToString("N"); try { Interface.Oxide.DataFileSystem.WriteObject(IdDataFile, new Dictionary { ["serverId"] = id }); } catch { } Puts($"[Net] Server ID: {id}"); } return id; } // Roster graczy online: gdy jest Core -> pełne staty z API; standalone -> sam steamId+nick. private object BuildOnlineRoster() { var fromCore = MajestatTopCore?.Call("API_GetOnlinePlayers"); if (fromCore != null) return fromCore; // List> z Core var list = new List>(); foreach (var bp in BasePlayer.activePlayerList) { if (bp == null) continue; string id = bp.UserIDString; if (string.IsNullOrEmpty(id)) continue; list.Add(new Dictionary { ["steamId"] = id, ["name"] = bp.displayName ?? id, ["xp"] = 0, ["kills"] = 0, ["deaths"] = 0, ["playtime"] = 0, }); } return list; } private void FetchPublicIp() { webrequest.Enqueue("https://api.ipify.org", null, (code, body) => { if (code == 200 && !string.IsNullOrEmpty(body)) { _publicIp = body.Trim(); Puts($"[Net] Public IP: {_publicIp}"); } else Puts("[Net] Could not detect public IP - Master will use the connection address."); }, this); } private static readonly HashSet AllowedTypes = new HashSet { "pve","roleplay","creative","minigame","training","battlefield","broyale", "builds","tut","premium","modded","oxide","carbon" }; private static readonly HashSet AllowedWipes = new HashSet { "monthly","biweekly","weekly" }; private static string ValidType(string v) { v = (v ?? "").Trim().ToLowerInvariant(); return AllowedTypes.Contains(v) ? v : "modded"; } private static string ValidWipe(string v) { v = (v ?? "").Trim().ToLowerInvariant(); return AllowedWipes.Contains(v) ? v : ""; } private void SendHeartbeat() { if (!_active) return; if (string.IsNullOrEmpty(_serverId)) _serverId = LoadOrCreateServerId(); int gamePort = _cfg.GamePort > 0 ? _cfg.GamePort : ConVar.Server.port; int queryPort = ConVar.Server.queryport > 0 ? ConVar.Server.queryport : gamePort; string type = ValidType(_cfg.ServerType); long wipedAt = 0; try { var t = SaveRestore.SaveCreatedTime; if (t > new DateTime(2000, 1, 1)) wipedAt = ((DateTimeOffset)t.ToUniversalTime()).ToUnixTimeSeconds(); } catch { } // Tożsamość zgłaszana do mastera: // • Core obecny -> "MajestatTopCore" + wersja Core (lista pokaże CORE x.y.z) // • Net sam -> "MajestatTopNet" + Build Net (lista pokaże NET ...) bool corePresent = MajestatTopCore != null; string clientName = corePresent ? "MajestatTopCore" : "MajestatTopNet"; string clientVer = corePresent ? (MajestatTopCore.Version.ToString()) : Build; var payload = new Dictionary { ["serverId"] = _serverId, ["token"] = _cfg.Token ?? "", ["owner"] = _cfg.OwnerSteamId ?? "", ["client"] = clientName, ["version"] = clientVer, ["name"] = ConVar.Server.hostname, ["description"]= ConVar.Server.description, ["tags"] = ConVar.Server.tags, ["headerImage"]= ConVar.Server.headerimage, ["url"] = ConVar.Server.url, ["pve"] = ConVar.Server.pve, ["official"] = ConVar.Server.official, ["players"] = BasePlayer.activePlayerList.Count, ["maxPlayers"] = ConVar.Server.maxplayers, ["map"] = World.Name, ["worldSize"] = World.Size, ["seed"] = World.Seed, ["uptime"] = (int)UnityEngine.Time.realtimeSinceStartup, ["ip"] = _publicIp, ["gamePort"] = gamePort, ["queryPort"] = queryPort, ["webPort"] = _cfg.WebPort, ["hasWeb"] = plugins.Find("MajestatTopWeb") != null, ["webUrl"] = _cfg.WebUrl ?? "", ["type"] = type, ["wipe"] = ValidWipe(_cfg.Wipe), ["wipedAt"] = wipedAt, ["online"] = BuildOnlineRoster(), }; string body = JsonConvert.SerializeObject(payload); var headers = new Dictionary { ["Content-Type"] = "application/json" }; webrequest.Enqueue(MasterUrl, body, (code, response) => { if (code == 200) { Puts("[Net] Heartbeat OK."); HandleResponse(response); } else if (code == 0) Puts("[Net] Master unreachable (timeout / no network)."); else PrintWarning($"[Net] Master rejected ({code}): {response}"); }, this, RequestMethod.POST, headers, 10f); } private int _premiumState = -1; private bool _dupWarned = false; private void HandleResponse(string response) { try { var data = JsonConvert.DeserializeObject>(response); if (data == null) return; // Master sygnalizuje, ze ten sam IP:port istnieje juz pod INNYM serverId -> nie zakladaj duplikatu. if (data.ContainsKey("duplicate") && Convert.ToBoolean(data["duplicate"])) { string existing = data.ContainsKey("existingId") && data["existingId"] != null ? data["existingId"].ToString() : ""; if (!_dupWarned) { _dupWarned = true; PrintWarning("[Net] DUPLICATE: serwer o tym IP:port istnieje juz w sieci jako ID '" + existing + "'. Aby skleic wpisy, ustaw w configu \"Server ID (puste = auto)\": \"" + existing + "\" i przeladuj plugin. Master NIE zakłada drugiego wpisu."); } return; } if (data.ContainsKey("verified")) _verified = Convert.ToBoolean(data["verified"]); bool premium = data.ContainsKey("premium") && Convert.ToBoolean(data["premium"]); bool expired = data.ContainsKey("premiumExpired") && Convert.ToBoolean(data["premiumExpired"]); int state = premium ? 1 : (expired ? 2 : 0); if (state == _premiumState) return; _premiumState = state; if (state == 1) { string until = ""; if (data.ContainsKey("premiumUntil") && data["premiumUntil"] != null) { long ts = Convert.ToInt64(data["premiumUntil"]); until = " (until " + DateTimeOffset.FromUnixTimeSeconds(ts) .LocalDateTime.ToString("yyyy-MM-dd") + ")"; } Puts("[Net] Premium subscription active" + until + "."); } else if (state == 2) PrintWarning("[Net] Premium subscription expired."); } catch { } } } }