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.0.1 * ================== * Centralny framework. Pełna kompatybilność z MajestatTop 2.9.2. * * MIGRACJA Z 2.9.2: * JSON → automatyczna przy starcie (czyta MajestatTop.json) * MySQL → automatyczna przy starcie (czyta majestat_stats) * SQLite→ automatyczna przy starcie (czyta MajestatTop.db / majestat_stats) * * 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 * * 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.0.1")] [Description("Central stats framework for MajestatTop — DB, cache, UI, XP, leaderboards, 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 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, np. 720 = co 12h, 1440 = co 24h [JsonProperty("Update Check Interval (minutes, 0 = startup only)")] public int UpdateCheckIntervalMinutes = 60; // co 60 minut domyślnie [JsonProperty("Unknown NPC fallback category (animal, scientist, zombie, none)")] public string UnknownNpcFallback = "animal"; [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(). // Nie edytuj ręcznie — ta sekcja jest zarządzana automatycznie. [JsonProperty("XP Sources (modules — managed automatically)")] public Dictionary XpSources = new Dictionary(); } private ConfigData _cfg; protected override void LoadConfig() { base.LoadConfig(); try { _cfg = Config.ReadObject() ?? new ConfigData(); } catch { _cfg = new ConfigData(); } SaveConfig(); } protected override void LoadDefaultConfig() => _cfg = new ConfigData(); protected override void SaveConfig() => Config.WriteObject(_cfg); // ───────────────────────────────────────────────────────────── // 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)> Columns = new List<(string, string)>(); } private Dictionary _tabs = new Dictionary(); // ───────────────────────────────────────────────────────────── // HELI TRACKER // ───────────────────────────────────────────────────────────── private Dictionary _heliTracker = new Dictionary(); // Reset confirmations private HashSet _resetConfirm = new HashSet(); // ───────────────────────────────────────────────────────────── // 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 ━━━━", }, this); lang.RegisterMessages(new Dictionary { ["TabFame"] = "SŁAWA", ["TitleFame"] = "HALL OF FAME", ["BtnClose"] = "ZAMKNIJ", ["ColRank"] = "LP.", ["ColPlayer"] = "NAZWA GRACZA", ["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 ━━━━", }, this, "pl"); } private string L(string k, string uid = null) => lang.GetMessage(k, this, uid); // ═════════════════════════════════════════════════════════════ // INIT // ═════════════════════════════════════════════════════════════ void OnServerInitialized() { permission.RegisterPermission(PermAdmin, this); RegisterCorePvpTab(); InitDatabase(() => { MigrateFromLegacy(() => { foreach (var p in BasePlayer.activePlayerList) LoadProfile(p.UserIDString, p.displayName); Puts("Gotowy. Wersja 3.0.1"); }); }); _saveTimer = timer.Every(_cfg.AutoSaveInterval, FlushSaveQueue); _lbCacheTimer = timer.Every(_cfg.LeaderboardCacheTTL, () => _lbCache.Clear()); if (_cfg.CheckUpdates) { timer.Once(5f, CheckForUpdate); // Cykliczne sprawdzanie jeśli interwał > 0 if (_cfg.UpdateCheckIntervalMinutes > 0) timer.Every(_cfg.UpdateCheckIntervalMinutes * 60f, CheckForUpdate); } } // ───────────────────────────────────────────────────────────── // Zakładka PVP — kolumny i sortowanie z konfiguracji (jak 2.9.2) // ───────────────────────────────────────────────────────────── private void RegisterCorePvpTab() { string sortKey = SortModeToStatKey(_cfg.SortLeaderboardBy); string fameSortKey = SortModeToStatKey(_cfg.SortFameBy); var cols = new List<(string, string)>(); // Zapisujemy klucze lang (np. "ColXP") a nie gotowe tłumaczenia // Tłumaczenie odbywa się per-gracz w BuildLeaderboard → ResolveHeader() 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.0.1"; private const string PluginUpdateUrl = "http://download.koldrix.com/rust/plugins/MajestatTopCore/version.json"; // Rejestr wyników od modułów: pluginName → (latestVersion, downloadUrl) lub null jeśli aktualna private Dictionary _updateResults = new Dictionary(); // Ile modułów jeszcze oczekuje na wynik private int _updatePending = 0; private void StartUpdateCheckerCore() { _updateResults.Clear(); // Policz załadowane moduły przez liczbę zakładek (bez pvp) int moduleCount = 0; foreach (var tab in _tabs.Values) if (tab.Id != "pvp") moduleCount++; _updatePending = moduleCount; // Core sprawdza się sam osobno Puts($"[Update] Automatyczne sprawdzanie co {_cfg.UpdateCheckIntervalMinutes} min."); // Core sprawdza siebie webrequest.Enqueue(PluginUpdateUrl, null, OnCoreUpdateResponse, this); // Wyślij sygnał do wszystkich modułów żeby sprawdziły swoje aktualizacje if (moduleCount > 0) Interface.CallHook("OnMajestatUpdateCheck"); } private void OnCoreUpdateResponse(int code, string response) { if (code == 0 || code >= 400 || string.IsNullOrEmpty(response)) { Puts("[Update] Nie można sprawdzić aktualizacji — serwer update niedostępny."); 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] Dostępna nowsza wersja ({latest}) - Twoja wersja ({PluginVersion})"); _updateResults["MajestatTopCore"] = (latest, url); } else { Puts($"[Update] Wersja aktualna ({PluginVersion}) — brak aktualizacji."); } } catch { Puts("[Update] Błąd odczytu odpowiedzi Core."); } TryPrintSummary(); } // API dla modułów — raportują wynik swojego sprawdzenia [HookMethod("API_ReportUpdateResult")] public void API_ReportUpdateResult(string pluginName, string latestVersion, string downloadUrl) { if (!string.IsNullOrEmpty(latestVersion)) _updateResults[pluginName] = (latestVersion, downloadUrl ?? ""); _updatePending = System.Math.Max(0, _updatePending - 1); TryPrintSummary(); } private void TryPrintSummary() { if (_updatePending > 0) return; int outOfDate = _updateResults.Count; if (outOfDate == 0) Puts("[Update] Zakończono sprawdzanie — wszystkie pluginy są aktualne."); else { PrintWarning($"[Update] Zakończono sprawdzanie — {outOfDate} plugin{(outOfDate == 1 ? "" : "y")} ma aktualizacje:"); foreach (var kv in _updateResults) PrintWarning($"[Update] Do pobrania {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 { var l = System.Array.ConvertAll(latest.Split('.'), int.Parse); var c = System.Array.ConvertAll(current.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(); _heliTracker.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); Puts("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); Puts("SQLite: tabele gotowe."); onReady?.Invoke(); } else { Puts("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); Puts($"Migracja JSON: wczytano {legacy.Count} graczy z MajestatTop."); // Zapisz do MajestatTopCore.json (nowy format) // Stary MajestatTop.json pozostaje jako kopia bezpieczeństwa FlushSaveQueue_All(); Puts("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"); } Puts($"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(); Puts("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"); } Puts($"Migracja SQLite: wczytano {_cache.Count} graczy."); FlushSaveQueue_All(); Puts("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; if (def.ContainsKey("columns") && def["columns"] is List> cols) { foreach (var c in cols) tab.Columns.Add((c["key"], c["header"])); } _tabs[tab.Id] = tab; _lbCache.Clear(); Puts($"Zakładka zarejestrowana: {tab.Label} (id={tab.Id})"); return true; } catch (Exception ex) { PrintError($"API_RegisterTab błąd: {ex.Message}"); return false; } } [HookMethod("API_UnregisterTab")] public void API_UnregisterTab(string tabId) { _tabs.Remove(tabId); _lbCache.Clear(); } /// Moduł rejestruje źródło XP i opcjonalnie zapisuje je do cfg. /// Wywoływane raz przy rejestracji zakładki. /// Jeśli klucz już istnieje w cfg — nie nadpisuje (admin mógł zmienić wartość). [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(); Puts($"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; } /// Zwraca listę zarejestrowanych zakładek (id, label, order) [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); 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); Puts($"HELI: {kp?.displayName ?? kid} zestrzelił helikopter (+{_cfg.PointsPerHeliKill} XP)"); } else Puts("[WARN] HELI: nie można ustalić 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); Puts($"BRADLEY: {bp2.displayName} zniszczył Bradleya (+{_cfg.PointsPerBradleyKill} XP)"); } return; } var player = info?.InitiatorPlayer; if (player == null || player.IsNpc) return; // PVP if (entity is BasePlayer && !entity.IsNpc) { var victim = entity.ToPlayer(); if (player != victim) { API_AddStat(player.UserIDString, "PvP.Kills", 1, true, false); AddXpDirect(player.UserIDString, _cfg.PointsPerPvpKill); API_AddStat(victim.UserIDString, "PvP.Deaths", 1, true, false); Puts($"PVP: {player.displayName} zabił {victim.displayName} (+{_cfg.PointsPerPvpKill} XP)"); } else { API_AddStat(victim.UserIDString, "PvE.Deaths", 1, true, false); Puts($"PVE ŚMIERĆ: {victim.displayName} zginął od środowiska"); } return; } // Zwierzęta if (entity is BaseAnimalNPC || pfx.Contains("wolf") || pfx.Contains("bear") || pfx.Contains("boar") || pfx.Contains("stag") || pfx.Contains("deer") || pfx.Contains("chicken") || pfx.Contains("horse") || pfx.Contains("snake") || pfx.Contains("shark") || pfx.Contains("polarbear") || pfx.Contains("cat") || pfx.Contains("panther") || pfx.Contains("lion") || pfx.Contains("tiger") || pfx.Contains("crow") || pfx.Contains("rabbit") || pfx.Contains("fox")) { API_AddStat(player.UserIDString, "Kill.Animal", 1, true, false); AddXpDirect(player.UserIDString, _cfg.PointsPerAnimalKill); Puts($"ZWIERZĘ: {player.displayName} zabił {pfx} (+{_cfg.PointsPerAnimalKill} XP)"); return; } // Naukowcy / Zombie / Tunelowcy i inne NPC if (entity.IsNpc || pfx.Contains("scientist") || pfx.Contains("scarecrow") || pfx.Contains("zombie") || pfx.Contains("mummy") || pfx.Contains("heavy") || pfx.Contains("tunneldweller") || pfx.Contains("underwaterdweller")) { // Naukowcy — scientist, heavy scientist, tunneldweller, underwaterdweller // (tunelowcy to w Rust warianty naukowców — prefab "tunneldweller") if (pfx.Contains("scientist") || pfx.Contains("heavy") || pfx.Contains("tunneldweller") || pfx.Contains("underwaterdweller")) { API_AddStat(player.UserIDString, "Kill.Scientist", 1, true, false); AddXpDirect(player.UserIDString, _cfg.PointsPerScientistKill); Puts($"NAUKOWIEC: {player.displayName} zabił {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); Puts($"ZOMBIE: {player.displayName} zabił {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); Puts($"[INFO] NPC bez kategorii: {pfx} — policzony jako Animal (+{_cfg.PointsPerAnimalKill} XP). Zmień 'Unknown NPC fallback category' w cfg."); break; case "scientist": API_AddStat(player.UserIDString, "Kill.Scientist", 1, true, false); AddXpDirect(player.UserIDString, _cfg.PointsPerScientistKill); Puts($"[INFO] NPC bez kategorii: {pfx} — policzony jako Scientist (+{_cfg.PointsPerScientistKill} XP). Zmień 'Unknown NPC fallback category' w cfg."); break; case "zombie": API_AddStat(player.UserIDString, "Kill.Zombie", 1, true, false); AddXpDirect(player.UserIDString, _cfg.PointsPerZombieKill); Puts($"[INFO] NPC bez kategorii: {pfx} — policzony jako Zombie (+{_cfg.PointsPerZombieKill} XP). Zmień 'Unknown NPC fallback category' w cfg."); break; default: // "none" Puts($"[INFO] NPC bez kategorii: {pfx} (entity.IsNpc={entity.IsNpc}) — nie przyznano punktów. Zgłoś prefab autorowi."); 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 — jak Fame[0] ["gather"] = "0.2 0.75 0.35", // zielony — jak Fame[1] ["loot"] = "0.75 0.2 0.75", // fioletowy — jak Fame[2] ["build"] = "0.1 0.55 0.55", // turkusowy — nie gryzie się z XP/zamknij }; // 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(); // 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; // ── Główny panel — identyczne proporcje jak w 2.9.2 ───── // ── Panel tła (trwały, CursorEnabled) ─────────────────── // Tworzony tylko raz — nie niszczony przy przełączaniu zakładek // Dzięki temu mysz nie skacze na środek ekranu if (!_activePanels.Contains(player.userID)) { var bg = new CuiElementContainer(); bg.Add(new CuiPanel { Image = { Color = "0 0 0 0" }, RectTransform = { AnchorMin = "0 0", AnchorMax = "1 1" }, CursorEnabled = true }, "Overlay", PANEL_BG); CuiHelper.DestroyUi(player, PANEL_BG); CuiHelper.AddUi(player, 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ł (lewy górny) ─────────────────────────────────── 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"; // Złoto-brązowy gdy XP Info, ciemnoniebieski gdy Back 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.43 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); 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); 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)> 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 (jak zawsze) // *.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) { if (string.IsNullOrEmpty(header)) return ""; if (_langColKeys.Contains(header)) return L(header, uid); 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()); } // ── Komendy konsolowe ──────────────────────────────────────── [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); } } }