using System; using System.Collections.Generic; using System.Linq; using Oxide.Core; using Oxide.Core.Database; using Oxide.Core.Plugins; using Oxide.Game.Rust.Cui; using UnityEngine; using Newtonsoft.Json; /* * MajestatTopCore 3.2.0 * ================== * Centralny framework. Pełna kompatybilność z MajestatTop 2.9.2. * * API DLA MODUŁÓW: * API_RegisterTab(Dictionary tabDef) * API_UnregisterTab(string tabId) * API_AddStat(string steamId, string statKey, double amount, bool fame, bool xp) * API_GetStat(string steamId, string statKey, bool fame) -> double * API_GetAllStats(string steamId, bool fame) -> Dictionary * API_GetMapData(string steamId, bool isAdmin) -> List> * API_GetMonuments() -> List> * API_GetMapInfo() -> Dictionary * API_GetChatHistory(int limit, string channel) -> List> * API_SubscribeChat(Action callback) * API_UnsubscribeChat(Action callback) * API_SendWebChat(string nick, string text, string channel, string steamId, bool isAdmin) * API_SubscribeMap(Action callback) * API_UnsubscribeMap(Action callback) * * ZAKŁADKA TAB DEFINITION (słownik przekazywany przez API): * "id" string — unikalny identyfikator * "label" string — wyświetlana nazwa * "sort" string — stat_key do sortowania (sezonowe) * "fame_sort" string — stat_key do sortowania (fame) * "order" int — kolejność (0=TOP PvP, 999=ostatnia przed FAME) * "columns" List> — [{key, header}, ...] */ namespace Oxide.Plugins { [Info("MajestatTopCore", "Wo0t", "3.2.0")] [Description("Central stats framework for MajestatTop — DB, cache, UI, XP, leaderboards, map, chat, module API.")] class MajestatTopCore : RustPlugin { // ------------------------------------------------------------- // BIBLIOTEKI DB // ------------------------------------------------------------- private Core.MySql.Libraries.MySql _mySql = Interface.GetMod().GetLibrary(); private Core.SQLite.Libraries.SQLite _sqlite = Interface.GetMod().GetLibrary(); private Connection _dbConn; private Timer _saveTimer; private Timer _lbCacheTimer; private Timer _heartbeatTimer; private string _publicIp = ""; private string _serverId = ""; private string _legacyServerId = null; private const string PermAdmin = "majestattopcore.admin"; // ------------------------------------------------------------- // KONFIGURACJA // ------------------------------------------------------------- private class ConfigData { // -- Baza danych ------------------------------------------ [JsonProperty("Database Mode (JSON, SQLITE, MYSQL)")] public string Mode = "JSON"; [JsonProperty("MySQL Host")] public string Host = "localhost"; [JsonProperty("MySQL Port")] public int Port = 3306; [JsonProperty("MySQL Database Name")] public string Database = "rust_stats"; [JsonProperty("MySQL User")] public string User = "root"; [JsonProperty("MySQL Password")] public string Password = "password"; // -- Funkcje ---------------------------------------------- [JsonProperty("Enable Fame Leaderboard (/fame)")] public bool EnableFame = true; [JsonProperty("Leaderboard Sorting Mode (XP, PVP_KILLS, PLAY_TIME)")] public string SortLeaderboardBy = "XP"; [JsonProperty("Fame Leaderboard Sorting Mode (XP, PVP_KILLS, PLAY_TIME)")] public string SortFameBy = "XP"; // -- Widoczność kolumn (zakładka PvP) --------------------- [JsonProperty("Show Total XP Column")] public bool ShowTotalXP = true; [JsonProperty("Show PvP Kills Column")] public bool ShowPvpKills = true; [JsonProperty("Show PvP Deaths Column")] public bool ShowPvpDeaths = true; [JsonProperty("Show PvE Deaths Column")] public bool ShowPveDeaths = true; [JsonProperty("Show Heli Kills Column")] public bool ShowHeliKills = true; [JsonProperty("Show Bradley Kills Column")] public bool ShowBradleyKills = true; [JsonProperty("Show Scientist Kills Column")]public bool ShowScientistKills= true; [JsonProperty("Show Zombie Kills Column")] public bool ShowZombieKills = true; [JsonProperty("Show Animal Kills Column")] public bool ShowAnimalKills = true; [JsonProperty("Show Play Time Column")] public bool ShowPlayTime = true; // -- Punkty XP (kompatybilność z 2.9.2) ------------------- [JsonProperty("Points Per PvP Kill")] public int PointsPerPvpKill = 5; [JsonProperty("Points Per Zombie Kill")] public int PointsPerZombieKill = 2; [JsonProperty("Points Per Animal Kill")] public int PointsPerAnimalKill = 1; [JsonProperty("Points Per Bradley Kill")] public int PointsPerBradleyKill = 20; [JsonProperty("Points Per Heli Kill")] public int PointsPerHeliKill = 20; [JsonProperty("Points Per Scientist Kill")] public int PointsPerScientistKill = 3; [JsonProperty("Points Per 30 Min Play Time")]public int PointsPer30MinPlayTime = 1; // -- Zapis / cache ----------------------------------------- [JsonProperty("Auto-Save Interval (seconds)")] public float AutoSaveInterval = 60f; // -- Update checker ---------------------------------------- [JsonProperty("Check for updates on startup")] public bool CheckUpdates = true; // 0 = tylko przy starcie, 720 = 12h, 1440 = 24h [JsonProperty("Update Check Interval (minutes, 0 = startup only)")] public int UpdateCheckIntervalMinutes = 60; [JsonProperty("Unknown NPC fallback category (animal, scientist, zombie, none)")] public string UnknownNpcFallback = "animal"; // -- Poziom logowania -------------------------------------- // all = wszystko (eventy gry: kills, gather, build + system) // system = starty modułów, rejestracje, update (domyślne — czyste logi) // alert = tylko błędy i dostępne aktualizacje // none = cisza [JsonProperty("Log Level (all, system, alert, none)")] public string LogLevel = "system"; [JsonProperty("Leaderboard Cache TTL (seconds)")] public float LeaderboardCacheTTL = 30f; [JsonProperty("Max rows per leaderboard tab")] public int LeaderboardRows = 10; // -- Format wyświetlania liczb ----------------------------- // none = pełne liczby (123456789) // K = skracaj od 1 000 (1.2k, 999 999) // M = skracaj od 1 000 000 (1.2M) — domyślne // KM = skracaj K i M (1.2k, 1.2M) [JsonProperty("Number Format (none, K, M, KM)")] public string NumberFormat = "M"; // Ile miejsc po przecinku przy skracaniu (0-3) [JsonProperty("Number Format Decimal Places")] public int NumberFormatDecimals = 1; // -- XP Sources (wypełniane przez moduły przez API) ------- // Moduły dodają swoje klucze przez API_RegisterXpSource(). [JsonProperty("XP Sources (modules — managed automatically)")] public Dictionary XpSources = new Dictionary(); // -- Auto-reload modułów przy przeładowaniu Core ----------- [JsonProperty("Auto-reload modules on Core reload")] public bool AutoReloadModules = true; // Opóźnienie (sekundy) między przeładowaniem kolejnych modułów // Minimum 3-4s — kompilacja Roslyn potrzebuje czasu i ~200MB RAM [JsonProperty("Auto-reload delay between modules (seconds)")] public float AutoReloadDelay = 4.0f; // Kolejność przeładowania — pluginy które nie są załadowane są pomijane [JsonProperty("Auto-reload module order", ObjectCreationHandling = ObjectCreationHandling.Replace)] public List AutoReloadOrder = new List { "MajestatTopBuild", "MajestatTopGather", "MajestatTopLoot", "MajestatTopWeb", "MajestatTopMap", "MajestatTopInfo" }; // -- Heartbeat → Master (server list) --------------------- // Verification token — issued in the Master panel; gives the ✓ badge. [JsonProperty("Heartbeat - Token")] public string HeartbeatToken = ""; [JsonProperty("Heartbeat - Web Port")] public int HeartbeatWebPort = 28020; // Empty = auto http://PublicIP:WebPort/. Set when Web is behind a proxy/domain. [JsonProperty("Heartbeat - Web URL")] public string HeartbeatWebUrl = ""; [JsonProperty("Heartbeat - Game Port")] public int HeartbeatGamePort = 0; // Allowed: modded oxide carbon pve roleplay creative minigame // training battlefield broyale builds tut premium [JsonProperty("Heartbeat - Type")] public string HeartbeatServerType = "modded"; // Allowed: monthly biweekly weekly [JsonProperty("Heartbeat - Wipe")] public string HeartbeatWipe = "monthly"; } private ConfigData _cfg; private int _logLevel = 1; // 0=all 1=system 2=alert 3=none protected override void LoadConfig() { base.LoadConfig(); // przechwyć stare ID (migracja do data) try { _legacyServerId = Config["Heartbeat - Server ID (auto, nie ruszaj)"] as string; } catch { _legacyServerId = null; } try { _cfg = Config.ReadObject(); if (_cfg == null) throw new Exception("pusty config"); _cfg.HeartbeatServerType = ValidType(_cfg.HeartbeatServerType); _cfg.HeartbeatWipe = ValidWipe(_cfg.HeartbeatWipe); 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 saved: {bak}. " + "Loaded defaults in memory and did NOT overwrite the file - fix it and reload."); } } catch { PrintError("[Config] Corrupt - loaded defaults (backup failed)."); } } _logLevel = LogLevelInt(_cfg.LogLevel); } protected override void LoadDefaultConfig() => _cfg = new ConfigData(); protected override void SaveConfig() => Config.WriteObject(_cfg); private static int LogLevelInt(string s) { switch (s?.ToLower()) { case "all": return 0; case "system": return 1; case "alert": return 2; case "none": return 3; default: return 1; } } // Loguj tylko jeśli poziom jest wystarczający // level: 0=all 1=system 2=alert private void Log(int level, string msg) { if (level < _logLevel) return; // Wycisz poziom system (1) podczas sekwencji bootowania — Core drukuje summary if (level == 1 && _autoReloadActive) return; if (level >= 2) PrintWarning(msg); else Puts(msg); } [HookMethod("API_GetLogLevel")] public int API_GetLogLevel() => _logLevel; [HookMethod("API_IsAutoReloading")] public bool API_IsAutoReloading() => _autoReloadActive; [HookMethod("API_SetModuleExtra")] public void API_SetModuleExtra(string name, string extra) { if (string.IsNullOrEmpty(name)) return; if (_registeredModules.ContainsKey(name)) _registeredModules[name].Extra = extra; } // ------------------------------------------------------------- // PROFIL GRACZA // ------------------------------------------------------------- private class PlayerProfile { public string SteamId; public string Name; public Dictionary Stats = new Dictionary(); public Dictionary FameStats = new Dictionary(); public bool Dirty = false; public float SessionStart = 0f; public double Get(string k) => Stats.TryGetValue(k, out double v) ? v : 0.0; public double GetFame(string k) => FameStats.TryGetValue(k, out double v) ? v : 0.0; public void Add(string k, double a, bool fame) { if (!Stats.ContainsKey(k)) Stats[k] = 0.0; Stats[k] += a; if (fame) { if (!FameStats.ContainsKey(k)) FameStats[k] = 0.0; FameStats[k] += a; } Dirty = true; } } // ------------------------------------------------------------- // CACHE // ------------------------------------------------------------- private Dictionary _cache = new Dictionary(); private HashSet _saveQueue = new HashSet(); // Leaderboard cache: "tabId_seasonal" / "tabId_fame" → wiersze private Dictionary> _lbCache = new Dictionary>(); private class LbEntry { public string SteamId, Name; public Dictionary Stats; public int Rank; } // ------------------------------------------------------------- // ZAKŁADKI — wewnętrzna reprezentacja // ------------------------------------------------------------- private class TabDef { public string Id, Label, Sort, FameSort; public int Order = 100; // Key = stat_key, Header = klucz lang LUB gotowy string // Jeśli Header zaczyna się od "Col" — traktuj jako klucz lang Core // Jeśli nie — wyświetl wprost (nagłówki z modułów zewnętrznych) public List<(string Key, string Header, string LangKey, string Owner)> Columns = new List<(string, string, string, string)>(); } private Dictionary _tabs = new Dictionary(); // ------------------------------------------------------------- // HELI TRACKER // ------------------------------------------------------------- private Dictionary _heliTracker = new Dictionary(); // -- Dane mapowe ---------------------------------------------- public class MapEntity { [JsonProperty("steamId")] public string SteamId = ""; [JsonProperty("nick")] public string Nick = ""; [JsonProperty("x")] public float X; [JsonProperty("z")] public float Z; [JsonProperty("rot")] public float Rot; [JsonProperty("type")] public string Type = "player"; // player/heli/bradley/cargo/ch47 [JsonProperty("teamId")] public long TeamId; [JsonProperty("isOnline")] public bool IsOnline = true; [JsonProperty("hasStock")] public bool HasStock = true; } private Dictionary _mapPlayers = new Dictionary(); private List _mapEntities = new List(); private Timer _mapTimer; // -- Chat multi-kanał ----------------------------------------- public class ChatMsg { [JsonProperty("steamId")] public string SteamId = ""; [JsonProperty("nick")] public string Nick = ""; [JsonProperty("text")] public string Text = ""; [JsonProperty("channel")] public string Channel = "global"; // global/team/clan/friends/server [JsonProperty("source")] public string Source = "game"; // game/web/server [JsonProperty("isAdmin")] public bool IsAdmin; [JsonProperty("timestamp")] public long Timestamp; } private List _chatHistory = new List(); private const int MaxChatHistory = 200; // Subskrybenci SSE — zarejestrowane callbacki z Web/Map pluginów private List> _chatSubscribers = new List>(); private List> _mapSubscribers = new List>(); private void BroadcastChat(ChatMsg msg) { lock (_chatHistory) { _chatHistory.Add(msg); if (_chatHistory.Count > MaxChatHistory) _chatHistory.RemoveAt(0); } // Wywołaj Oxide hook — każdy plugin może odbierać bez problemów z typami Interface.CallHook("OnMajestatChatMessage", msg.SteamId, msg.Nick, msg.Text, msg.Channel, msg.Source, msg.IsAdmin, msg.Timestamp); } // Reset confirmations private HashSet _resetConfirm = new HashSet(); // Słowa-klucze prefabów zwierząt — jedno źródło prawdy dla klasyfikacji zabójstw // i diagnostyki (mtopcore.mapdiag). private static readonly string[] AnimalPrefabKeywords = { "wolf", "bear", "polarbear", "boar", "stag", "deer", "chicken", "horse", "snake", "shark", "cat", "panther", "lion", "tiger", "crocodile", "croc", "rabbit", "fox" }; // Czy encja to zwierzę (po typie bazowym lub prefabie). Wyjątek: scarecrow ≠ crow. private bool IsAnimalEntity(BaseCombatEntity entity, string pfx) { if (entity is BaseAnimalNPC) return true; if (pfx.Contains("crow") && !pfx.Contains("scarecrow")) return true; foreach (var k in AnimalPrefabKeywords) if (pfx.Contains(k)) return true; return false; } void OnPluginLoaded(Plugin p) { if (p == null) return; // Jeśli to znany zewnętrzny plugin — zarejestruj go if (System.Array.IndexOf(_knownExternalPlugins, p.Name) >= 0 && !_registeredModules.ContainsKey(p.Name)) { _registeredModules[p.Name] = new ModuleInfo { Name = p.Name, Version = p.Version.ToString(), Author = p.Author ?? "", Description = "Detected: " + p.Name }; Log(1, $"[Module] Auto-detected (OnLoad): {p.Name} v{p.Version}"); } } void OnPluginUnloaded(Plugin p) { if (p == null) return; if (System.Array.IndexOf(_knownExternalPlugins, p.Name) >= 0) _registeredModules.Remove(p.Name); } // ------------------------------------------------------------- // LANG // ------------------------------------------------------------- protected override void LoadDefaultMessages() { lang.RegisterMessages(new Dictionary { ["TabFame"] = "FAME", ["TitleFame"] = "HALL OF FAME", ["BtnClose"] = "CLOSE", ["ColRank"] = "RANK", ["ColPlayer"] = "PLAYER NAME", ["ColXP"] = "TOTAL\nXP", ["ColKills"] = "PVP\nKILLS", ["ColDeaths"] = "PVP\nDEATHS", ["ColPve"] = "PVE\nDEATHS", ["ColHeli"] = "HELI", ["ColBradley"] = "BRADLEY", ["ColSci"] = "SCIENTIST", ["ColZombie"] = "ZOMBIE", ["ColAnimal"] = "ANIMAL", ["ColTime"] = "TIME", ["YourPos"] = "YOUR POSITION", ["TotalPlayers"] = "TOTAL PLAYERS: {0}", ["NoPermission"] = "ERROR: You do not have permission.", ["ResetWarn"] = "WARNING: About to reset SEASONAL stats. Type /mtopreset confirm within 15s.", ["ResetOk"] = "SUCCESS: Seasonal stats reset.", ["ResetSingle"] = "SUCCESS: Reset stats for {0} ({1}).", ["NotFound"] = "ERROR: Player '{0}' not found.", ["FameDisabled"] = "ERROR: Hall of Fame is disabled.", ["StatsTitle"] = "━━━━ YOUR STATS ━━━━", ["TabTop"] = "TOP", ["TabBuild"] = "BUILD", ["TabGather"] = "GATHER", ["TabLoot"] = "LOOT", ["ColGracz"] = "PLAYER", }, this); lang.RegisterMessages(new Dictionary { ["TabFame"] = "SŁAWA", ["TitleFame"] = "HALL OF FAME", ["BtnClose"] = "ZAMKNIJ", ["ColRank"] = "LP.", ["ColPlayer"] = "PLAYER NAME", ["ColXP"] = "PUNKTY\nXP", ["ColKills"] = "PVP\nZABOJSTWA", ["ColDeaths"] = "PVP\nZGONY", ["ColPve"] = "PVE\nZGONY", ["ColHeli"] = "HELI\nSTRACONY", ["ColBradley"] = "BRADLEY\nZNISZCZONE", ["ColSci"] = "NAUKOWCY", ["ColZombie"] = "ZOMBIE", ["ColAnimal"] = "ZWIERZETA", ["ColTime"] = "CZAS GRY", ["YourPos"] = "TWOJA POZYCJA", ["TotalPlayers"] = "W SUMIE GRACZY: {0}", ["NoPermission"] = "BLĄD: Nie masz uprawnień.", ["ResetWarn"] = "UWAGA: Resetujesz statystyki SEZONOWE. Wpisz /mtopreset confirm w ciągu 15s.", ["ResetOk"] = "SUKCES: Statystyki sezonowe zresetowane.", ["ResetSingle"] = "SUKCES: Zresetowano statystyki dla {0} ({1}).", ["NotFound"] = "BLĄD: Gracz '{0}' nie znaleziony.", ["FameDisabled"] = "BLĄD: Hall of Fame wyłączony.", ["StatsTitle"] = "━━━━ TWOJE STATYSTYKI ━━━━", ["TabTop"] = "TOP", ["TabBuild"] = "BUILD", ["TabGather"] = "GATHER", ["TabLoot"] = "LOOT", ["ColGracz"] = "GRACZ", }, this, "pl"); } private string L(string k, string uid = null) => lang.GetMessage(k, this, uid); // ═════════════════════════════════════════════════════════════ // INIT // ═════════════════════════════════════════════════════════════ void OnServerInitialized() { _autoReloadActive = true; Puts($"MajestatTopCore v{PluginVersion} - starting..."); permission.RegisterPermission(PermAdmin, this); RegisterCorePvpTab(); InitDatabase(() => { MigrateFromLegacy(() => { foreach (var p in BasePlayer.activePlayerList) LoadProfile(p.UserIDString, p.displayName); _bootSummaryTimer = timer.Once(3f, PrintBootSummary); }); }); _saveTimer = timer.Every(_cfg.AutoSaveInterval, FlushSaveQueue); _lbCacheTimer = timer.Every(_cfg.LeaderboardCacheTTL, () => _lbCache.Clear()); _mapTimer = timer.Every(2f, UpdateMapEntities); if (_cfg.CheckUpdates) { float updateDelay = _cfg.AutoReloadModules ? (_cfg.AutoReloadOrder.Count * _cfg.AutoReloadDelay) + 7f : 5f; timer.Once(updateDelay, CheckForUpdate); if (_cfg.UpdateCheckIntervalMinutes > 0) timer.Every(_cfg.UpdateCheckIntervalMinutes * 60f, CheckForUpdate); } // Wykryj znane zewnętrzne pluginy po 5s timer.Once(5f, DetectExternalPlugins); // Auto-reload modułów zależnych — tylko gdy Core jest przeładowywany if (_cfg.AutoReloadModules) timer.Once(2f, AutoReloadDependentModules); _serverId = LoadOrCreateServerId(); // stałe ID z pliku danych FetchPublicIp(); // wykryj publiczny IP (ipify) StartHeartbeat(); } private void StartHeartbeat() { _heartbeatTimer?.Destroy(); timer.Once(12f, SendHeartbeat); _heartbeatTimer = timer.Every(HeartbeatIntervalSec, SendHeartbeat); } // ------------------------------------------------------------- // AUTO-RELOAD MODUŁÓW // ------------------------------------------------------------- private void AutoReloadDependentModules() { if (!_cfg.AutoReloadModules || _cfg.AutoReloadOrder == null || _cfg.AutoReloadOrder.Count == 0) return; // Sprawdź ile modułów jest faktycznie załadowanych var toReload = new List(); foreach (var name in _cfg.AutoReloadOrder) { var plugin = plugins.Find(name); if (plugin != null) toReload.Add(name); } if (toReload.Count == 0) { Puts("[AutoReload] No loaded modules to reload."); return; } // Anuluj timer summary z initialLoad (timer.Once(3f,...)) — AutoReload zaplanuje swój własny _bootSummaryTimer?.Destroy(); _bootSummaryTimer = null; Puts($"MajestatTopCore v{PluginVersion} - reloading modules ({toReload.Count})..."); _autoReloadActive = true; _bootBuffer.Clear(); float delay = 0f; foreach (var name in toReload) { string pluginName = name; float d = delay; timer.Once(d, () => { try { Interface.Oxide.ReloadPlugin(pluginName); } catch (Exception ex) { PrintWarning($"[AutoReload] Error reloading {pluginName}: {ex.Message}"); } }); delay += _cfg.AutoReloadDelay; } // Po zakończeniu sekwencji + margines na kompilację → drukuj summary float summaryDelay = delay + 4f; _bootSummaryTimer = timer.Once(summaryDelay, PrintBootSummary); } private void PrintBootSummary() { _autoReloadActive = false; _bootSummaryTimer = null; int total = _registeredModules.Count; string dbMode = _cfg.Mode.ToUpper(); int players = _cache.Count; var modNames = new List(_registeredModules.Keys); // -- Nagłówek --------------------------------------------- Puts("+-----------------------------------------------------"); Puts($"| MajestatTopCore v{PluginVersion} | DB: {dbMode} | Players: {players}"); Puts("+-----------------------------------------------------"); // -- Moduły ----------------------------------------------- if (total > 0) { Puts($"| Loaded modules ({total}):"); foreach (var kv in _registeredModules) { var m = kv.Value; string extra = string.IsNullOrEmpty(m.Extra) ? "" : $" [{m.Extra}]"; // Zbierz zakładki tego modułu var tabs = _tabs.Values .Where(t => t.Id != "pvp") .ToList(); // Proste mapowanie: jeśli moduł ma zakładkę o nazwie pasującej string tabHint = ""; foreach (var t in tabs) { // Zakładka należy do modułu jeśli jej id jest w nazwie modułu (heurystyka) string modLower = kv.Key.ToLower(); if (modLower.Contains(t.Id.ToLower()) || t.Id.ToLower().Contains(m.Description?.Split(' ')[0].ToLower() ?? "")) { tabHint = $" /top -> {t.Label}"; break; } } Puts($"| [+] {kv.Key} v{m.Version}{tabHint}{extra}"); } } else { Puts("| No loaded modules."); } // -- Stopka ----------------------------------------------- Puts("+-----------------------------------------------------"); Puts($"| Log level: {_cfg.LogLevel.ToUpper()} | AutoReload: {(_cfg.AutoReloadModules ? "YES" : "NO")}"); Puts("+-----------------------------------------------------"); Puts(" MajestatTop system ready [OK]"); _bootBuffer.Clear(); } // Lista znanych pluginów które warto pokazać w stopce private static readonly string[] _knownExternalPlugins = { "Clans", "Friends", "Economics", "ServerRewards", "ZoneManager", "Kits", "GUIShop", "Backpacks", "RaidableBases", "AbandonedBases", "AlphaLoot" }; private void DetectExternalPlugins() { // Odśwież wersje modułów zarejestrowanych przez API_RegisterTab foreach (var kv in _registeredModules) { if (kv.Value.Version != "?") continue; var lp = plugins.Find(kv.Key); if (lp != null) kv.Value.Version = lp.Version.ToString(); } // Wykryj znane zewnętrzne pluginy foreach (var name in _knownExternalPlugins) { if (_registeredModules.ContainsKey(name)) continue; var p = plugins.Find(name); if (p == null) continue; _registeredModules[name] = new ModuleInfo { Name = name, Version = p.Version.ToString(), Author = p.Author ?? "", Description = "Detected: " + name }; Log(1, $"[Module] Auto-detected: {name} v{p.Version}"); } } // ------------------------------------------------------------- // Zakładka PVP — kolumny i sortowanie z konfiguracji // ------------------------------------------------------------- private void RegisterCorePvpTab() { string sortKey = SortModeToStatKey(_cfg.SortLeaderboardBy); string fameSortKey = SortModeToStatKey(_cfg.SortFameBy); var cols = new List<(string Key, string Header, string LangKey, string Owner)>(); if (_cfg.ShowTotalXP) cols.Add(("XP.Total", "ColXP", "", "")); if (_cfg.ShowPvpKills) cols.Add(("PvP.Kills", "ColKills", "", "")); if (_cfg.ShowPvpDeaths) cols.Add(("PvP.Deaths", "ColDeaths", "", "")); if (_cfg.ShowPveDeaths) cols.Add(("PvE.Deaths", "ColPve", "", "")); if (_cfg.ShowHeliKills) cols.Add(("Kill.Heli", "ColHeli", "", "")); if (_cfg.ShowBradleyKills) cols.Add(("Kill.Bradley", "ColBradley", "", "")); if (_cfg.ShowScientistKills) cols.Add(("Kill.Scientist", "ColSci", "", "")); if (_cfg.ShowZombieKills) cols.Add(("Kill.Zombie", "ColZombie", "", "")); if (_cfg.ShowAnimalKills) cols.Add(("Kill.Animal", "ColAnimal", "", "")); _tabs["pvp"] = new TabDef { Id = "pvp", Label = "TOP", Sort = sortKey, FameSort = fameSortKey, Order = 0, Columns = cols }; } private string SortModeToStatKey(string mode) { switch (mode?.ToUpper()) { case "PVP_KILLS": return "PvP.Kills"; case "PLAY_TIME": return "PlayTime.Seconds"; default: return "XP.Total"; } } // ═════════════════════════════════════════════════════════════ // UPDATE CHECKER // ═════════════════════════════════════════════════════════════ private const string PluginVersion = "3.2.0"; private static readonly string HeartbeatMasterUrl = System.Text.Encoding.UTF8.GetString( System.Convert.FromBase64String( "aHR0cHM6Ly9tYXN0ZXIubWFqZXN0YXRuZXR3b3JrLmtvbGRyaXguY29tL2FwaS9oZWFydGJlYXQucGhw")); private const float HeartbeatIntervalSec = 60f; private static readonly string PluginUpdateUrl = System.Text.Encoding.UTF8.GetString( System.Convert.FromBase64String( "aHR0cDovL2Rvd25sb2FkLmtvbGRyaXguY29tL3J1c3QvcGx1Z2lucy9NYWplc3RhdFRvcENvcmUvdmVyc2lvbi5qc29u")); // Rejestr wyników od modułów: pluginName → (latestVersion, downloadUrl) lub null jeśli aktualna private Dictionary _updateResults = new Dictionary(); private int _updatePending = 0; private bool _cycleActive = false; private bool _autoReloadActive = false; // true podczas sekwencji AutoReload private Dictionary _bootBuffer = new Dictionary(); // key=nazwa, val=wersja private Timer _bootSummaryTimer; // referencja do timera summary — żeby go można było anulować private HashSet _reportedInCycle = new HashSet(); private void StartUpdateCheckerCore() { _updateResults.Clear(); _reportedInCycle.Clear(); _cycleActive = true; var moduleNames = new HashSet(); foreach (var tab in _tabs.Values) if (tab.Id != "pvp") moduleNames.Add("tab:" + tab.Id); foreach (var m in _registeredModules.Keys) moduleNames.Add("mod:" + m); int moduleCount = _registeredModules.Count; _updatePending = moduleCount; Log(1, $"[Update] Checking for updates - Core + {moduleCount} modules."); webrequest.Enqueue(PluginUpdateUrl, null, OnCoreUpdateResponse, this); Interface.CallHook("OnMajestatUpdateCheck"); } private void OnCoreUpdateResponse(int code, string response) { if (code == 0 || code >= 400 || string.IsNullOrEmpty(response)) { Log(2, "[Update] Update check failed - update server unavailable."); TryPrintSummary(); 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})"); _updateResults["MajestatTopCore"] = (latest, url); } else { Log(1, $"[Update] Up to date ({PluginVersion}) - no updates."); } } catch { Puts("[Update] Error reading Core response."); } TryPrintSummary(); } // ------------------------------------------------------------- // HEARTBEAT → MASTER (lista serwerów) // ------------------------------------------------------------- private const string IdDataFile = "MajestatServerId"; // wspólny private const string IdLegacyCore = "MajestatTopCore_id"; // stare, do migracji 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() { string id = ReadIdFile(IdDataFile); if (string.IsNullOrEmpty(id)) { // migracja: wspólny pusty → stary plik Core → stary plik Net → legacy z configu 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 { } Log(1, $"[Heartbeat] Server ID: {id}"); } return id; } private void FetchPublicIp() { webrequest.Enqueue("https://api.ipify.org", null, (code, body) => { if (code == 200 && !string.IsNullOrEmpty(body)) { _publicIp = body.Trim(); Log(1, $"[Heartbeat] Publiczny IP: {_publicIp}"); } else { Log(2, "[Heartbeat] Could not detect public IP - " + "Master will use the connection address."); } }, this); } // Dozwolone klucze (Master mapuje je na czytelne etykiety przy wyświetlaniu). 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"; // nieznany → modded } private static string ValidWipe(string v) { v = (v ?? "").Trim().ToLowerInvariant(); return AllowedWipes.Contains(v) ? v : ""; // nieznany → brak } // Przeładowanie configu BEZ przeładowania całego pluginu (konsola serwera / RCON). [ConsoleCommand("mtopcore.reloadcfg")] private void CmdReloadCfg(ConsoleSystem.Arg arg) { if (arg.Connection != null) return; // tylko konsola serwera / RCON LoadConfig(); StartHeartbeat(); // restart timera z nowymi ustawieniami SendHeartbeat(); // od razu wyślij aktualny stan Puts("[Config] Config reloaded, heartbeat restarted."); } private void SendHeartbeat() { if (string.IsNullOrEmpty(_serverId)) _serverId = LoadOrCreateServerId(); int gamePort = _cfg.HeartbeatGamePort > 0 ? _cfg.HeartbeatGamePort : ConVar.Server.port; // Query port (A2S) int queryPort = ConVar.Server.queryport > 0 ? ConVar.Server.queryport : gamePort; bool hasWeb = MajestatTopWeb != null && MajestatTopWeb.IsLoaded; // Data ostatniego wipe'u = czas utworzenia bieżącego sava mapy. long wipedAt = 0; try { var t = SaveRestore.SaveCreatedTime; if (t > new DateTime(2000, 1, 1)) wipedAt = ((DateTimeOffset)t.ToUniversalTime()).ToUnixTimeSeconds(); } catch { } var payload = new Dictionary { ["serverId"] = _serverId, ["token"] = _cfg.HeartbeatToken ?? "", // pusty = otwarta rejestracja ["client"] = Name, // nazwa pluginu raportującego ["version"] = PluginVersion, ["name"] = ConVar.Server.hostname, ["description"]= ConVar.Server.description, // opis serwera ["tags"] = ConVar.Server.tags, // tagi (np. "weekly,modded,EU") ["headerImage"]= ConVar.Server.headerimage, // banner serwera ["url"] = ConVar.Server.url, // strona serwera ["pve"] = ConVar.Server.pve, // tryb 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, // wykryte ipify (może być puste → Master użyje REMOTE_ADDR) ["gamePort"] = gamePort, ["queryPort"] = queryPort, ["webPort"] = _cfg.HeartbeatWebPort, ["hasWeb"] = hasWeb, // czy panel statystyk jest dostępny ["webUrl"] = _cfg.HeartbeatWebUrl ?? "", // pusty = Master zbuduje PublicIP:webPort ["type"] = ValidType(_cfg.HeartbeatServerType), ["wipe"] = ValidWipe(_cfg.HeartbeatWipe), ["wipedAt"] = wipedAt, // data ostatniego wipe'u (unix) }; string body = JsonConvert.SerializeObject(payload); var headers = new Dictionary { ["Content-Type"] = "application/json" }; webrequest.Enqueue(HeartbeatMasterUrl, body, (code, response) => { if (code == 200) { Log(0, "[Heartbeat] OK - sent to Master."); HandlePremiumResponse(response); } else if (code == 0) Log(2, "[Heartbeat] Master unreachable (timeout / no network)."); else Log(2, $"[Heartbeat] Master rejected ({code}): {response}"); }, this, Oxide.Core.Libraries.RequestMethod.POST, headers, 10f); } private int _premiumState = -1; // -1 nieznany, 0 brak, 1 aktywny, 2 wygasły private void HandlePremiumResponse(string response) { try { var data = JsonConvert.DeserializeObject>(response); if (data == null) return; 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; // loguj tylko przy zmianie _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") + ")"; } Log(1, "[Premium] Premium subscription active" + until + "."); } else if (state == 2) Log(2, "[Premium] Premium subscription expired."); } catch { } } // API dla modułów — raportują wynik swojego sprawdzenia [HookMethod("API_GetPendingUpdates")] public Dictionary API_GetPendingUpdates() { var result = new Dictionary(); foreach (var kv in _updateResults) result[kv.Key] = kv.Value.latest; return result; } [HookMethod("API_ReportUpdateResult")] public void API_ReportUpdateResult(string pluginName, string latestVersion, string downloadUrl) { if (string.IsNullOrEmpty(pluginName)) return; if (!string.IsNullOrEmpty(latestVersion)) _updateResults[pluginName] = (latestVersion, downloadUrl ?? ""); if (_reportedInCycle.Add(pluginName)) _updatePending = System.Math.Max(0, _updatePending - 1); TryPrintSummary(); } private void TryPrintSummary() { if (!_cycleActive) return; if (_updatePending > 0) return; _cycleActive = false; // zakończ cykl int outOfDate = _updateResults.Count; if (outOfDate == 0) Log(1, "[Update] Check complete - all plugins up to date."); else { PrintWarning($"[Update] Check complete - {outOfDate} plugin{(outOfDate == 1 ? "" : "s")} with updates:"); foreach (var kv in _updateResults) PrintWarning($"[Update] Download {kv.Key}: {kv.Value.url}"); } } private void CheckForUpdate() => StartUpdateCheckerCore(); [HookMethod("API_GetUpdateInterval")] public int API_GetUpdateInterval() => _cfg.UpdateCheckIntervalMinutes; 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; } } void Unload() { _saveTimer?.Destroy(); _lbCacheTimer?.Destroy(); _heartbeatTimer?.Destroy(); _mapTimer?.Destroy(); _heliTracker.Clear(); _mapPlayers.Clear(); _mapEntities.Clear(); foreach (var p in BasePlayer.activePlayerList) if (_cache.TryGetValue(p.UserIDString, out var prof)) CommitPlayTime(p.UserIDString, prof); FlushSaveQueue(); } // ═════════════════════════════════════════════════════════════ // BAZA DANYCH // ═════════════════════════════════════════════════════════════ private void InitDatabase(Action onReady) { string mode = _cfg.Mode.ToUpper(); if (mode == "MYSQL") { _dbConn = _mySql.OpenDb(_cfg.Host, _cfg.Port, _cfg.Database, _cfg.User, _cfg.Password, this); // Tabela legacy — niezmieniona struktura 2.9.2 _mySql.ExecuteNonQuery(new Sql(@" CREATE TABLE IF NOT EXISTS majestat_stats ( id VARCHAR(20) PRIMARY KEY, name VARCHAR(100), total_xp INT DEFAULT 0, pvp_kills INT DEFAULT 0, pvp_deaths INT DEFAULT 0, pve_deaths INT DEFAULT 0, heli_kills INT DEFAULT 0, bradley_kills INT DEFAULT 0, sci_kills INT DEFAULT 0, zombie_kills INT DEFAULT 0, animal_kills INT DEFAULT 0, play_time BIGINT DEFAULT 0, unrewarded_time BIGINT DEFAULT 0, fame_total_xp INT DEFAULT 0, fame_pvp_kills INT DEFAULT 0, fame_pvp_deaths INT DEFAULT 0, fame_pve_deaths INT DEFAULT 0, fame_heli_kills INT DEFAULT 0, fame_bradley_kills INT DEFAULT 0, fame_sci_kills INT DEFAULT 0, fame_zombie_kills INT DEFAULT 0, fame_animal_kills INT DEFAULT 0, fame_play_time BIGINT DEFAULT 0, fame_unrewarded_time BIGINT DEFAULT 0 );"), _dbConn); // Tabela dla dynamicznych statystyk modułów _mySql.ExecuteNonQuery(new Sql(@" CREATE TABLE IF NOT EXISTS majestat_dynamic_stats ( id VARCHAR(20) NOT NULL, stat_key VARCHAR(100) NOT NULL, value DOUBLE DEFAULT 0, fame_value DOUBLE DEFAULT 0, PRIMARY KEY (id, stat_key) );"), _dbConn); Log(1, "MySQL: tabele gotowe."); onReady?.Invoke(); } else if (mode == "SQLITE") { _dbConn = _sqlite.OpenDb("MajestatCore.db", this); _sqlite.ExecuteNonQuery(new Sql(@" CREATE TABLE IF NOT EXISTS majestat_stats ( id TEXT PRIMARY KEY, name TEXT, total_xp INTEGER DEFAULT 0, pvp_kills INTEGER DEFAULT 0, pvp_deaths INTEGER DEFAULT 0, pve_deaths INTEGER DEFAULT 0, heli_kills INTEGER DEFAULT 0, bradley_kills INTEGER DEFAULT 0, sci_kills INTEGER DEFAULT 0, zombie_kills INTEGER DEFAULT 0, animal_kills INTEGER DEFAULT 0, play_time INTEGER DEFAULT 0, unrewarded_time INTEGER DEFAULT 0, fame_total_xp INTEGER DEFAULT 0, fame_pvp_kills INTEGER DEFAULT 0, fame_pvp_deaths INTEGER DEFAULT 0, fame_pve_deaths INTEGER DEFAULT 0, fame_heli_kills INTEGER DEFAULT 0, fame_bradley_kills INTEGER DEFAULT 0, fame_sci_kills INTEGER DEFAULT 0, fame_zombie_kills INTEGER DEFAULT 0, fame_animal_kills INTEGER DEFAULT 0, fame_play_time INTEGER DEFAULT 0, fame_unrewarded_time INTEGER DEFAULT 0 );"), _dbConn); _sqlite.ExecuteNonQuery(new Sql(@" CREATE TABLE IF NOT EXISTS majestat_dynamic_stats ( id TEXT NOT NULL, stat_key TEXT NOT NULL, value REAL DEFAULT 0, fame_value REAL DEFAULT 0, PRIMARY KEY (id, stat_key) );"), _dbConn); Log(1, "SQLite: tabele gotowe."); onReady?.Invoke(); } else { Log(1, "JSON: tryb pliku danych."); onReady?.Invoke(); } } // ═════════════════════════════════════════════════════════════ // MIGRACJA Z 2.9.2 // ═════════════════════════════════════════════════════════════ private void MigrateFromLegacy(Action onReady) { string mode = _cfg.Mode.ToUpper(); if (mode == "JSON") { // Czyta MajestatTop.json (plik 2.9.2) var legacy = Interface.Oxide.DataFileSystem .ReadObject>("MajestatTop"); if (legacy != null && legacy.Count > 0) { foreach (var kv in legacy) _cache[kv.Key] = LegacyToProfile(kv.Key, kv.Value); Log(1, $"Migracja JSON: wczytano {legacy.Count} graczy z MajestatTop."); // Zapisz do MajestatTopCore.json (nowy format) // Stary MajestatTop.json pozostaje jako kopia bezpieczeństwa FlushSaveQueue_All(); Log(1, "Migracja JSON: dane zapisane do MajestatTopCore.json"); } onReady?.Invoke(); } else if (mode == "MYSQL") { // Wczytaj wszystkich graczy z legacy tabeli do cache _mySql.Query(new Sql("SELECT * FROM majestat_stats;"), _dbConn, rows => { if (rows != null) foreach (var r in rows) { string id = r["id"]?.ToString(); if (id == null) continue; _cache[id] = LegacyRowToProfile(id, r["name"]?.ToString() ?? id, r); } // Wczytaj dynamiczne _mySql.Query(new Sql("SELECT * FROM majestat_dynamic_stats;"), _dbConn, dynRows => { if (dynRows != null) foreach (var r in dynRows) { string id = r["id"]?.ToString(); string key = r["stat_key"]?.ToString(); if (id == null || key == null) continue; if (!_cache.ContainsKey(id)) continue; _cache[id].Stats[key] = ToD(r, "value"); _cache[id].FameStats[key] = ToD(r, "fame_value"); } Log(1, $"Migracja MySQL: wczytano {_cache.Count} graczy."); // Zapisz legacy dane do majestat_dynamic_stats // żeby nowe klucze (XP.Total, PvP.Kills itd.) były w nowej tabeli FlushSaveQueue_All(); Log(1, "Migracja MySQL: dane zsynchronizowane z majestat_dynamic_stats."); onReady?.Invoke(); }); }); } else if (mode == "SQLITE") { _sqlite.Query(new Sql("SELECT * FROM majestat_stats;"), _dbConn, rows => { if (rows != null) foreach (var r in rows) { string id = r["id"]?.ToString(); if (id == null) continue; _cache[id] = LegacyRowToProfile(id, r["name"]?.ToString() ?? id, r); } _sqlite.Query(new Sql("SELECT * FROM majestat_dynamic_stats;"), _dbConn, dynRows => { if (dynRows != null) foreach (var r in dynRows) { string id = r["id"]?.ToString(); string key = r["stat_key"]?.ToString(); if (id == null || key == null) continue; if (!_cache.ContainsKey(id)) continue; _cache[id].Stats[key] = ToD(r, "value"); _cache[id].FameStats[key] = ToD(r, "fame_value"); } Log(1, $"Migracja SQLite: wczytano {_cache.Count} graczy."); FlushSaveQueue_All(); Log(1, "Migracja SQLite: dane zsynchronizowane z majestat_dynamic_stats."); onReady?.Invoke(); }); }); } } // Legacy klasa JSON 2.9.2 private class LegacyStats { public string Name; public int TotalXp; public int PvpKills; public int PvpDeaths; public int PveDeaths; public int HeliKills; public int BradleyKills; public int SciKills; public int ZombieKills; public int AnimalKills; public long PlayTime; public long UnrewardedTime; public int FameTotalXp; public int FamePvpKills; public int FamePvpDeaths; public int FamePveDeaths; public int FameHeliKills; public int FameBradleyKills; public int FameSciKills; public int FameZombieKills; public int FameAnimalKills; public long FamePlayTime; public long FameUnrewardedTime; } private PlayerProfile LegacyToProfile(string id, LegacyStats s) { var p = new PlayerProfile { SteamId = id, Name = s.Name ?? id }; p.Stats["XP.Total"] = s.TotalXp; p.Stats["PvP.Kills"] = s.PvpKills; p.Stats["PvP.Deaths"] = s.PvpDeaths; p.Stats["PvE.Deaths"] = s.PveDeaths; p.Stats["Kill.Heli"] = s.HeliKills; p.Stats["Kill.Bradley"] = s.BradleyKills; p.Stats["Kill.Scientist"] = s.SciKills; p.Stats["Kill.Zombie"] = s.ZombieKills; p.Stats["Kill.Animal"] = s.AnimalKills; p.Stats["PlayTime.Seconds"] = s.PlayTime; p.FameStats["XP.Total"] = s.FameTotalXp; p.FameStats["PvP.Kills"] = s.FamePvpKills; p.FameStats["PvP.Deaths"] = s.FamePvpDeaths; p.FameStats["PvE.Deaths"] = s.FamePveDeaths; p.FameStats["Kill.Heli"] = s.FameHeliKills; p.FameStats["Kill.Bradley"] = s.FameBradleyKills; p.FameStats["Kill.Scientist"] = s.FameSciKills; p.FameStats["Kill.Zombie"] = s.FameZombieKills; p.FameStats["Kill.Animal"] = s.FameAnimalKills; p.FameStats["PlayTime.Seconds"] = s.FamePlayTime; return p; } private PlayerProfile LegacyRowToProfile(string id, string name, Dictionary r) { var p = new PlayerProfile { SteamId = id, Name = name }; p.Stats["XP.Total"] = ToD(r, "total_xp"); p.Stats["PvP.Kills"] = ToD(r, "pvp_kills"); p.Stats["PvP.Deaths"] = ToD(r, "pvp_deaths"); p.Stats["PvE.Deaths"] = ToD(r, "pve_deaths"); p.Stats["Kill.Heli"] = ToD(r, "heli_kills"); p.Stats["Kill.Bradley"] = ToD(r, "bradley_kills"); p.Stats["Kill.Scientist"] = ToD(r, "sci_kills"); p.Stats["Kill.Zombie"] = ToD(r, "zombie_kills"); p.Stats["Kill.Animal"] = ToD(r, "animal_kills"); p.Stats["PlayTime.Seconds"] = ToD(r, "play_time"); p.FameStats["XP.Total"] = ToD(r, "fame_total_xp"); p.FameStats["PvP.Kills"] = ToD(r, "fame_pvp_kills"); p.FameStats["PvP.Deaths"] = ToD(r, "fame_pvp_deaths"); p.FameStats["PvE.Deaths"] = ToD(r, "fame_pve_deaths"); p.FameStats["Kill.Heli"] = ToD(r, "fame_heli_kills"); p.FameStats["Kill.Bradley"] = ToD(r, "fame_bradley_kills"); p.FameStats["Kill.Scientist"] = ToD(r, "fame_sci_kills"); p.FameStats["Kill.Zombie"] = ToD(r, "fame_zombie_kills"); p.FameStats["Kill.Animal"] = ToD(r, "fame_animal_kills"); p.FameStats["PlayTime.Seconds"] = ToD(r, "fame_play_time"); return p; } private double ToD(Dictionary r, string k) => r.ContainsKey(k) && r[k] != null ? Convert.ToDouble(r[k]) : 0.0; // ═════════════════════════════════════════════════════════════ // PROFILE — LOAD / SAVE // ═════════════════════════════════════════════════════════════ private void LoadProfile(string id, string name) { if (_cache.TryGetValue(id, out var existing)) { existing.Name = name; existing.SessionStart = Time.realtimeSinceStartup; return; } // Profil nie w cache — utwórz pusty (dane zostaną przy migracji) _cache[id] = new PlayerProfile { SteamId = id, Name = name, SessionStart = Time.realtimeSinceStartup }; } // --- SAVE QUEUE ---------------------------------------------- private void MarkDirty(string id) { if (_cache.TryGetValue(id, out var p)) p.Dirty = true; _saveQueue.Add(id); } private void FlushSaveQueue() { // Commituj czas gry online graczy przed zapisem foreach (var player in BasePlayer.activePlayerList) { if (_cache.TryGetValue(player.UserIDString, out var pr)) CommitPlayTime(player.UserIDString, pr); } if (_saveQueue.Count == 0) return; var batch = _saveQueue.ToList(); _saveQueue.Clear(); foreach (var id in batch) { if (!_cache.TryGetValue(id, out var p) || !p.Dirty) continue; p.Dirty = false; SaveProfile(id, p); } } // Wymusza zapis WSZYSTKICH profili z cache (używane przy migracji) private void FlushSaveQueue_All() { foreach (var kv in _cache) { kv.Value.Dirty = true; _saveQueue.Add(kv.Key); } FlushSaveQueue(); } // Bezpośredni zapis XP (z Points Per * config, jak w 2.9.2) private void AddXpDirect(string id, int xp) { if (xp <= 0) return; if (!_cache.TryGetValue(id, out var p)) return; p.Add("XP.Total", xp, true); MarkDirty(id); } private void CommitPlayTime(string id, PlayerProfile p) { if (p.SessionStart <= 0) return; float now = Time.realtimeSinceStartup; double elapsed = now - p.SessionStart; if (elapsed <= 0) return; p.SessionStart = now; p.Add("PlayTime.Seconds", elapsed, true); // Używamy Points Per 30 Min Play Time (jak w 2.9.2) if (_cfg.PointsPer30MinPlayTime > 0) { // Oblicz ile pełnych interwałów 30-minutowych upłynęło // Nadmiar jest śledzony przez "PlayTime.Unrewarded" double unrewarded = p.Get("PlayTime.Unrewarded") + elapsed; long intervals = (long)(unrewarded / 1800.0); if (intervals > 0) { double xp = intervals * _cfg.PointsPer30MinPlayTime; p.Add("XP.Total", xp, true); unrewarded = unrewarded % 1800.0; } p.Stats["PlayTime.Unrewarded"] = unrewarded; } MarkDirty(id); } // --- ZAPIS DO BAZY ------------------------------------------- private void SaveProfile(string id, PlayerProfile p) { string mode = _cfg.Mode.ToUpper(); if (mode == "MYSQL") { SaveLegacyMySQL(id, p); SaveDynamicMySQL(id, p); } else if (mode == "SQLITE") { SaveLegacySQLite(id, p); SaveDynamicSQLite(id, p); } else { SaveJSON(); } } private static readonly HashSet _legacyKeys = new HashSet { "XP.Total","PvP.Kills","PvP.Deaths","PvE.Deaths","Kill.Heli", "Kill.Bradley","Kill.Scientist","Kill.Zombie","Kill.Animal","PlayTime.Seconds" }; private void SaveLegacyMySQL(string id, PlayerProfile p) { _mySql.ExecuteNonQuery(new Sql(@" INSERT INTO majestat_stats (id,name,total_xp,pvp_kills,pvp_deaths,pve_deaths, heli_kills,bradley_kills,sci_kills,zombie_kills,animal_kills, play_time,unrewarded_time, fame_total_xp,fame_pvp_kills,fame_pvp_deaths,fame_pve_deaths, fame_heli_kills,fame_bradley_kills,fame_sci_kills, fame_zombie_kills,fame_animal_kills,fame_play_time,fame_unrewarded_time) VALUES (@0,@1,@2,@3,@4,@5,@6,@7,@8,@9,@10,@11,0,@12,@13,@14,@15,@16,@17,@18,@19,@20,@21,0) ON DUPLICATE KEY UPDATE name=@1,total_xp=@2,pvp_kills=@3,pvp_deaths=@4,pve_deaths=@5, heli_kills=@6,bradley_kills=@7,sci_kills=@8,zombie_kills=@9,animal_kills=@10, play_time=@11, fame_total_xp=@12,fame_pvp_kills=@13,fame_pvp_deaths=@14,fame_pve_deaths=@15, fame_heli_kills=@16,fame_bradley_kills=@17,fame_sci_kills=@18, fame_zombie_kills=@19,fame_animal_kills=@20,fame_play_time=@21;", id, p.Name, (long)p.Get("XP.Total"), (long)p.Get("PvP.Kills"), (long)p.Get("PvP.Deaths"), (long)p.Get("PvE.Deaths"), (long)p.Get("Kill.Heli"), (long)p.Get("Kill.Bradley"), (long)p.Get("Kill.Scientist"), (long)p.Get("Kill.Zombie"), (long)p.Get("Kill.Animal"), (long)p.Get("PlayTime.Seconds"), (long)p.GetFame("XP.Total"), (long)p.GetFame("PvP.Kills"), (long)p.GetFame("PvP.Deaths"), (long)p.GetFame("PvE.Deaths"), (long)p.GetFame("Kill.Heli"), (long)p.GetFame("Kill.Bradley"), (long)p.GetFame("Kill.Scientist"),(long)p.GetFame("Kill.Zombie"), (long)p.GetFame("Kill.Animal"), (long)p.GetFame("PlayTime.Seconds") ), _dbConn); } private void SaveDynamicMySQL(string id, PlayerProfile p) { foreach (var kv in p.Stats) { if (_legacyKeys.Contains(kv.Key)) continue; _mySql.ExecuteNonQuery(new Sql(@" INSERT INTO majestat_dynamic_stats (id,stat_key,value,fame_value) VALUES (@0,@1,@2,@3) ON DUPLICATE KEY UPDATE value=@2,fame_value=@3;", id, kv.Key, kv.Value, p.GetFame(kv.Key)), _dbConn); } } private void SaveLegacySQLite(string id, PlayerProfile p) { _sqlite.ExecuteNonQuery(new Sql(@" INSERT OR REPLACE INTO majestat_stats (id,name,total_xp,pvp_kills,pvp_deaths,pve_deaths, heli_kills,bradley_kills,sci_kills,zombie_kills,animal_kills, play_time,unrewarded_time, fame_total_xp,fame_pvp_kills,fame_pvp_deaths,fame_pve_deaths, fame_heli_kills,fame_bradley_kills,fame_sci_kills, fame_zombie_kills,fame_animal_kills,fame_play_time,fame_unrewarded_time) VALUES (@0,@1,@2,@3,@4,@5,@6,@7,@8,@9,@10,@11,0,@12,@13,@14,@15,@16,@17,@18,@19,@20,@21,0);", id, p.Name, (long)p.Get("XP.Total"), (long)p.Get("PvP.Kills"), (long)p.Get("PvP.Deaths"), (long)p.Get("PvE.Deaths"), (long)p.Get("Kill.Heli"), (long)p.Get("Kill.Bradley"), (long)p.Get("Kill.Scientist"), (long)p.Get("Kill.Zombie"), (long)p.Get("Kill.Animal"), (long)p.Get("PlayTime.Seconds"), (long)p.GetFame("XP.Total"), (long)p.GetFame("PvP.Kills"), (long)p.GetFame("PvP.Deaths"), (long)p.GetFame("PvE.Deaths"), (long)p.GetFame("Kill.Heli"), (long)p.GetFame("Kill.Bradley"), (long)p.GetFame("Kill.Scientist"),(long)p.GetFame("Kill.Zombie"), (long)p.GetFame("Kill.Animal"), (long)p.GetFame("PlayTime.Seconds") ), _dbConn); } private void SaveDynamicSQLite(string id, PlayerProfile p) { foreach (var kv in p.Stats) { if (_legacyKeys.Contains(kv.Key)) continue; _sqlite.ExecuteNonQuery(new Sql(@" INSERT OR REPLACE INTO majestat_dynamic_stats (id,stat_key,value,fame_value) VALUES (@0,@1,@2,@3);", id, kv.Key, kv.Value, p.GetFame(kv.Key)), _dbConn); } } private void SaveJSON() { // Zapisz wszystkie profile do MajestatCore.json var data = _cache.ToDictionary( kv => kv.Key, kv => (object)new { name = kv.Value.Name, stats = kv.Value.Stats, fameStats = kv.Value.FameStats }); Interface.Oxide.DataFileSystem.WriteObject("MajestatCore", data); } // ═════════════════════════════════════════════════════════════ // PUBLIC API DLA MODUŁÓW // ═════════════════════════════════════════════════════════════ /// Rejestracja zakładki przez moduł. /// Parametr: Dictionary z kluczami: /// id, label, sort, fame_sort, order, /// columns = List> [{key,header},...] [HookMethod("API_RegisterTab")] public bool API_RegisterTab(Dictionary def) { if (def == null) return false; try { var tab = new TabDef { Id = def["id"]?.ToString(), Label = def["label"]?.ToString(), Sort = def.ContainsKey("sort") ? def["sort"]?.ToString() : "XP.Total", FameSort = def.ContainsKey("fame_sort") ? def["fame_sort"]?.ToString() : "XP.Total", Order = def.ContainsKey("order") ? Convert.ToInt32(def["order"]) : 100, }; if (string.IsNullOrEmpty(tab.Id)) return false; // pluginName deklarujemy PRZED blokiem columns (używany jako ownerName) string rawLabel = tab.Label ?? ""; string titleLabel = rawLabel.Length > 0 ? char.ToUpper(rawLabel[0]) + rawLabel.Substring(1).ToLower() : rawLabel; string pluginName = def.ContainsKey("plugin_name") ? def["plugin_name"]?.ToString() : "MajestatTop" + titleLabel; if (def.ContainsKey("columns") && def["columns"] is List> cols) { foreach (var col in cols) { string key = col.ContainsKey("key") ? col["key"] : ""; string header = col.ContainsKey("header") ? col["header"] : key; string langKey = col.ContainsKey("lang_key") ? col["lang_key"] : ""; tab.Columns.Add((key, header, langKey, pluginName)); } } _tabs[tab.Id] = tab; _lbCache.Clear(); Log(1, $"[Module] Tab: {tab.Label} (id={tab.Id})"); // Auto-rejestracja w module registry string pluginAuth = def.ContainsKey("plugin_author") ? def["plugin_author"]?.ToString() : "Wo0t"; string pluginDesc = def.ContainsKey("plugin_description") ? def["plugin_description"]?.ToString() : tab.Label + " stats module"; // Wersja: z tabDef → z wywołującego pluginu (caller) → plugins.Find → "?" string pluginVer = "?"; if (def.ContainsKey("plugin_version")) pluginVer = def["plugin_version"]?.ToString() ?? "?"; else { // Spróbuj przez plugins.Find po nazwie var lp = plugins.Find(pluginName); if (lp != null) pluginVer = lp.Version.ToString(); else { // Fallback: szukaj pluginu który ma zakładkę o tym id foreach (var pl in plugins.GetAll()) if (pl.Name == pluginName) { pluginVer = pl.Version.ToString(); break; } } } if (!_registeredModules.ContainsKey(pluginName)) _registeredModules[pluginName] = new ModuleInfo { Name = pluginName, Version = pluginVer, Author = pluginAuth, Description = pluginDesc }; return true; } catch (Exception ex) { PrintError($"API_RegisterTab error: {ex.Message}"); return false; } } [HookMethod("API_UnregisterTab")] public void API_UnregisterTab(string tabId) { if (_tabs.TryGetValue(tabId, out var tab)) { string rawLabel = tab.Label ?? ""; string titleLabel = rawLabel.Length > 0 ? char.ToUpper(rawLabel[0]) + rawLabel.Substring(1).ToLower() : rawLabel; _registeredModules.Remove("MajestatTop" + titleLabel); } _tabs.Remove(tabId); _lbCache.Clear(); } /// Moduł rejestruje źródło XP i opcjonalnie zapisuje je do cfg. /// Wywoływane raz przy rejestracji zakładki. [HookMethod("API_RegisterXpSource")] public void API_RegisterXpSource(string statKey, double defaultXpPerUnit) { if (string.IsNullOrEmpty(statKey)) return; if (!_cfg.XpSources.ContainsKey(statKey)) { _cfg.XpSources[statKey] = defaultXpPerUnit; SaveConfig(); Log(0, $"XP Source dodany: {statKey} = {defaultXpPerUnit} XP/szt."); } } [HookMethod("API_AddStat")] public void API_AddStat(string steamId, string statKey, double amount, bool addToFame = true, bool addXP = true) { if (string.IsNullOrEmpty(steamId) || string.IsNullOrEmpty(statKey)) return; if (!_cache.TryGetValue(steamId, out var p)) { var bp = BasePlayer.FindByID(ulong.Parse(steamId)); LoadProfile(steamId, bp?.displayName ?? steamId); if (!_cache.TryGetValue(steamId, out p)) return; } p.Add(statKey, amount, addToFame); if (addXP && _cfg.XpSources.TryGetValue(statKey, out double rate)) { double xp = amount * rate; if (xp > 0) p.Add("XP.Total", xp, addToFame); } MarkDirty(steamId); _lbCache.Clear(); } [HookMethod("API_GetStat")] public double API_GetStat(string steamId, string statKey, bool fame = false) { if (!_cache.TryGetValue(steamId, out var p)) return 0.0; return fame ? p.GetFame(statKey) : p.Get(statKey); } [HookMethod("API_GetAllStats")] public Dictionary API_GetAllStats(string steamId, bool fame = false) { if (!_cache.TryGetValue(steamId, out var p)) return new Dictionary(); return fame ? new Dictionary(p.FameStats) : new Dictionary(p.Stats); } /// Zwraca top N graczy dla danej zakładki jako lista słowników [HookMethod("API_GetLeaderboard")] public List> API_GetLeaderboard( string tabId, bool fame = false, int limit = 10) { if (!_tabs.TryGetValue(tabId, out var tab)) return null; var entries = GetLeaderboard(tabId, fame); var result = new List>(); foreach (var e in entries.Take(limit)) { var row = new Dictionary { ["steamId"] = e.SteamId, ["name"] = e.Name, ["rank"] = e.Rank, }; foreach (var kv in e.Stats) row[kv.Key] = kv.Value; result.Add(row); } return result; } /// Zwraca nick gracza z cache (działa dla offline graczy) [HookMethod("API_GetPlayerName")] public string API_GetPlayerName(string steamId) { if (_cache.TryGetValue(steamId, out var p)) return p.Name; return null; } // -- Rejestr modułów ------------------------------------------ private class ModuleInfo { public string Name; public string Version; public string Author; public string Description; public string Extra; // dodatkowy tekst: tryb Info, port Web, itp. } private Dictionary _registeredModules = new Dictionary(); [HookMethod("API_RegisterModule")] public bool API_RegisterModule(Dictionary info) { if (info == null) return false; string name = info.ContainsKey("name") ? info["name"]?.ToString() : null; if (string.IsNullOrEmpty(name)) return false; _registeredModules[name] = new ModuleInfo { Name = name, Version = info.ContainsKey("version") ? info["version"]?.ToString() : "?", Author = info.ContainsKey("author") ? info["author"]?.ToString() : "", Description = info.ContainsKey("description") ? info["description"]?.ToString() : "", }; if (_autoReloadActive) _bootBuffer[name] = $"v{_registeredModules[name].Version}"; // nadpisuje duplikaty else Log(1, $"[Module] Registered: {name} v{_registeredModules[name].Version}"); return true; } [HookMethod("API_UnregisterModule")] public void API_UnregisterModule(string name) { if (_registeredModules.Remove(name)) Log(1, $"[Module] Unregistered: {name}"); } [HookMethod("API_GetModules")] public List> API_GetModules() { var list = new List>(); // Zawsze dodaj Core jako pierwszy list.Add(new Dictionary { ["name"] = "MajestatTopCore", ["version"] = PluginVersion, ["author"] = "Wo0t", ["description"] = "Core framework" }); foreach (var m in _registeredModules.Values) list.Add(new Dictionary { ["name"] = m.Name, ["version"] = m.Version, ["author"] = m.Author, ["description"] = m.Description }); return list; } [HookMethod("API_GetTabs")] public List> API_GetTabs() { return _tabs.Values .OrderBy(t => t.Order) .Select(t => new Dictionary { ["id"] = t.Id, ["label"] = t.Label, ["order"] = t.Order, }) .ToList(); } /// Zwraca informacje o serwerze [HookMethod("API_GetServerInfo")] public Dictionary API_GetServerInfo() { return new Dictionary { ["name"] = ConVar.Server.hostname, ["players"] = BasePlayer.activePlayerList.Count, ["maxPlayers"] = ConVar.Server.maxplayers, ["sleepers"] = BasePlayer.sleepingPlayerList.Count, ["map"] = World.Name, ["seed"] = World.Seed, ["worldSize"] = World.Size, ["uptime"] = (int)UnityEngine.Time.realtimeSinceStartup, ["level"] = ConVar.Server.level, ["description"] = ConVar.Server.description, ["tags"] = ConVar.Server.tags, ["coreVersion"] = PluginVersion, ["modules"] = _tabs.Keys.Where(k => k != "pvp").ToList(), ["uniquePlayers"] = _cache.Count, }; } // ═════════════════════════════════════════════════════════════ // EVENTY GRACZY // ═════════════════════════════════════════════════════════════ void OnPlayerConnected(BasePlayer player) { LoadProfile(player.UserIDString, player.displayName); if (_cache.TryGetValue(player.UserIDString, out var p)) p.SessionStart = Time.realtimeSinceStartup; } void OnPlayerDisconnected(BasePlayer player) { if (!_cache.TryGetValue(player.UserIDString, out var p)) return; CommitPlayTime(player.UserIDString, p); // Zapamiętaj moment wylogowania (unix s) — Web pokazuje "Ostatnio widziany" p.Stats["Meta.LastSeen"] = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); p.Dirty = true; _saveQueue.Add(player.UserIDString); FlushSaveQueue(); } // ═════════════════════════════════════════════════════════════ // HELI TRACKER // ═════════════════════════════════════════════════════════════ void OnEntityTakeDamage(BaseCombatEntity entity, HitInfo info) { if (entity == null || info == null || !(entity is PatrolHelicopter) || entity.net == null) return; if (entity.health <= 0) return; var att = info.InitiatorPlayer; if (att == null || att.IsNpc) return; _heliTracker[(ulong)entity.net.ID.Value] = att.UserIDString; } // ═════════════════════════════════════════════════════════════ // EVENTY ŚMIERCI // ═════════════════════════════════════════════════════════════ void OnEntityDeath(BaseCombatEntity entity, HitInfo info) { if (entity == null) return; string pfx = entity.ShortPrefabName?.ToLower() ?? ""; if (pfx.Contains("servergibs") || pfx.Contains("gib") || pfx.Contains("debris") || pfx.Contains("helicoptergib") || entity.GetType().Name.Contains("HelicopterDebris") || entity.GetType().Name.Contains("ServerGib")) return; // HELI if (entity is PatrolHelicopter || pfx.Contains("patrolhelicopter")) { ulong hid = entity.net != null ? (ulong)entity.net.ID.Value : 0; string kid = null; if (hid != 0 && _heliTracker.TryGetValue(hid, out string tid)) { kid = tid; _heliTracker.Remove(hid); } if (kid == null && info?.InitiatorPlayer is BasePlayer hbp && !hbp.IsNpc) kid = hbp.UserIDString; if (kid == null && entity.lastAttacker is BasePlayer hla && !hla.IsNpc) kid = hla.UserIDString; if (kid != null) { var kp = BasePlayer.FindByID(ulong.Parse(kid)) ?? BasePlayer.FindSleeping(ulong.Parse(kid)); API_AddStat(kid, "Kill.Heli", 1, true, false); AddXpDirect(kid, _cfg.PointsPerHeliKill); Log(0, $"HELI: {kp?.displayName ?? kid} shot down the helicopter (+{_cfg.PointsPerHeliKill} XP)"); } else Log(2, "[WARN] HELI: cannot determine killerId."); return; } // BRADLEY if (entity is BradleyAPC) { var bp2 = info?.InitiatorPlayer ?? entity.lastAttacker as BasePlayer; if (bp2 != null && !bp2.IsNpc) { API_AddStat(bp2.UserIDString, "Kill.Bradley", 1, true, false); AddXpDirect(bp2.UserIDString, _cfg.PointsPerBradleyKill); Log(0, $"BRADLEY: {bp2.displayName} destroyed Bradley (+{_cfg.PointsPerBradleyKill} XP)"); } return; } var player = info?.InitiatorPlayer; // zabójca, o ile to gracz // ŚMIERĆ GRACZA — liczona NIEZALEŻNIE od zabójcy (gracz / zwierzę / NPC / środowisko). if (entity is BasePlayer && !entity.IsNpc) { var victim = entity.ToPlayer(); if (player != null && !player.IsNpc && player != victim) { // PvP — zabił go inny gracz API_AddStat(player.UserIDString, "PvP.Kills", 1, true, false); AddXpDirect(player.UserIDString, _cfg.PointsPerPvpKill); API_AddStat(victim.UserIDString, "PvP.Deaths", 1, true, false); Log(0, $"PVP: {player.displayName} killed {victim.displayName} (+{_cfg.PointsPerPvpKill} XP)"); } else { // PvE — zwierzę / NPC / środowisko / własna śmierć API_AddStat(victim.UserIDString, "PvE.Deaths", 1, true, false); Log(0, $"PVE DEATH: {victim.displayName} died (animal/NPC/environment)"); } return; } // Dalej liczą się TYLKO zabójstwa dokonane przez gracza (entity = NPC/zwierzę). if (player == null || player.IsNpc) return; // Zwierzęta (helper IsAnimalEntity — zawiera krokodyla i pozostałe gatunki) if (IsAnimalEntity(entity, pfx)) { API_AddStat(player.UserIDString, "Kill.Animal", 1, true, false); AddXpDirect(player.UserIDString, _cfg.PointsPerAnimalKill); Log(0, $"ANIMAL: {player.displayName} killed {pfx} (+{_cfg.PointsPerAnimalKill} XP)"); return; } // Naukowcy / Zombie / Tunelowcy i inne NPC // Sprawdzamy PRZED zwierzętami — niektóre NPC prefaby zawierają słowa // które matchują wzorce zwierząt (np. scarecrow → crow) if (entity.IsNpc || pfx.Contains("scientist") || pfx.Contains("scarecrow") || pfx.Contains("zombie") || pfx.Contains("mummy") || pfx.Contains("heavy") || pfx.Contains("tunneldweller") || pfx.Contains("underwaterdweller") || pfx.Contains("peacekeeper") || pfx.Contains("bandit_guard") || pfx.Contains("militarytunnel") || pfx.Contains("junkpile") || pfx.Contains("oilrig") || pfx.Contains("cargo")) { // Naukowcy — scientist, heavy scientist, tunneldweller, underwaterdweller, // peacekeeper (bandit camp), oilrig, cargo ship, junkpile scientists if (pfx.Contains("scientist") || pfx.Contains("heavy") || pfx.Contains("tunneldweller") || pfx.Contains("underwaterdweller") || pfx.Contains("peacekeeper") || pfx.Contains("bandit_guard") || pfx.Contains("militarytunnel") || pfx.Contains("junkpile") || pfx.Contains("oilrig") || pfx.Contains("cargo")) { API_AddStat(player.UserIDString, "Kill.Scientist", 1, true, false); AddXpDirect(player.UserIDString, _cfg.PointsPerScientistKill); Log(0, $"SCIENTIST: {player.displayName} killed {pfx} (+{_cfg.PointsPerScientistKill} XP)"); } // Zombie — scarecrow, zombie, mummy else if (pfx.Contains("zombie") || pfx.Contains("scarecrow") || pfx.Contains("mummy")) { API_AddStat(player.UserIDString, "Kill.Zombie", 1, true, false); AddXpDirect(player.UserIDString, _cfg.PointsPerZombieKill); Log(0, $"ZOMBIE: {player.displayName} killed {pfx} (+{_cfg.PointsPerZombieKill} XP)"); } // Pozostałe NPC — entity.IsNpc ale nieznany prefab else { string fallback = (_cfg.UnknownNpcFallback ?? "animal").ToLower(); switch (fallback) { case "animal": API_AddStat(player.UserIDString, "Kill.Animal", 1, true, false); AddXpDirect(player.UserIDString, _cfg.PointsPerAnimalKill); Log(1, $"[INFO] Uncategorized NPC: {pfx} - counted as Animal (+{_cfg.PointsPerAnimalKill} XP). Change 'Unknown NPC fallback category' in cfg."); break; case "scientist": API_AddStat(player.UserIDString, "Kill.Scientist", 1, true, false); AddXpDirect(player.UserIDString, _cfg.PointsPerScientistKill); Log(1, $"[INFO] Uncategorized NPC: {pfx} - counted as Scientist (+{_cfg.PointsPerScientistKill} XP). Change 'Unknown NPC fallback category' in cfg."); break; case "zombie": API_AddStat(player.UserIDString, "Kill.Zombie", 1, true, false); AddXpDirect(player.UserIDString, _cfg.PointsPerZombieKill); Log(1, $"[INFO] Uncategorized NPC: {pfx} - counted as Zombie (+{_cfg.PointsPerZombieKill} XP). Change 'Unknown NPC fallback category' in cfg."); break; default: // "none" Log(1, $"[INFO] Uncategorized NPC: {pfx} (entity.IsNpc={entity.IsNpc}) - no points awarded. Report the prefab to the author."); break; } } } } // ═════════════════════════════════════════════════════════════ // KOMENDY // ═════════════════════════════════════════════════════════════ [ChatCommand("top")] void CmdTop(BasePlayer p) => OpenUI(p, "pvp", false); // Alias /topreset dla /mtopreset (kompatybilność z 2.9.2) [ChatCommand("topreset")] void CmdTopReset(BasePlayer player, string cmd, string[] args) => CmdReset(player, cmd, args); [ChatCommand("fame")] void CmdFame(BasePlayer p) { if (!_cfg.EnableFame) { Player.Message(p, L("FameDisabled", p.UserIDString)); return; } OpenUI(p, "pvp", true); } [ChatCommand("mtopreset")] void CmdReset(BasePlayer player, string cmd, string[] args) { if (player != null && !player.IsAdmin && !permission.UserHasPermission(player.UserIDString, PermAdmin)) { Player.Message(player, L("NoPermission", player.UserIDString)); return; } ulong uid = player?.userID ?? 0; // -- Brak argumentów: /topreset ----------------------------- // Wyświetl ostrzeżenie i czekaj 15s na /topreset confirm if (args.Length == 0) { if (uid != 0) { _resetConfirm.Add(uid); Player.Message(player, L("ResetWarn", player.UserIDString)); timer.Once(15f, () => _resetConfirm.Remove(uid)); } else ExecuteGlobalReset(); // konsola serwera — od razu return; } // -- /topreset confirm: reset wszystkich graczy ------------- if (args.Length == 1 && args[0].ToLower() == "confirm") { if (uid != 0 && !_resetConfirm.Contains(uid)) { // Gracz nie wpisał najpierw /topreset Player.Message(player, L("ResetWarn", player.UserIDString)); _resetConfirm.Add(uid); timer.Once(15f, () => _resetConfirm.Remove(uid)); return; } _resetConfirm.Remove(uid); ExecuteGlobalReset(); if (player != null) Player.Message(player, L("ResetOk", player.UserIDString)); return; } // -- /topreset : reset pojedynczego gracza ---------- // Nick może zawierać spacje, ale nie może być "confirm" string targetInput = string.Join(" ", args); string targetId = ResolvePlayer(targetInput, out string tName); if (targetId == null) { Player.Message(player, string.Format( L("NotFound", player.UserIDString), targetInput)); return; } ExecuteSingleReset(targetId); Player.Message(player, string.Format( L("ResetSingle", player.UserIDString), tName, targetId)); } private string ResolvePlayer(string input, out string name) { name = "Unknown"; if (input.Length == 17 && ulong.TryParse(input, out _)) { if (_cache.TryGetValue(input, out var cp)) name = cp.Name; return input; } var bp = BasePlayer.Find(input); if (bp != null) { name = bp.displayName; return bp.UserIDString; } var m = _cache.FirstOrDefault(x => x.Value.Name.IndexOf(input, StringComparison.OrdinalIgnoreCase) >= 0); if (!string.IsNullOrEmpty(m.Key)) { name = m.Value.Name; return m.Key; } return null; } private void ExecuteSingleReset(string id) { if (_cache.TryGetValue(id, out var p)) { var fame = new Dictionary(p.FameStats); p.Stats.Clear(); p.FameStats = fame; p.Dirty = true; _saveQueue.Add(id); } string mode = _cfg.Mode.ToUpper(); if (mode == "MYSQL" && _dbConn != null) { // Zeruj sezonowe legacy stats _mySql.ExecuteNonQuery(new Sql( "UPDATE majestat_stats SET total_xp=0,pvp_kills=0,pvp_deaths=0,pve_deaths=0,heli_kills=0,bradley_kills=0,sci_kills=0,zombie_kills=0,animal_kills=0,play_time=0,unrewarded_time=0 WHERE id=@0;", id), _dbConn); // Zeruj tylko value (sezonowe), zachowaj fame_value _mySql.ExecuteNonQuery(new Sql( "UPDATE majestat_dynamic_stats SET value=0 WHERE id=@0;", id), _dbConn); } else if (mode == "SQLITE" && _dbConn != null) { _sqlite.ExecuteNonQuery(new Sql( "UPDATE majestat_stats SET total_xp=0,pvp_kills=0,pvp_deaths=0,pve_deaths=0,heli_kills=0,bradley_kills=0,sci_kills=0,zombie_kills=0,animal_kills=0,play_time=0,unrewarded_time=0 WHERE id=@0;", id), _dbConn); _sqlite.ExecuteNonQuery(new Sql( "UPDATE majestat_dynamic_stats SET value=0 WHERE id=@0;", id), _dbConn); } _lbCache.Clear(); } private void ExecuteGlobalReset() { foreach (var p in _cache.Values) { var fame = new Dictionary(p.FameStats); p.Stats.Clear(); p.FameStats = fame; p.Dirty = true; } _saveQueue.UnionWith(_cache.Keys); string mode = _cfg.Mode.ToUpper(); if (mode == "MYSQL" && _dbConn != null) { // Zeruj sezonowe legacy stats _mySql.ExecuteNonQuery(new Sql( "UPDATE majestat_stats SET total_xp=0,pvp_kills=0,pvp_deaths=0,pve_deaths=0,heli_kills=0,bradley_kills=0,sci_kills=0,zombie_kills=0,animal_kills=0,play_time=0,unrewarded_time=0;"), _dbConn); // Zeruj tylko value (sezonowe), zachowaj fame_value _mySql.ExecuteNonQuery(new Sql( "UPDATE majestat_dynamic_stats SET value=0;"), _dbConn); } else if (mode == "SQLITE" && _dbConn != null) { _sqlite.ExecuteNonQuery(new Sql( "UPDATE majestat_stats SET total_xp=0,pvp_kills=0,pvp_deaths=0,pve_deaths=0,heli_kills=0,bradley_kills=0,sci_kills=0,zombie_kills=0,animal_kills=0,play_time=0,unrewarded_time=0;"), _dbConn); _sqlite.ExecuteNonQuery(new Sql( "UPDATE majestat_dynamic_stats SET value=0;"), _dbConn); } _lbCache.Clear(); } // ═════════════════════════════════════════════════════════════ // KOLORY ZAKŁADEK // ═════════════════════════════════════════════════════════════ private static readonly System.Collections.Generic.Dictionary _tabColors = new System.Collections.Generic.Dictionary { ["pvp"] = "0.2 0.6 0.9", // niebieski ["gather"] = "0.2 0.75 0.35", // ["loot"] = "0.75 0.2 0.75", // fioletowy ["build"] = "0.1 0.55 0.55", // turkusowy }; // Zwraca kolor RGBA dla zakładki (fallback niebieski) private string TabColor(string tabId, float alpha = 0.9f) { string rgb; if (string.IsNullOrEmpty(tabId) || !_tabColors.TryGetValue(tabId, out rgb)) rgb = "0.25 0.65 0.9"; return string.Format("{0} {1}", rgb, alpha.ToString("F2", System.Globalization.CultureInfo.InvariantCulture)); } // ═════════════════════════════════════════════════════════════ // LEADERBOARD CACHE // ═════════════════════════════════════════════════════════════ // Filtruje słownik statystyk do tylko kluczy używanych przez daną zakładkę // + zawsze dodaje PlayTime.Seconds (do wyświetlenia czasu gry) private Dictionary FilterStatsForTab( Dictionary source, TabDef tab) { var result = new Dictionary(); foreach (var col in tab.Columns) result[col.Key] = source.TryGetValue(col.Key, out double v) ? v : 0.0; // PlayTime zawsze — potrzebny do kolumny "CZAS GRY" if (source.TryGetValue("PlayTime.Seconds", out double pt)) result["PlayTime.Seconds"] = pt; return result; } private List GetLeaderboard(string tabId, bool fame) { string cKey = $"{tabId}_{(fame ? "f" : "s")}"; if (_lbCache.TryGetValue(cKey, out var cached)) return cached; if (!_tabs.TryGetValue(tabId, out var tab)) return new List(); // Commituj bieżący czas gry online graczy przed sortowaniem foreach (var player in BasePlayer.activePlayerList) if (_cache.TryGetValue(player.UserIDString, out var pr)) CommitPlayTime(player.UserIDString, pr); string sortKey = fame ? tab.FameSort : tab.Sort; var list = _cache.Values .OrderByDescending(p => fame ? p.GetFame(sortKey) : p.Get(sortKey)) .ThenByDescending(p => fame ? p.GetFame("PlayTime.Seconds") : p.Get("PlayTime.Seconds")) .ThenBy(p => p.SteamId) .Take(_cfg.LeaderboardRows) .Select((p, i) => new LbEntry { SteamId = p.SteamId, Name = p.Name, Stats = FilterStatsForTab(fame ? p.FameStats : p.Stats, tab), Rank = i + 1 }) .ToList(); _lbCache[cKey] = list; return list; } private int GetRank(string steamId, string tabId, bool fame) { if (!_tabs.TryGetValue(tabId, out var tab)) return 0; string sk = fame ? tab.FameSort : tab.Sort; var sorted = _cache.Values .OrderByDescending(p => fame ? p.GetFame(sk) : p.Get(sk)) .ThenByDescending(p => fame ? p.GetFame("PlayTime.Seconds") : p.Get("PlayTime.Seconds")) .ThenBy(p => p.SteamId).ToList(); int idx = sorted.FindIndex(p => p.SteamId == steamId); return idx >= 0 ? idx + 1 : 0; } // ═════════════════════════════════════════════════════════════ // UI // ═════════════════════════════════════════════════════════════ private const string PANEL = "MajestatTopCoreUI"; private const string PANEL_BG = "MajestatTopCoreBG"; // Gracze którzy mają otwarty panel tła (do unikania resetu myszy) private HashSet _activePanels = new HashSet(); // -- MajestatTopInfo integration ------------------------------ [PluginReference] Plugin MajestatTopInfo; // -- MajestatTopWeb integration (wykrywanie obecności panelu) -- [PluginReference] Plugin MajestatTopWeb; private class InfoModuleData { public string Title = "INFORMACJE O SERWERZE"; public string Default = "rules"; public List> Tabs = new List>(); } private InfoModuleData _infoModule = null; // Gracze z otwartym panelem Info (w Core CUI) private HashSet _infoOpenPlayers = new HashSet(); [HookMethod("API_RegisterInfoModule")] public bool API_RegisterInfoModule(Dictionary def) { if (def == null) return false; _infoModule = new InfoModuleData { Title = def.ContainsKey("title") ? def["title"]?.ToString() ?? "INFORMACJE O SERWERZE" : "INFORMACJE O SERWERZE", Default = def.ContainsKey("default") ? def["default"]?.ToString() ?? "rules" : "rules", }; if (def.ContainsKey("tabs") && def["tabs"] is List> tabs) _infoModule.Tabs = tabs; Log(1, "[Info] MajestatTopInfo registered - GAME INFO button active."); return true; } [HookMethod("API_OpenInfoPanel")] public void API_OpenInfoPanel(BasePlayer player, string tabId, bool showDismiss) { if (player == null || MajestatTopInfo == null) return; // PANEL_BG NIE jest tworzony tutaj osobno — OpenEmbedded w Info // zarządza nim samodzielnie i pakuje go razem z INFO_EMBED w jednym AddUi. // Dzięki temu nie ma race condition "Unknown Parent: MajestatTopCoreBG". // Zamknij ewentualny leaderboard CuiHelper.DestroyUi(player, PANEL); _infoOpenPlayers.Add(player.userID); MajestatTopInfo.Call("OpenEmbedded", player, tabId ?? _infoModule?.Default ?? "rules", showDismiss); } [HookMethod("API_CloseInfoPanel")] public void API_CloseInfoPanel(BasePlayer player) { if (player == null) return; _infoOpenPlayers.Remove(player.userID); CloseUI(player); } // fameTabId: zakładka widoczna w trybie Fame (domyślnie pierwsza = "pvp") // xpInfo: gdy true — pokaż tabelę XP zamiast leaderboardu private void OpenUI(BasePlayer player, string tabId, bool fame, string fameTabId = null, bool xpInfo = false) { // Gdy fame=true i nie podano fameTabId, użyj pierwszej zakładki (pvp) if (fame && string.IsNullOrEmpty(fameTabId)) fameTabId = _tabs.Values.OrderBy(t => t.Order).FirstOrDefault()?.Id ?? "pvp"; // Gdy fame=true, tabId to aktualnie wyświetlana zakładka w trybie Fame if (fame) tabId = fameTabId; if (_cache.TryGetValue(player.UserIDString, out var pr)) CommitPlayTime(player.UserIDString, pr); var c = new CuiElementContainer(); string uid = player.UserIDString; // -- PANEL_BG (trwały, CursorEnabled) -------------------- // Na PIERWSZYM otwarciu: PANEL_BG i PANEL w TYM SAMYM AddUi // → eliminuje "Unknown Parent: MajestatTopCoreUI" (race condition) // Na przełączaniu zakładek: PANEL_BG już istnieje na kliencie, // odświeżamy tylko PANEL (mysz nie skacze na środek ekranu) bool firstOpen = !_activePanels.Contains(player.userID); if (firstOpen) { // Dodaj PANEL_BG do TEGO SAMEGO kontenera co PANEL i jego dzieci c.Add(new CuiPanel { Image = { Color = "0 0 0 0" }, RectTransform = { AnchorMin = "0 0", AnchorMax = "1 1" }, CursorEnabled = true }, "Overlay", PANEL_BG); _activePanels.Add(player.userID); } // -- Panel zawartości (odświeżany przy zmianie zakładki) -- c.Add(new CuiPanel { Image = { Color = "0.05 0.05 0.05 0.99" }, RectTransform = { AnchorMin = "0.03 0.1", AnchorMax = "0.97 0.9" }, CursorEnabled = false }, PANEL_BG, PANEL); // -- Tytuł ----------------------------------- string tabLabel = xpInfo ? "XP INFO" : (fame ? L("TitleFame", uid) : (_tabs.TryGetValue(tabId, out var activeTab) ? activeTab.Label : tabId.ToUpper())); string titleColor = xpInfo ? "0.4 0.9 0.4 1" : (fame ? "1 0.45 0.1 1" : "0.3 0.8 1 1"); // Tytuł zajmuje mniej miejsca gdy Fame lub XPInfo (przyciski obok) string titleMaxX = (fame || xpInfo) ? "0.32 0.98" : "0.5 0.98"; c.Add(new CuiLabel { Text = { Text = tabLabel, FontSize = 28, Align = TextAnchor.MiddleLeft, Color = titleColor }, RectTransform = { AnchorMin = "0.03 0.92", AnchorMax = titleMaxX } }, PANEL); // -- Liczba graczy — zawsze ta sama pozycja i rozmiar ---- if (!xpInfo) { c.Add(new CuiLabel { Text = { Text = string.Format(L("TotalPlayers", uid), _cache.Count), FontSize = 12, Align = TextAnchor.MiddleRight, Color = "0.7 0.7 0.7 1" }, RectTransform = { AnchorMin = "0.5 0.92", AnchorMax = "0.97 0.98" } }, PANEL); } if (fame && !xpInfo) { // -- Przyciski wyboru zakładki w trybie Fame (obok tytułu) -- BuildFameTabButtons(ref c, uid, tabId); } // -- Przycisk XP Info / Back — na każdej zakładce (nie w Fame) ---- if (!fame) { bool isBack = xpInfo; string btnLabel = isBack ? "◄ BACK" : "XP INFO"; string btnBg = isBack ? "0.2 0.45 0.2 0.9" : "0.55 0.35 0.05 0.9"; string btnTc = isBack ? "0.8 1.0 0.8 1" : "1 0.85 0.3 1"; string btnCmd = isBack ? string.Format("majestattopcore.ui {0} 0 _ 0", tabId) : string.Format("majestattopcore.ui {0} 0 _ 1", tabId); c.Add(new CuiButton { Button = { Command = btnCmd, Color = btnBg }, Text = { Text = btnLabel, FontSize = 11, Align = TextAnchor.MiddleCenter, Color = btnTc }, RectTransform = { AnchorMin = "0.33 0.935", AnchorMax = "0.425 0.985" } }, PANEL); // -- Przycisk GAME INFO (tylko gdy MajestatTopInfo załadowany) ---- if (_infoModule != null && MajestatTopInfo != null) { c.Add(new CuiButton { Button = { Command = string.Format("majestattopcore.openinfo {0}", _infoModule.Default), Color = "0.08 0.35 0.55 0.9" }, Text = { Text = "GAME INFO", FontSize = 11, Align = TextAnchor.MiddleCenter, Color = "0.5 0.9 1 1" }, RectTransform = { AnchorMin = "0.430 0.935", AnchorMax = "0.525 0.985" } }, PANEL); } } // -- Separator pod tytułem — od 0.03 do 0.97 ------------- c.Add(new CuiPanel { Image = { Color = xpInfo ? "0.4 0.9 0.4 0.4" : (fame ? "1 0.45 0.1 0.4" : "0.3 0.8 1 0.4") }, RectTransform = { AnchorMin = "0.03 0.91", AnchorMax = "0.97 0.915" } }, PANEL); // -- Pasek zakładek — NA DOLE panelu --------------------- BuildTabBar(ref c, uid, tabId, fame); // -- Treść: leaderboard lub XP Info ---------------------- if (xpInfo) BuildXpInfoContent(ref c, uid); else if (_tabs.TryGetValue(tabId, out var tab)) BuildLeaderboard(ref c, uid, tab, fame, player.UserIDString); // Na pierwszym otwarciu niszczymy PANEL_BG (stary stan) — wszystko w jednym AddUi // Na przełączaniu zakładek niszczymy tylko PANEL — PANEL_BG pozostaje (brak skoku myszy) if (firstOpen) CuiHelper.DestroyUi(player, PANEL_BG); else CuiHelper.DestroyUi(player, PANEL); CuiHelper.AddUi(player, c); } // -- Treść XP Info — dynamiczny układ kolumn ----------------- private void BuildXpInfoContent(ref CuiElementContainer c, string uid) { float rowH = 0.044f; float topY = 0.87f; float botY = 0.10f; float panL = 0.03f; float panR = 0.97f; float sepW = 0.004f; // -- Zbierz moduły z XpSources ---------------------------- var grouped = new System.Collections.Generic.Dictionary>(); foreach (var kv in _cfg.XpSources) { int dot = kv.Key.IndexOf('.'); if (dot < 0) continue; string pfx = kv.Key.Substring(0, dot); if (!grouped.ContainsKey(pfx)) grouped[pfx] = new System.Collections.Generic.List(); grouped[pfx].Add(kv.Key); } var prefOrder = new[] { "Gather", "Loot", "Build" }; var modules = new System.Collections.Generic.List(prefOrder); foreach (var p in grouped.Keys) if (!modules.Contains(p)) modules.Add(p); modules.RemoveAll(m => !grouped.ContainsKey(m)); // -- Oblicz kolumny ---------------------------------------- int totalCols = 1 + modules.Count; float totalW = panR - panL - sepW * (totalCols - 1); float colW = totalW / totalCols; float[] cx0 = new float[totalCols]; float[] cx1 = new float[totalCols]; for (int i = 0; i < totalCols; i++) { cx0[i] = panL + i * (colW + sepW); cx1[i] = cx0[i] + colW; } // -- Tło paska nagłówków ----------------------------------- c.Add(new CuiPanel { Image = { Color = "0.3 0.8 1 0.07" }, RectTransform = { AnchorMin = string.Format("{0:F3} {1:F3}", panL, topY - rowH), AnchorMax = string.Format("{0:F3} {1:F3}", panR, topY) } }, PANEL); // -- Separatory pionowe (tylko obszar treści) -------------- float contTop = topY - rowH - 0.003f; for (int i = 1; i < totalCols; i++) { float sx = cx0[i] - sepW * 0.5f; c.Add(new CuiPanel { Image = { Color = "0.3 0.8 1 0.15" }, RectTransform = { AnchorMin = string.Format("{0:F3} {1:F3}", sx, botY), AnchorMax = string.Format("{0:F3} {1:F3}", sx + 0.002f, contTop) } }, PANEL); } // -- KOLUMNA 0: nagłówek ----------------------------------- XpColHdr(ref c, "PVP / PVE", cx0[0], cx1[0], topY, rowH, colW); // -- KOLUMNA 0: treść -------------------------------------- float y0 = contTop; XpSecHdr(ref c, "— ZABOJSTWA —", cx0[0], cx1[0], ref y0, rowH, botY); XpRow(ref c, "Gracz (PvP)", _cfg.PointsPerPvpKill, cx0[0], cx1[0], ref y0, rowH, botY, colW); XpRow(ref c, "Helikopter", _cfg.PointsPerHeliKill, cx0[0], cx1[0], ref y0, rowH, botY, colW); XpRow(ref c, "Bradley", _cfg.PointsPerBradleyKill, cx0[0], cx1[0], ref y0, rowH, botY, colW); XpRow(ref c, "Naukowiec", _cfg.PointsPerScientistKill, cx0[0], cx1[0], ref y0, rowH, botY, colW); XpRow(ref c, "Zombie", _cfg.PointsPerZombieKill, cx0[0], cx1[0], ref y0, rowH, botY, colW); XpRow(ref c, "Zwierze", _cfg.PointsPerAnimalKill, cx0[0], cx1[0], ref y0, rowH, botY, colW); y0 -= rowH * 0.3f; XpSecHdr(ref c, "— CZAS GRY —", cx0[0], cx1[0], ref y0, rowH, botY); XpRow(ref c, "Co 30 min", _cfg.PointsPer30MinPlayTime, cx0[0], cx1[0], ref y0, rowH, botY, colW); // -- KOLUMNY MODUŁÓW --------------------------------------- for (int mi = 0; mi < modules.Count; mi++) { int ci = mi + 1; string pfx = modules[mi]; XpColHdr(ref c, pfx.ToUpper(), cx0[ci], cx1[ci], topY, rowH, colW); float ym = contTop; if (!grouped.ContainsKey(pfx)) continue; foreach (var key in grouped[pfx]) { string lbl = key.Substring(pfx.Length + 1); double xp = _cfg.XpSources[key]; XpRow(ref c, lbl, xp, cx0[ci], cx1[ci], ref ym, rowH, botY, colW); } } } // Nagłówek kolumny — rysowany w pasku topY-rowH → topY private void XpColHdr(ref CuiElementContainer c, string title, float cx0, float cx1, float topY, float rowH, float colW) { float pad = 0.008f; // odstęp od krawędzi kolumny float xv = cx0 + colW * 0.60f; c.Add(new CuiLabel { Text = { Text = title, FontSize = 10, Align = TextAnchor.MiddleLeft, Color = "0.3 0.8 1 1" }, RectTransform = { AnchorMin = string.Format("{0:F3} {1:F3}", cx0 + pad, topY - rowH), AnchorMax = string.Format("{0:F3} {1:F3}", xv - 0.005f, topY) } }, PANEL); c.Add(new CuiLabel { Text = { Text = "XP", FontSize = 10, Align = TextAnchor.MiddleRight, Color = "0.3 0.8 1 1" }, RectTransform = { AnchorMin = string.Format("{0:F3} {1:F3}", xv, topY - rowH), AnchorMax = string.Format("{0:F3} {1:F3}", cx1 - pad, topY) } }, PANEL); } // Nagłówek sekcji (np. "— ZABÓJSTWA —") — zmniejsza y private void XpSecHdr(ref CuiElementContainer c, string title, float cx0, float cx1, ref float y, float rowH, float botY) { if (y - rowH < botY) return; float pad = 0.008f; c.Add(new CuiLabel { Text = { Text = title, FontSize = 10, Align = TextAnchor.MiddleLeft, Color = "0.3 0.8 1 1" }, RectTransform = { AnchorMin = string.Format("{0:F3} {1:F3}", cx0 + pad, y - rowH), AnchorMax = string.Format("{0:F3} {1:F3}", cx1 - pad, y) } }, PANEL); y -= rowH; } // Wiersz etykieta + wartość XP — zmniejsza y private void XpRow(ref CuiElementContainer c, string label, double xp, float cx0, float cx1, ref float y, float rowH, float botY, float colW) { if (y - rowH < botY) return; float pad = 0.008f; // odstęp od krawędzi kolumny float xv = cx0 + colW * 0.60f; string xpStr = string.Format("{0} XP", xp); string xpCol = xp > 0 ? "1 0.65 0 1" : "0.5 0.5 0.5 1"; c.Add(new CuiLabel { Text = { Text = label, FontSize = 11, Align = TextAnchor.MiddleLeft, Color = "1 1 1 1" }, RectTransform = { AnchorMin = string.Format("{0:F3} {1:F3}", cx0 + pad, y - rowH), AnchorMax = string.Format("{0:F3} {1:F3}", xv - 0.005f, y) } }, PANEL); c.Add(new CuiLabel { Text = { Text = xpStr, FontSize = 11, Align = TextAnchor.MiddleRight, Color = xpCol }, RectTransform = { AnchorMin = string.Format("{0:F3} {1:F3}", xv, y - rowH), AnchorMax = string.Format("{0:F3} {1:F3}", cx1 - pad, y) } }, PANEL); y -= rowH; } // Komórka tekstu private void XpCell(ref CuiElementContainer c, string text, float x0, float y0, float x1, float y1, string color, int fontSize, TextAnchor align) { c.Add(new CuiLabel { Text = { Text = text, FontSize = fontSize, Align = align, Color = color }, RectTransform = { AnchorMin = string.Format("{0:F3} {1:F3}", x0, y0), AnchorMax = string.Format("{0:F3} {1:F3}", x1, y1) } }, PANEL); } // Wiersz: label po lewej + XP po prawej private void XpLabelRow(ref CuiElementContainer c, string label, double xpRate, float x0, float xVal, float x1, float y, float rowH, string labelColor, string goldColor, string dimColor) { string xpStr = string.Format("{0} XP", xpRate); string xpColor = xpRate > 0 ? goldColor : dimColor; c.Add(new CuiLabel { Text = { Text = label, FontSize = 11, Align = TextAnchor.MiddleLeft, Color = labelColor }, RectTransform = { AnchorMin = string.Format("{0:F3} {1:F3}", x0, y), AnchorMax = string.Format("{0:F3} {1:F3}", xVal - 0.01f, y + rowH) } }, PANEL); c.Add(new CuiLabel { Text = { Text = xpStr, FontSize = 11, Align = TextAnchor.MiddleRight, Color = xpColor }, RectTransform = { AnchorMin = string.Format("{0:F3} {1:F3}", xVal, y), AnchorMax = string.Format("{0:F3} {1:F3}", x1, y + rowH) } }, PANEL); } private void CloseUI(BasePlayer player) { _activePanels.Remove(player.userID); CuiHelper.DestroyUi(player, PANEL); CuiHelper.DestroyUi(player, PANEL_BG); } // Małe kolorowe przyciski w górnym pasku trybu Fame // Pozwalają przełączać między zakładkami (TOP, GATHER itd.) w Fame private void BuildFameTabButtons(ref CuiElementContainer c, string uid, string activeFameTabId) { var tabList = _tabs.Values.OrderBy(t => t.Order).ToList(); // Przyciski zaczynają się od x=0.33, tuż obok tytułu float x = 0.33f; float btnW = 0.075f; float gap = 0.006f; float y0 = 0.935f; float y1 = 0.985f; // Kolory dla każdej zakładki — różne żeby łatwo odróżnić string[] colors = { "0.2 0.6 0.9 0.9", // niebieski — TOP PvP "0.2 0.75 0.35 0.9", // zielony — Gather "0.75 0.2 0.75 0.9", // fioletowy — Loot "0.1 0.55 0.55 0.9", // turkusowy — Build "0.6 0.1 0.1 0.9", // czerwony — przyszłe "0.85 0.55 0.1 0.9", // pomarańczowy — przyszłe }; string activeColor = "1 1 1 0.25"; // podświetlenie aktywnej for (int i = 0; i < tabList.Count; i++) { var tab = tabList[i]; bool isActive = tab.Id == activeFameTabId; string bg = isActive ? activeColor : (i < colors.Length ? colors[i] : "0.3 0.3 0.3 0.8"); string border = isActive ? "1 0.8 0.2 1" : "1 1 1 0.7"; // Aktywna zakładka — podświetlona ramką if (isActive) { c.Add(new CuiPanel { Image = { Color = i < colors.Length ? colors[i] : "0.3 0.3 0.3 0.8" }, RectTransform = { AnchorMin = $"{x - 0.002f:F3} {y0 - 0.003f}", AnchorMax = $"{x + btnW + 0.002f:F3} {y1 + 0.003f}" } }, PANEL); } c.Add(new CuiButton { Button = { Command = $"majestattopcore.ui {tab.Id} 1 {tab.Id}", Color = bg }, Text = { Text = tab.Label, FontSize = 9, Align = TextAnchor.MiddleCenter, Color = isActive ? "1 1 0.2 1" : "1 1 1 1" }, RectTransform = { AnchorMin = $"{x:F3} {y0}", AnchorMax = $"{x + btnW:F3} {y1}" } }, PANEL); x += btnW + gap; if (x + btnW > 0.86f) break; // nie wychodź poza obszar tytułu } } private void BuildTabBar(ref CuiElementContainer c, string uid, string activeId, bool fame) { // Zakładki posortowane: TOP(0) → moduły → [FAME na końcu] var tabList = _tabs.Values.OrderBy(t => t.Order).ToList(); // Pasek zakładek NA DOLE: AnchorY od 0.01 do 0.08 float tabBarY0 = 0.01f; float tabBarY1 = 0.08f; float tabW = 0.09f; float gap = 0.002f; float x = 0.01f; foreach (var tab in tabList) { bool active = tab.Id == activeId && !fame; string bg = active ? "0.25 0.65 0.9 0.9" : "0.12 0.12 0.12 0.85"; string tc = active ? "1 1 1 1" : "0.7 0.7 0.7 1"; c.Add(new CuiButton { Button = { Command = $"majestattopcore.ui {tab.Id} 0", Color = bg }, Text = { Text = tab.Label, FontSize = 11, Align = TextAnchor.MiddleCenter, Color = tc }, RectTransform = { AnchorMin = $"{x:F3} {tabBarY0}", AnchorMax = $"{x + tabW:F3} {tabBarY1}" } }, PANEL); x += tabW + gap; } // FAME — po wszystkich modułach if (_cfg.EnableFame) { bool fa = fame; string bg = fa ? "0.9 0.45 0.1 0.9" : "0.12 0.12 0.12 0.85"; string tc = fa ? "1 1 1 1" : "0.7 0.7 0.7 1"; c.Add(new CuiButton { Button = { Command = $"majestattopcore.ui {activeId} 1", Color = bg }, Text = { Text = L("TabFame", uid), FontSize = 11, Align = TextAnchor.MiddleCenter, Color = tc }, RectTransform = { AnchorMin = $"{x:F3} {tabBarY0}", AnchorMax = $"{x + tabW:F3} {tabBarY1}" } }, PANEL); x += tabW + gap; } // -- ZAMKNIJ — prawy dolny, sztywny ----------------------- c.Add(new CuiButton { Button = { Command = "majestattopcore.close", Color = "0.8 0.2 0.2 0.8" }, Text = { Text = L("BtnClose", uid), FontSize = 13, Align = TextAnchor.MiddleCenter }, RectTransform = { AnchorMin = "0.87 0.01", AnchorMax = "0.99 0.08" } }, PANEL); } private void BuildLeaderboard(ref CuiElementContainer c, string uid, TabDef tab, bool fame, string viewerId) { var entries = GetLeaderboard(tab.Id, fame); var cols = tab.Columns; float startX = 0.28f; float endX = 0.88f; float colW = cols.Count > 0 ? (endX - startX) / cols.Count : 0f; // -- Nagłówki kolumn -------------------------------------- float hy = 0.85f; c.Add(new CuiLabel { Text = { Text = L("ColRank", uid), FontSize = 10, Align = TextAnchor.MiddleCenter }, RectTransform = { AnchorMin = $"0.01 {hy}", AnchorMax = $"0.04 {hy + 0.04f}" } }, PANEL); c.Add(new CuiLabel { Text = { Text = L("ColPlayer", uid), FontSize = 10, Align = TextAnchor.MiddleLeft }, RectTransform = { AnchorMin = $"0.05 {hy}", AnchorMax = $"0.27 {hy + 0.04f}" } }, PANEL); for (int i = 0; i < cols.Count; i++) { string hc; if (cols[i].Key == "XP.Total") hc = "1 0.65 0 1"; else if (cols[i].Key.EndsWith(".Total")) hc = TabColor(tab.Id, 0.9f); else hc = "1 1 1 1"; // ResolveHeader: jeśli header to klucz lang Core ("Col...") → tłumacz // jeśli to string z modułu zewnętrznego → użyj wprost string hdrText = ResolveHeader( cols[i].Header, uid, cols[i].LangKey, cols[i].Owner); c.Add(new CuiLabel { Text = { Text = hdrText, FontSize = 9, Align = TextAnchor.MiddleCenter, Color = hc }, RectTransform = { AnchorMin = $"{startX + i * colW:F3} {hy}", AnchorMax = $"{startX + (i+1) * colW:F3} {hy + 0.045f}" } }, PANEL); } c.Add(new CuiLabel { Text = { Text = L("ColTime", uid), FontSize = 10, Align = TextAnchor.MiddleCenter }, RectTransform = { AnchorMin = $"0.88 {hy}", AnchorMax = $"0.99 {hy + 0.04f}" } }, PANEL); // -- Wiersze leaderboardu --------------------------------- float rowH = 0.058f; for (int i = 0; i < entries.Count; i++) DrawRow(ref c, uid, entries[i], cols, startX, colW, 0.78f - i * rowH, false, fame, tab.Id); // -- Twoja pozycja ---------------------------------------- c.Add(new CuiLabel { Text = { Text = L("YourPos", uid), FontSize = 10, Align = TextAnchor.MiddleLeft, Color = fame ? "1 0.45 0.1 0.6" : "0.3 0.8 1 0.6" }, RectTransform = { AnchorMin = "0.05 0.21", AnchorMax = "0.3 0.24" } }, PANEL); c.Add(new CuiPanel { Image = { Color = "1 1 1 0.1" }, RectTransform = { AnchorMin = "0.02 0.20", AnchorMax = "0.98 0.202" } }, PANEL); if (_cache.TryGetValue(viewerId, out var vp)) { int rank = GetRank(viewerId, tab.Id, fame); var me = new LbEntry { SteamId = viewerId, Name = vp.Name, Stats = FilterStatsForTab(fame ? vp.FameStats : vp.Stats, tab), Rank = rank }; DrawRow(ref c, uid, me, cols, startX, colW, 0.13f, true, fame, tab.Id); } } private void DrawRow(ref CuiElementContainer c, string uid, LbEntry e, List<(string Key, string Header, string LangKey, string Owner)> cols, float startX, float colW, float y, bool isMe, bool fame, string tabId = "pvp") { string tc = isMe ? "1 0.8 0.2 1" : "1 1 1 1"; c.Add(new CuiLabel { Text = { Text = $"{e.Rank}.", FontSize = 11, Align = TextAnchor.MiddleCenter, Color = tc }, RectTransform = { AnchorMin = $"0.01 {y}", AnchorMax = $"0.04 {y + 0.05f}" } }, PANEL); c.Add(new CuiElement { Parent = PANEL, Components = { new CuiRawImageComponent { SteamId = e.SteamId }, new CuiRectTransformComponent { AnchorMin = $"0.05 {y + 0.005f}", AnchorMax = $"0.075 {y + 0.045f}" } } }); c.Add(new CuiButton { Button = { Command = $"client.openurl https://steamcommunity.com/profiles/{e.SteamId}", Color = "0 0 0 0" }, Text = { Text = e.Name ?? "Unknown", FontSize = 11, Align = TextAnchor.MiddleLeft, Color = tc }, RectTransform = { AnchorMin = $"0.08 {y}", AnchorMax = $"0.27 {y + 0.05f}" } }, PANEL); for (int i = 0; i < cols.Count; i++) { double val = e.Stats.TryGetValue(cols[i].Key, out double v) ? v : 0.0; string disp = FormatNumber(val); // XP.Total = złoty // *.Total (Gather/Loot/Build) = kolor zakładki (jasny odcień) // Pozostałe = normalny kolor wiersza string col; if (isMe) col = tc; else if (cols[i].Key == "XP.Total") col = "1 0.65 0 1"; else if (cols[i].Key.EndsWith(".Total")) col = TabColor(tabId, 0.9f); else col = tc; c.Add(new CuiLabel { Text = { Text = disp, FontSize = 11, Align = TextAnchor.MiddleCenter, Color = col }, RectTransform = { AnchorMin = $"{startX + i * colW:F3} {y}", AnchorMax = $"{startX + (i+1) * colW:F3} {y + 0.05f}" } }, PANEL); } if (e.Stats.TryGetValue("PlayTime.Seconds", out double pt)) { var ts = TimeSpan.FromSeconds(pt); string t = ts.TotalHours >= 1 ? $"{(int)ts.TotalHours}h {ts.Minutes}m" : $"{ts.Minutes}m"; string timeCol = isMe ? "1 0.8 0.2 1" : (fame ? "1 0.45 0.1 1" : "0.3 0.8 1 1"); c.Add(new CuiLabel { Text = { Text = t, FontSize = 10, Align = TextAnchor.MiddleCenter, Color = timeCol }, RectTransform = { AnchorMin = $"0.88 {y}", AnchorMax = $"0.99 {y + 0.05f}" } }, PANEL); } } // ------------------------------------------------------------- // TŁUMACZENIE NAGŁÓWKÓW KOLUMN (per gracz) // ------------------------------------------------------------- // Klucze lang dla kolumn Core — tłumaczone per-gracz private static readonly HashSet _langColKeys = new HashSet { "ColXP","ColKills","ColDeaths","ColPve","ColHeli","ColBradley", "ColSci","ColZombie","ColAnimal","ColTime","ColRank","ColPlayer" }; /// Jeśli header jest kluczem lang Core (np. "ColXP") → tłumacz dla gracza. /// Jeśli header to dowolny inny string (z modułu zewnętrznego) → użyj wprost. private string ResolveHeader(string header, string uid, string langKey = null, string ownerPlugin = null) { if (string.IsNullOrEmpty(header)) return ""; // 1. Klucz lang Core (np. "ColXP", "ColKills") → tłumacz przez Core lang if (_langColKeys.Contains(header)) return L(header, uid); // 2. lang_key z modułu → tłumacz przez lang pluginu modułu if (!string.IsNullOrEmpty(langKey) && !string.IsNullOrEmpty(ownerPlugin)) { var pl = plugins.Find(ownerPlugin); if (pl != null) { string translated = lang.GetMessage(langKey, pl, uid); if (!string.IsNullOrEmpty(translated) && translated != langKey) return translated; } } return header; } // ------------------------------------------------------------- // FORMAT LICZB // ------------------------------------------------------------- private string FormatNumber(double val) { string fmt = _cfg.NumberFormat?.ToUpper() ?? "M"; int dec = Math.Max(0, Math.Min(3, _cfg.NumberFormatDecimals)); string decFmt = dec == 0 ? "F0" : "F" + dec; switch (fmt) { case "KM": if (val >= 1_000_000) return (val / 1_000_000).ToString(decFmt) + "M"; if (val >= 1_000) return (val / 1_000).ToString(decFmt) + "k"; return ((long)val).ToString(); case "K": if (val >= 1_000) return (val / 1_000).ToString(decFmt) + "k"; return ((long)val).ToString(); case "M": if (val >= 1_000_000) return (val / 1_000_000).ToString(decFmt) + "M"; return ((long)val).ToString(); default: // "none" lub inne — pełne liczby return ((long)val).ToString("N0"); } } // -- Komendy otwierające konkretne zakładki ------------------- // TOP PvP // /top już zarejestrowany wyżej // /famepvp — Fame na zakładce PvP [ChatCommand("famepvp")] void CmdFamePvp(BasePlayer p) { if (!_cfg.EnableFame) { Player.Message(p, L("FameDisabled", p.UserIDString)); return; } OpenUI(p, "pvp", true, "pvp"); } // /topgather — zakładka Gather (sezonowa) [ChatCommand("topgather")] void CmdTopGather(BasePlayer p) => OpenUI(p, "gather", false); // /famegather — Fame na zakładce Gather [ChatCommand("famegather")] void CmdFameGather(BasePlayer p) { if (!_cfg.EnableFame) { Player.Message(p, L("FameDisabled", p.UserIDString)); return; } OpenUI(p, "gather", true, "gather"); } // /topbuild — zakładka Build (sezonowa) [ChatCommand("topbuild")] void CmdTopBuild(BasePlayer p) => OpenUI(p, "build", false); // /famebuild — Fame na zakładce Build [ChatCommand("famebuild")] void CmdFameBuild(BasePlayer p) { if (!_cfg.EnableFame) { Player.Message(p, L("FameDisabled", p.UserIDString)); return; } OpenUI(p, "build", true, "build"); } // /toploot — zakładka Loot (sezonowa) [ChatCommand("toploot")] void CmdTopLoot(BasePlayer p) => OpenUI(p, "loot", false); // /fameloot — Fame na zakładce Loot [ChatCommand("fameloot")] void CmdFameLoot(BasePlayer p) { if (!_cfg.EnableFame) { Player.Message(p, L("FameDisabled", p.UserIDString)); return; } OpenUI(p, "loot", true, "loot"); } // -- Krótkie aliasy (bez prefiksu "top") ---------------------- // /gather → zakładka Gather [ChatCommand("gather")] void CmdGather(BasePlayer p) => OpenUI(p, "gather", false); // /loot → zakładka Loot [ChatCommand("loot")] void CmdLoot(BasePlayer p) => OpenUI(p, "loot", false); // /build → zakładka Build [ChatCommand("build")] void CmdBuild(BasePlayer p) => OpenUI(p, "build", false); // -- /stats [nick] — podsumowanie XP gracza na chacie ------- [ChatCommand("stats")] void CmdStats(BasePlayer player, string cmd, string[] args) { if (player == null) return; string uid = player.UserIDString; string viewName = player.displayName; if (args.Length > 0) { if (!player.IsAdmin && !permission.UserHasPermission(uid, PermAdmin)) { Player.Message(player, L("NoPermission", uid)); return; } string targetId = ResolvePlayer(string.Join(" ", args), out string tName); if (targetId == null) { Player.Message(player, string.Format(L("NotFound", uid), string.Join(" ", args))); return; } uid = targetId; viewName = tName; } if (!_cache.TryGetValue(uid, out var prof)) { Player.Message(player, "Brak danych dla tego gracza."); return; } CommitPlayTime(uid, prof); long xp = (long)prof.Get("XP.Total"); long kills = (long)prof.Get("PvP.Kills"); long deaths = (long)prof.Get("PvP.Deaths"); long heli = (long)prof.Get("Kill.Heli"); long bradley = (long)prof.Get("Kill.Bradley"); long sci = (long)prof.Get("Kill.Scientist"); long zombie = (long)prof.Get("Kill.Zombie"); long animal = (long)prof.Get("Kill.Animal"); long pt = (long)prof.Get("PlayTime.Seconds"); int rank = GetRank(uid, "pvp", false); var ts = System.TimeSpan.FromSeconds(pt); string time = ts.TotalHours >= 1 ? string.Format("{0}h {1}m", (int)ts.TotalHours, ts.Minutes) : string.Format("{0}m", ts.Minutes); bool isOwn = uid == player.UserIDString; string tcol = isOwn ? "#4dcfff" : "#ffaa00"; string title = isOwn ? "TWOJE STATYSTYKI" : "STATYSTYKI: " + viewName; var sb = new System.Text.StringBuilder(); sb.AppendLine(string.Format("━━━━ {1} ━━━━", tcol, title)); sb.AppendLine(string.Format("XP: {0} Pozycja: #{1}", xp.ToString("N0"), rank)); sb.AppendLine(string.Format("Czas gry: {0}", time)); sb.AppendLine(); sb.AppendLine("— Zabójstwa —"); sb.AppendLine(string.Format(" PvP: {0} ({1} XP/kill)", kills, _cfg.PointsPerPvpKill)); sb.AppendLine(string.Format(" Heli: {0} ({1} XP/kill)", heli, _cfg.PointsPerHeliKill)); sb.AppendLine(string.Format(" Bradley: {0} ({1} XP/kill)", bradley, _cfg.PointsPerBradleyKill)); sb.AppendLine(string.Format(" Naukowiec: {0} ({1} XP/kill)", sci, _cfg.PointsPerScientistKill)); sb.AppendLine(string.Format(" Zombie: {0} ({1} XP/kill)", zombie, _cfg.PointsPerZombieKill)); sb.AppendLine(string.Format(" Zwierze: {0} ({1} XP/kill)", animal, _cfg.PointsPerAnimalKill)); sb.AppendLine(string.Format(" Smierci: {0}", deaths)); sb.AppendLine(); sb.AppendLine("— Czas gry —"); sb.AppendLine(string.Format(" Co 30 min: {0} XP", _cfg.PointsPer30MinPlayTime)); var gatherKeys = new[] { "Gather.Wood","Gather.Stone","Gather.Metal","Gather.Sulfur","Gather.Scrap" }; bool hasGather = System.Array.Exists(gatherKeys, k => prof.Get(k) > 0 || _cfg.XpSources.ContainsKey(k)); if (hasGather) { sb.AppendLine(); sb.AppendLine("— Zbieranie —"); foreach (var k in gatherKeys) { double val = prof.Get(k); string label = k.Replace("Gather.", ""); double xpRate = _cfg.XpSources.TryGetValue(k, out double r) ? r : 0; if (val > 0 || xpRate > 0) { string xpInfo = xpRate > 0 ? string.Format(" ({0} XP/szt)", xpRate) : ""; sb.AppendLine(string.Format(" {0,-10} {1}{2}", label, val.ToString("N0"), xpInfo)); } } } var lootKeys = new[] { "Loot.EliteCrate","Loot.MilitaryCrate","Loot.LockedCrate","Loot.HackableCrate","Loot.Airdrop","Loot.NormalCrate","Loot.Barrel" }; bool hasLoot = System.Array.Exists(lootKeys, k => prof.Get(k) > 0 || _cfg.XpSources.ContainsKey(k)); if (hasLoot) { sb.AppendLine(); sb.AppendLine("— Loot —"); foreach (var k in lootKeys) { double val = prof.Get(k); string label = k.Replace("Loot.", ""); double xpRate = _cfg.XpSources.TryGetValue(k, out double r) ? r : 0; if (val > 0 || xpRate > 0) { string xpInfo = xpRate > 0 ? string.Format(" ({0} XP/szt)", xpRate) : ""; sb.AppendLine(string.Format(" {0,-15} {1}{2}", label, val.ToString("N0"), xpInfo)); } } } sb.Append(string.Format("━━━━━━━━━━━━━━━━━━━━━━━━", tcol)); Player.Message(player, sb.ToString()); } // -- /xpinfo — tabela XP za poszczególne czynności ---------- [ChatCommand("xpinfo")] void CmdXpInfo(BasePlayer player) { if (player == null) return; var sb = new System.Text.StringBuilder(); sb.AppendLine("━━━━ TABELA XP ━━━━"); sb.AppendLine("Ile XP dostajesz za poszczegolne czynnosci:"); sb.AppendLine(); sb.AppendLine("— Zabojstwa PvP / PvE —"); sb.AppendLine(string.Format(" Zabicie gracza: {0} XP", _cfg.PointsPerPvpKill)); sb.AppendLine(string.Format(" Zestrzelenie Heli: {0} XP", _cfg.PointsPerHeliKill)); sb.AppendLine(string.Format(" Zniszcz. Bradley: {0} XP", _cfg.PointsPerBradleyKill)); sb.AppendLine(string.Format(" Naukowiec: {0} XP", _cfg.PointsPerScientistKill)); sb.AppendLine(string.Format(" Zombie/Strach: {0} XP", _cfg.PointsPerZombieKill)); sb.AppendLine(string.Format(" Zwierze: {0} XP", _cfg.PointsPerAnimalKill)); sb.AppendLine(); sb.AppendLine("— Czas gry —"); sb.AppendLine(string.Format(" Co 30 minut: {0} XP", _cfg.PointsPer30MinPlayTime)); var moduleGroups = new System.Collections.Generic.Dictionary { ["Zbieranie"] = new[] { "Gather.Wood","Gather.Stone","Gather.Metal","Gather.Sulfur","Gather.Scrap","Gather.Food" }, ["Loot"] = new[] { "Loot.EliteCrate","Loot.MilitaryCrate","Loot.LockedCrate","Loot.HackableCrate","Loot.Airdrop","Loot.UnderwaterCrate","Loot.NormalCrate","Loot.Barrel" }, ["Budowanie"] = new[] { "Build.Walls","Build.Floors","Build.Doors","Build.Turrets","Build.Traps","Build.Electrical","Build.Pipes" } }; foreach (var group in moduleGroups) { bool hasAny = System.Array.Exists(group.Value, k => _cfg.XpSources.ContainsKey(k)); if (!hasAny) continue; sb.AppendLine(); sb.AppendLine(string.Format("— {0} —", group.Key)); foreach (var k in group.Value) { if (!_cfg.XpSources.TryGetValue(k, out double xpRate)) continue; string label = k.Contains(".") ? k.Substring(k.IndexOf('.') + 1) : k; string xpStr = xpRate > 0 ? string.Format("{0} XP/szt", xpRate) : "0 XP"; sb.AppendLine(string.Format(" {0,-16} {1}", label, xpStr)); } } sb.Append("━━━━━━━━━━━━━━━━━━━━"); Player.Message(player, sb.ToString()); } // ═════════════════════════════════════════════════════════════ // MAPA — zbieranie danych // ═════════════════════════════════════════════════════════════ private void UpdateMapEntities() { var entities = new List(); // Gracze online foreach (var p in BasePlayer.activePlayerList) { var e = new MapEntity { SteamId = p.UserIDString, Nick = p.displayName, X = p.transform.position.x, Z = p.transform.position.z, Rot = p.eyes.rotation.eulerAngles.y, Type = "player", TeamId = (long)(p.currentTeam), IsOnline = true }; _mapPlayers[p.UserIDString] = e; entities.Add(e); } // Helikoptery — świeży skan co tick (timer = główny wątek, brak race condition) foreach (var h in BaseNetworkable.serverEntities.OfType()) { if (h == null || h.IsDestroyed || h.IsDead()) continue; entities.Add(new MapEntity { X = h.transform.position.x, Z = h.transform.position.z, Type = "heli" }); } // Bradley — świeży skan + filtr martwych wraków (IsDead) = brak kumulacji foreach (var b in BaseNetworkable.serverEntities.OfType()) { if (b == null || b.IsDestroyed || b.IsDead()) continue; entities.Add(new MapEntity { X = b.transform.position.x, Z = b.transform.position.z, Type = "bradley" }); } // Cargo Ship foreach (var s in BaseNetworkable.serverEntities.OfType()) { if (s == null || s.IsDestroyed) continue; entities.Add(new MapEntity { X = s.transform.position.x, Z = s.transform.position.z, Type = "cargo" }); } // CH47 (Chinook) foreach (var c in BaseNetworkable.serverEntities.OfType()) { if (c == null || c.IsDestroyed || c.IsDead()) continue; entities.Add(new MapEntity { X = c.transform.position.x, Z = c.transform.position.z, Type = "ch47" }); } // Vending Machines (sklepy graczy) foreach (var v in BaseNetworkable.serverEntities.OfType()) { if (v == null || v.IsDestroyed) continue; if (!v.IsBroadcasting()) continue; // tylko aktywne sklepy var items = new System.Text.StringBuilder(); foreach (var order in v.sellOrders.sellOrders) { string itemName = ItemManager.FindItemDefinition(order.itemToSellID)?.displayName.translated ?? order.itemToSellID.ToString(); string costName = ItemManager.FindItemDefinition(order.currencyID)?.displayName.translated ?? order.currencyID.ToString(); if (items.Length > 0) items.Append("|"); items.Append($"{order.itemToSellAmount}x {itemName} za {order.currencyAmountPerItem}x {costName}"); } entities.Add(new MapEntity { SteamId = v.OwnerID.ToString(), Nick = v.shopName ?? "Sklep", X = v.transform.position.x, Z = v.transform.position.z, Type = "vending", HasStock = v.inventory != null && v.inventory.itemList.Count > 0, IsOnline = true }); } _mapEntities = entities; // Wywołaj Oxide hook zamiast callbacków Interface.CallHook("OnMajestatMapUpdate", entities.ToArray()); } // -- Chat hooks ----------------------------------------------- void OnPlayerChat(BasePlayer player, string message, ConVar.Chat.ChatChannel channel) { // Ignoruj własne broadcasty z WebChat if (_suppressChatHook) return; string ch = "global"; switch (channel) { case ConVar.Chat.ChatChannel.Team: ch = "team"; break; case ConVar.Chat.ChatChannel.Local: ch = "local"; break; } bool isAdmin = player.IsAdmin || permission.UserHasPermission(player.UserIDString, Name.ToLower() + ".admin"); BroadcastChat(new ChatMsg { SteamId = player.UserIDString, Nick = player.displayName, Text = message, Channel = ch, Source = "game", IsAdmin = isAdmin, Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds() }); } // Przechwytywanie czatu konsoli serwera (RustAdmin, linux console, RCON) void OnServerMessage(string message, string name, string color, ulong id) { if (string.IsNullOrEmpty(message)) return; BroadcastChat(new ChatMsg { SteamId = "0", Nick = "Server", Text = message, Channel = "global", Source = "server", IsAdmin = true, Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds() }); } // Clan chat — opcjonalny (Clans plugin) void OnClanChat(BasePlayer player, string message) { if (player == null) return; BroadcastChat(new ChatMsg { SteamId = player.UserIDString, Nick = player.displayName, Text = message, Channel = "clan", Source = "game", Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds() }); } // Friends prywatny — opcjonalny void OnFriendMessage(BasePlayer sender, BasePlayer target, string message) { if (sender == null) return; BroadcastChat(new ChatMsg { SteamId = sender.UserIDString, Nick = sender.displayName, Text = message, Channel = "friends", Source = "game", Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds() }); } // BroadcastChat jest zdefiniowany przy polach _chatSubscribers powyżej // ═════════════════════════════════════════════════════════════ // API — mapa // ═════════════════════════════════════════════════════════════ // Zwraca encje mapowe filtrowane wg uprawnień steamId // steamId="" = niezalogowany (tylko monumenty/eventy bez graczy) // steamId=admin = wszystko [HookMethod("API_GetMapData")] public List> API_GetMapData(string steamId, bool isAdmin) { var result = new List>(); ulong myTeam = 0; BasePlayer me = null; if (!string.IsNullOrEmpty(steamId)) { me = BasePlayer.Find(steamId); if (me != null && me.currentTeam != 0) myTeam = me.currentTeam; else if (ulong.TryParse(steamId, out ulong sid)) { var rm = RelationshipManager.ServerInstance; if (rm != null) foreach (var team in rm.teams.Values) if (team.members.Contains(sid)) { myTeam = team.teamID; break; } } } foreach (var e in _mapEntities) { if (e.Type != "player") { // Wszystko wymaga logowania if (!string.IsNullOrEmpty(steamId)) result.Add(EntityToDict(e)); continue; } if (isAdmin) { result.Add(EntityToDict(e)); continue; } if (string.IsNullOrEmpty(steamId)) continue; // niezalogowany — nie widzi graczy // Siebie zawsze widzi if (e.SteamId == steamId) { result.Add(EntityToDict(e)); continue; } // Team if (myTeam != 0 && e.TeamId == (long)myTeam) { result.Add(EntityToDict(e)); continue; } // Clans — jeśli plugin załadowany var clans = plugins.Find("Clans"); if (clans != null && me != null) { string myClan = clans.Call("GetClanOf", steamId); string hisClan = clans.Call("GetClanOf", e.SteamId); if (!string.IsNullOrEmpty(myClan) && myClan == hisClan) { result.Add(EntityToDict(e)); continue; } } // Friends — jeśli plugin załadowany var friends = plugins.Find("Friends"); if (friends != null) { bool isFriend = friends.Call("HasFriend", steamId, e.SteamId); if (isFriend) { result.Add(EntityToDict(e)); continue; } } } return result; } private Dictionary EntityToDict(MapEntity e) { return new Dictionary { ["steamId"] = e.SteamId, ["nick"] = e.Nick, ["x"] = e.X, ["z"] = e.Z, ["rot"] = e.Rot, ["type"] = e.Type, ["teamId"] = e.TeamId, ["isOnline"] = e.IsOnline, ["hasStock"] = e.HasStock }; } [HookMethod("API_GetMonuments")] public List> API_GetMonuments() { var result = new List>(); foreach (var m in TerrainMeta.Path.Monuments) { if (m == null) continue; result.Add(new Dictionary { ["name"] = m.displayPhrase.translated ?? m.name ?? "", ["x"] = m.transform.position.x, ["z"] = m.transform.position.z, ["size"] = m.Bounds.size.magnitude }); } return result; } [HookMethod("API_GetMapInfo")] public Dictionary API_GetMapInfo() { return new Dictionary { ["worldSize"] = World.Size, ["seed"] = World.Seed, ["name"] = World.Name }; } // ═════════════════════════════════════════════════════════════ // API — chat // ═════════════════════════════════════════════════════════════ [HookMethod("API_GetChatHistory")] public List> API_GetChatHistory(int limit, string channel) { List msgs; lock (_chatHistory) { msgs = string.IsNullOrEmpty(channel) || channel == "all" ? _chatHistory.ToList() : _chatHistory.Where(m => m.Channel == channel).ToList(); } if (limit > 0 && msgs.Count > limit) msgs = msgs.Skip(msgs.Count - limit).ToList(); return msgs.Select(m => new Dictionary { ["steamId"] = m.SteamId, ["nick"] = m.Nick, ["text"] = m.Text, ["channel"] = m.Channel, ["source"] = m.Source, ["isAdmin"] = m.IsAdmin, ["timestamp"] = m.Timestamp }).ToList(); } [HookMethod("API_SubscribeChat")] public void API_SubscribeChat(object callback) { /* legacy — używaj hooka OnMajestatChatMessage */ } [HookMethod("API_UnsubscribeChat")] public void API_UnsubscribeChat(object callback) { } // Wyślij wiadomość do gry z Web (przez Web plugin) private bool _suppressChatHook = false; [HookMethod("API_SendWebChat")] public void API_SendWebChat(string nick, string text, string channel, string steamId, bool isAdmin) { Puts($"[WebChat][{channel}] {nick}: {text}"); // Ustaw flagę żeby OnPlayerChat nie przetwarzał naszego Broadcast _suppressChatHook = true; string prefix = isAdmin ? "[WEB][ADMIN]" : "[WEB]"; if (channel == "global") Server.Broadcast($"{prefix} {nick}: {text}"); else if (channel == "team") { var player = BasePlayer.Find(steamId); if (player != null && player.currentTeam != 0) { var team = RelationshipManager.ServerInstance.FindTeam(player.currentTeam); if (team != null) foreach (var mid in team.members) { var member = BasePlayer.FindByID(mid); if (member != null) member.ChatMessage($"[TEAM][WEB] {nick}: {text}"); } } } _suppressChatHook = false; // Dodaj do historii raz — bezpośrednio BroadcastChat(new ChatMsg { SteamId = steamId, Nick = nick, Text = text, Channel = channel, Source = "web", IsAdmin = isAdmin, Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds() }); } // Subskrypcja danych mapowych — używaj hooka OnMajestatMapUpdate [HookMethod("API_SubscribeMap")] public void API_SubscribeMap(object callback) { /* legacy — używaj hooka OnMajestatMapUpdate */ } [HookMethod("API_UnsubscribeMap")] public void API_UnsubscribeMap(object callback) { } [HookMethod("API_GetXpInfo")] public Dictionary API_GetXpInfo() { // PVP/PVE var pvp = new Dictionary { ["Gracz (PvP)"] = _cfg.PointsPerPvpKill, ["Helikopter"] = _cfg.PointsPerHeliKill, ["Bradley"] = _cfg.PointsPerBradleyKill, ["Naukowiec"] = _cfg.PointsPerScientistKill, ["Zombie"] = _cfg.PointsPerZombieKill, ["Zwierze"] = _cfg.PointsPerAnimalKill, ["Co 30 min"] = _cfg.PointsPer30MinPlayTime, }; // Moduły z XpSources — pogrupowane po prefiksie var grouped = new Dictionary>(); var prefOrder = new[] { "Gather", "Loot", "Build" }; foreach (var kv in _cfg.XpSources) { int dot = kv.Key.IndexOf('.'); if (dot < 0) continue; string pfx = kv.Key.Substring(0, dot); string lbl = kv.Key.Substring(dot + 1); if (!grouped.ContainsKey(pfx)) grouped[pfx] = new Dictionary(); grouped[pfx][lbl] = kv.Value; } // Posortuj kolumny var modules = new List>(); foreach (var pfx in prefOrder) if (grouped.ContainsKey(pfx)) modules.Add(new Dictionary { ["name"] = pfx, ["rows"] = grouped[pfx] }); foreach (var kv in grouped) if (!prefOrder.Contains(kv.Key)) modules.Add(new Dictionary { ["name"] = kv.Key, ["rows"] = kv.Value }); return new Dictionary { ["pvp"] = pvp, ["modules"] = modules }; } // -- Komendy konsolowe ---------------------------------------- // Diagnostyka mapy — pokazuje encje pojazdów (Bradley/Heli/CH47/Cargo) i NPC. // Użycie z konsoli serwera/RCON: mtopcore.mapdiag [ConsoleCommand("mtopcore.mapdiag")] private void ConsoleCmdMapDiag(ConsoleSystem.Arg arg) { if (arg.Player() != null && !arg.Player().IsAdmin) return; float worldHalf = World.Size / 2f + 600f; var sb = new System.Text.StringBuilder(); sb.AppendLine($"=== MAP DIAG (World.Size={World.Size}) ==="); // Pojazdy/eventy DiagEntityType(sb, "Bradley", worldHalf, true); DiagEntityType(sb, "Heli", worldHalf, true); DiagEntityType(sb, "CH47", worldHalf, true); DiagEntityType(sb, "Cargo", worldHalf, true); // NPC — tylko liczby sb.AppendLine("--- NPC ---"); DiagEntityType(sb, "Scientist", worldHalf, false); DiagEntityType(sb, "Scarecrow", worldHalf, false); int npcPlayers = 0, realPlayers = 0; foreach (var pl in BaseNetworkable.serverEntities.OfType()) { if (pl == null || pl.IsDestroyed) continue; if (pl.IsNpc) npcPlayers++; else realPlayers++; } sb.AppendLine($"--- NPC players (IsNpc, w tym scientist/scarecrow): {npcPlayers} " + $"| Gracze realni: {realPlayers}"); // Zwierzęta — podział na gatunki (po prefabie) + flaga rozpoznania przez klasyfikator var animalCounts = new Dictionary(); var animalRecognized = new Dictionary(); int unrecognized = 0; foreach (var e in BaseNetworkable.serverEntities.OfType()) { if (e == null || e.IsDestroyed || e is BasePlayer) continue; string pfx = e.ShortPrefabName?.ToLower() ?? ""; string tn = e.GetType().FullName ?? ""; // kandydat na zwierzę: stary BaseAnimalNPC, encja Gen2 (nie-naukowiec), lub match prefabu bool animalCandidate = (e is BaseAnimalNPC) || (tn.Contains(".Gen2.") && !e.GetType().Name.Contains("Scientist")) || IsAnimalEntity(e, pfx); if (!animalCandidate) continue; string species = string.IsNullOrEmpty(pfx) ? e.GetType().Name : pfx; if (!animalCounts.ContainsKey(species)) { animalCounts[species] = 0; animalRecognized[species] = IsAnimalEntity(e, pfx); } animalCounts[species]++; if (!IsAnimalEntity(e, pfx)) unrecognized++; } sb.AppendLine("--- Zwierzęta (gatunki) ---"); if (animalCounts.Count == 0) sb.AppendLine(" (brak)"); foreach (var kv in animalCounts.OrderByDescending(x => x.Value)) sb.AppendLine($" {kv.Key}: {kv.Value} [{(animalRecognized[kv.Key] ? "OK" : "FALLBACK! dodaj do AnimalPrefabKeywords")}]"); if (unrecognized > 0) sb.AppendLine($" UWAGA: {unrecognized} encji zwierząt NIE jest rozpoznawanych przez klasyfikator " + $"(wpadają w 'Unknown NPC fallback'). Gatunki z [FALLBACK!] wyżej."); string outp = sb.ToString(); arg.ReplyWith(outp); Puts(outp); } // -- Reset statystyk z konsoli/RCON ---------------------------- // mtopcore.reset — reset sezonowych statystyk gracza [ConsoleCommand("mtopcore.reset")] private void ConsoleCmdReset(ConsoleSystem.Arg arg) { var pl = arg.Player(); if (pl != null && !pl.IsAdmin && !permission.UserHasPermission(pl.UserIDString, PermAdmin)) return; string input = (arg.GetString(0, "") ?? "").Trim(); if (string.IsNullOrEmpty(input)) { arg.ReplyWith("Użycie: mtopcore.reset (lub mtopcore.resetall dla wszystkich)"); return; } string targetId = ResolvePlayer(input, out string tName); if (targetId == null) { arg.ReplyWith($"[MajestatTopCore] Nie znaleziono gracza: {input}"); return; } ExecuteSingleReset(targetId); string msg = $"[MajestatTopCore] Zresetowano statystyki sezonowe gracza {tName} ({targetId}). Fame zachowane."; arg.ReplyWith(msg); Puts(msg); } // mtopcore.resetall — reset sezonowych statystyk WSZYSTKICH graczy (od razu, bez confirm) [ConsoleCommand("mtopcore.resetall")] private void ConsoleCmdResetAll(ConsoleSystem.Arg arg) { var pl = arg.Player(); if (pl != null && !pl.IsAdmin && !permission.UserHasPermission(pl.UserIDString, PermAdmin)) return; ExecuteGlobalReset(); string msg = "[MajestatTopCore] Zresetowano statystyki sezonowe WSZYSTKICH graczy. Fame zachowane."; arg.ReplyWith(msg); Puts(msg); } // Helper diagnostyczny: zlicza encje danego typu, opcjonalnie wypisuje każdą. // Działa dla dowolnego BaseNetworkable; IsDead sprawdzane tylko dla BaseCombatEntity. private void DiagEntityType(System.Text.StringBuilder sb, string label, float worldHalf, bool listEach) where T : BaseNetworkable { int total = 0, shown = 0, dead = 0, destroyed = 0, outOfBounds = 0; foreach (var e in BaseNetworkable.serverEntities.OfType()) { total++; if (e == null) continue; if (e.IsDestroyed) { destroyed++; continue; } var bce = e as BaseCombatEntity; if (bce != null && bce.IsDead()) { dead++; continue; } var p = e.transform.position; bool oob = Mathf.Abs(p.x) > worldHalf || Mathf.Abs(p.z) > worldHalf; if (oob) outOfBounds++; else shown++; if (listEach) sb.AppendLine($" {label} id={e.net?.ID.Value} pos=({p.x:F0},{p.y:F0},{p.z:F0}) " + $"hp={(bce != null ? bce.health.ToString("F0") : "-")} " + $"oob={oob} prefab={e.ShortPrefabName}"); } sb.AppendLine($"--- {label}: total={total} żywych_widocznych={shown} " + $"martwych={dead} zniszczonych={destroyed} pozaMapą={outOfBounds}"); } [ConsoleCommand("majestattopcore.ui")] private void ConsoleCmdUI(ConsoleSystem.Arg arg) { var player = arg.Player(); if (player == null) return; string tabId = arg.GetString(0, "pvp"); bool fame = arg.GetBool(1, false); // 3. argument: fameTabId — która zakładka w trybie Fame ("_" = brak) string fameTabId = arg.HasArgs(3) ? arg.GetString(2) : null; if (fameTabId == "_") fameTabId = null; // 4. argument: xpInfo (0/1) bool xpInfo = arg.HasArgs(4) && arg.GetString(3) == "1"; OpenUI(player, tabId, fame, fameTabId, xpInfo); } [ConsoleCommand("majestattopcore.close")] private void ConsoleCmdClose(ConsoleSystem.Arg arg) { var player = arg.Player(); if (player == null) return; CloseUI(player); } // -- Komendy dla Info panel ----------------------------------- [ConsoleCommand("majestattopcore.openinfo")] private void ConsoleCmdOpenInfo(ConsoleSystem.Arg arg) { var player = arg.Player(); if (player == null) return; string tabId = arg.GetString(0, _infoModule?.Default ?? "rules"); API_OpenInfoPanel(player, tabId, false); } // Przełącz zakładkę w Info panelu (klik lewego paska) [ConsoleCommand("majestattopcore.infotab")] private void ConsoleCmdInfoTab(ConsoleSystem.Arg arg) { var player = arg.Player(); if (player == null) return; string tabId = arg.GetString(0, _infoModule?.Default ?? "rules"); if (MajestatTopInfo != null) MajestatTopInfo.Call("OpenEmbedded", player, tabId, false); } // Zamknij Info panel i wróć do leaderboardu (lub całkowicie) [ConsoleCommand("majestattopcore.closeinfo")] private void ConsoleCmdCloseInfo(ConsoleSystem.Arg arg) { var player = arg.Player(); if (player == null) return; _infoOpenPlayers.Remove(player.userID); CuiHelper.DestroyUi(player, "MajestatInfoEmbed"); // Powiadom Info o zamknięciu — synchronizuje _activePanels dla firstOpen MajestatTopInfo?.Call("OnInfoPanelClosed", player); CloseUI(player); } } }