using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Text; using Oxide.Core; using Oxide.Core.Libraries; using Oxide.Core.Plugins; using Oxide.Game.Rust.Cui; using UnityEngine; using Newtonsoft.Json; namespace Oxide.Plugins { [Info("MajestatTopVote", "Wo0t", "1.0.0")] [Description("Głosowanie na mapę przed wipe - propozycje z RustMaps, integracja z Core/Web.")] class MajestatTopVote : RustPlugin { // Powiązania z resztą serii [PluginReference] Plugin MajestatTopCore; [PluginReference] Plugin MajestatTopWeb; [PluginReference] Plugin ImageLibrary; private const string PermAdmin = "majestattopvote.admin"; private const string PluginVersion = "1.0.0"; // UI private const string PANEL = "MajestatTopVoteUI"; private const string PANEL_BG = "MajestatTopVoteBG"; private readonly HashSet _activePanels = new HashSet(); private readonly HashSet _connectPrompt = new HashSet(); // Stylistyka (zgodna z Core) private const string COL_BG = "0.05 0.05 0.05 0.99"; private const string COL_CARD = "0.10 0.10 0.10 0.95"; private const string COL_CARD_WIN = "0.10 0.20 0.10 0.97"; private const string COL_ACCENT = "0.3 0.8 1 1"; private const string COL_ACCENT_D = "0.3 0.8 1 0.15"; private const string COL_GREEN = "0.4 0.9 0.4 1"; private const string COL_ORANGE = "1 0.45 0.1 1"; private const string COL_TEXT = "0.85 0.85 0.85 1"; private const string COL_TEXT_DIM = "0.7 0.7 0.7 1"; private class ConfigData { [JsonProperty("RustMaps API Key")] public string RustMapsApiKey = ""; [JsonProperty("Map Size Min")] public int SizeMin = 3000; [JsonProperty("Map Size Max")] public int SizeMax = 3750; [JsonProperty("Map Size Step")] public int SizeStep = 250; [JsonProperty("Map Options")] public int Options = 3; [JsonProperty("Use Staging Maps")] public bool Staging = false; [JsonProperty("Wipe Schedule")] public string WipeScheduleOverride = ""; [JsonProperty("Read Schedule From Tags")] public bool ReadScheduleFromTags = true; [JsonProperty("Wipe Weekday")] public int WipeWeekday = 4; [JsonProperty("Wipe Hour")] public int WipeHour = 17; [JsonProperty("Days Before Wipe")] public Dictionary DaysBefore = new Dictionary { ["monthly"] = 7, ["biweekly"] = 5, ["weekly"] = 3, }; [JsonProperty("Auto Close At Wipe")] public bool AutoClose = true; [JsonProperty("Auto Open Window")] public bool AutoOpen = true; [JsonProperty("Allow Vote Change")] public bool AllowChangeVote = true; [JsonProperty("Show Panel On Connect")] public bool ShowOnConnect = true; [JsonProperty("Show On Connect Delay (sec)")] public float ShowOnConnectDelay = 10f; [JsonProperty("Yield To Info Panel")] public bool YieldToInfo = true; [JsonProperty("Announce Winner")] public bool AnnounceWinner = true; [JsonProperty("Write Winner File")] public bool WriteWinnerFile = true; [JsonProperty("Winner File")] public string WinnerFile = "MajestatTopVote_nextmap.json"; [JsonProperty("Set Convars On Winner")]public bool SetConvarsOnWinner = false; [JsonProperty("Winner Command")] public string WinnerCommand = ""; [JsonProperty("Chat Commands")] public List ChatCommands = new List { "vote", "glosuj" }; [JsonProperty("Chat Icon SteamID")] public ulong ChatIconSteamID = 0; [JsonProperty("Broadcast Start")] public bool BroadcastStart = true; [JsonProperty("Broadcast Interval (min)")] public int BroadcastIntervalMinutes = 60; [JsonProperty("Broadcast Before Open")] public bool BroadcastBeforeOpen = true; [JsonProperty("Broadcast Before Open (days)")] public int BroadcastBeforeOpenDays = 2; [JsonProperty("Msg Start")] public string MsgStart = "[Vote] Ruszyło głosowanie na mapę! Wpisz /vote. Map do wyboru: {maps}"; [JsonProperty("Msg Time Left")] public string MsgTimeLeft = "[Vote] Do końca głosowania zostało {time}. Wpisz /vote"; [JsonProperty("Msg Starts In")] public string MsgStartsIn = "[Vote] Głosowanie na mapę rozpocznie się za {time}."; [JsonProperty("Time Show Days")] public bool TimeShowDays = true; [JsonProperty("Time Show Hours")] public bool TimeShowHours = true; [JsonProperty("Time Show Minutes")] public bool TimeShowMinutes = true; [JsonProperty("Language")] public string Lang = "PL"; [JsonProperty("Log Level")] public string LogLevel = "system"; } private ConfigData _cfg; protected override void LoadDefaultConfig() => _cfg = new ConfigData(); private bool _configBroken = false; protected override void LoadConfig() { base.LoadConfig(); _configBroken = false; try { _cfg = Config.ReadObject(); if (_cfg == null) throw new Exception("config deserialized to null"); } catch (Exception ex) { _configBroken = true; _cfg = new ConfigData(); string path = Config.Filename; string backup = ""; try { if (!string.IsNullOrEmpty(path) && File.Exists(path)) { backup = path + ".invalid-" + DateTime.Now.ToString("yyyyMMdd-HHmmss"); File.Copy(path, backup, true); } } catch { /* backup best-effort */ } PrintError("=================================================================="); PrintError("[Vote] CONFIG FILE IS INVALID - it could not be parsed."); PrintError($"[Vote] Reason: {ex.Message}"); PrintError($"[Vote] File: {path}"); if (!string.IsNullOrEmpty(backup)) PrintError($"[Vote] A backup of the broken file was saved as: {backup}"); PrintError("[Vote] Default settings are used until you fix the file (your API key / settings are NOT applied)."); PrintError("[Vote] Fix the JSON, then run: oxide.reload MajestatTopVote"); PrintError("=================================================================="); } if (_cfg == null) _cfg = new ConfigData(); if (_cfg.DaysBefore == null || _cfg.DaysBefore.Count == 0) _cfg.DaysBefore = new Dictionary { ["monthly"] = 7, ["biweekly"] = 5, ["weekly"] = 3 }; _cfg.Options = Mathf.Clamp(_cfg.Options, 2, 6); if (_cfg.SizeMax < _cfg.SizeMin) _cfg.SizeMax = _cfg.SizeMin; if (_cfg.SizeStep < 1) _cfg.SizeStep = 1; if (_cfg.ChatCommands == null) _cfg.ChatCommands = new List { "vote", "glosuj" }; _cfg.ChatCommands = _cfg.ChatCommands .Where(c => !string.IsNullOrWhiteSpace(c)) .Select(c => c.Trim().ToLowerInvariant()) .Distinct() .ToList(); if (_cfg.ChatCommands.Count == 0) _cfg.ChatCommands.Add("vote"); if (!_configBroken) SaveConfig(); } protected override void SaveConfig() => Config.WriteObject(_cfg, true); private class MapOption { public int Index; public long Seed; public int Size; public string MapId = ""; public string Url = ""; // strona rustmaps.com public string ImageUrl = ""; // obraz do CUI (PNG - musi renderować się w grze) public string IconUrl = ""; // obraz z ikonami monumentów (do Web; może być webp) public bool Ready = false; public bool Pending = false; } private class VoteSession { public string SessionId = ""; public bool Open = false; public bool Finalized = false; public long OpenedAt = 0; public long ClosesAt = 0; public string Schedule = "weekly"; public List Maps = new List(); public Dictionary Votes = new Dictionary(); public int WinnerIndex = -1; public bool StartAnnounced = false; } private VoteSession _session; private const string DataFile = "MajestatTopVote_session"; private void LoadData() { try { _session = Interface.Oxide.DataFileSystem.ReadObject(DataFile); } catch { } if (_session == null) _session = new VoteSession(); } private void SaveData() { try { Interface.Oxide.DataFileSystem.WriteObject(DataFile, _session); } catch { } } private Timer _scheduler; private void Init() { permission.RegisterPermission(PermAdmin, this); LoadData(); } private void OnServerInitialized() { foreach (var ch in _cfg.ChatCommands.Where(c => !string.IsNullOrEmpty(c)).Distinct()) { try { cmd.AddChatCommand(ch, this, nameof(HandleVoteChat)); } catch { /* już zarejestrowana */ } } RegisterInCore(); if (MajestatTopWeb != null) API_RegisterInWeb(); _scheduler = timer.Every(30f, CheckSchedule); CheckSchedule(); if (_session != null && _session.Maps != null && _session.Maps.Count > 0) { foreach (var m in _session.Maps.Where(x => x.Ready)) PreloadImage(m); if (_session.Open && _session.Maps.Any(x => !x.Ready)) StartPoll(); } Log(1, $"MajestatTopVote v{PluginVersion} started (schedule={ResolveSchedule()}, options={_cfg.Options})."); } private void Unload() { _scheduler?.Destroy(); StopPoll(); foreach (var p in BasePlayer.activePlayerList) CloseUI(p); MajestatTopCore?.Call("API_UnregisterModule", Name); MajestatTopWeb?.Call("API_UnregisterVoteModule"); SaveData(); } private void OnPluginLoaded(Plugin p) { if (p?.Name == "MajestatTopCore") RegisterInCore(); if (p?.Name == "MajestatTopWeb") API_RegisterInWeb(); if (p?.Name == "ImageLibrary" && _session?.Maps != null) { foreach (var m in _session.Maps.Where(x => x.Ready)) PreloadImage(m); timer.Once(5f, RefreshViewers); } } private void RegisterInCore() { if (MajestatTopCore == null) return; MajestatTopCore.Call("API_RegisterModule", new Dictionary { ["name"] = Name, ["version"] = PluginVersion, ["author"] = "Wo0t", ["description"] = "Głosowanie na mapę przed wipe", }); } void OnPlayerConnected(BasePlayer player) { if (player == null || !_cfg.ShowOnConnect) return; if (!API_IsVoteActive()) return; if (_session.Votes.ContainsKey(player.UserIDString)) return; _connectPrompt.Add(player.userID); float delay = Mathf.Max(2f, _cfg.ShowOnConnectDelay); timer.Once(delay, () => TryConnectPrompt(player, 0)); } void OnPlayerDisconnected(BasePlayer player, string reason) { if (player != null) _connectPrompt.Remove(player.userID); } void OnMajestatInfoClosed(BasePlayer player) { if (player == null || !_connectPrompt.Contains(player.userID)) return; timer.Once(0.5f, () => ShowConnectPrompt(player)); // let Info's UI tear down first } private void TryConnectPrompt(BasePlayer player, int attempt) { if (player == null || !player.IsConnected) { if (player != null) _connectPrompt.Remove(player.userID); return; } if (!_connectPrompt.Contains(player.userID)) return; if (_cfg.YieldToInfo && (MajestatTopCore?.Call("API_IsInfoOpen", player) ?? false)) { if (attempt < 24) timer.Once(5f, () => TryConnectPrompt(player, attempt + 1)); else _connectPrompt.Remove(player.userID); return; } ShowConnectPrompt(player); } private void ShowConnectPrompt(BasePlayer player) { if (player == null) return; if (!_connectPrompt.Remove(player.userID)) return; if (!player.IsConnected || !API_IsVoteActive()) return; if (_session.Votes.ContainsKey(player.UserIDString)) return; OpenUI(player); } private static long Now() => DateTimeOffset.UtcNow.ToUnixTimeSeconds(); private string ResolveSchedule() { string s = (_cfg.WipeScheduleOverride ?? "").Trim().ToLowerInvariant(); if (s == "weekly" || s == "biweekly" || s == "monthly") return s; if (_cfg.ReadScheduleFromTags) { string tags = (ConVar.Server.tags ?? "").ToLowerInvariant(); if (tags.Contains("biweekly")) return "biweekly"; if (tags.Contains("monthly")) return "monthly"; if (tags.Contains("weekly")) return "weekly"; } return "weekly"; } private int DaysBeforeFor(string schedule) { if (_cfg.DaysBefore != null && _cfg.DaysBefore.TryGetValue(schedule, out int d)) return d; switch (schedule) { case "monthly": return 7; case "biweekly": return 5; default: return 3; } } private DayOfWeek TargetDow() => (DayOfWeek)(Mathf.Clamp(_cfg.WipeWeekday, 1, 7) % 7); private DateTime FirstWeekdayOfMonth(int year, int month, DayOfWeek dow, int hour) { var d = new DateTime(year, month, 1, Mathf.Clamp(hour, 0, 23), 0, 0, DateTimeKind.Local); while (d.DayOfWeek != dow) d = d.AddDays(1); return d; } private DateTime NextWeekdayOnOrAfter(DateTime from, DayOfWeek dow, int hour) { var d = new DateTime(from.Year, from.Month, from.Day, Mathf.Clamp(hour, 0, 23), 0, 0, DateTimeKind.Local); int guard = 0; while ((d.DayOfWeek != dow || d <= from) && guard++ < 14) d = d.AddDays(1); return d; } private DateTime NextWipeLocal() { var now = DateTime.Now; var dow = TargetDow(); string sch = ResolveSchedule(); if (sch == "weekly") return NextWeekdayOnOrAfter(now, dow, _cfg.WipeHour); if (sch == "monthly") { var fw = FirstWeekdayOfMonth(now.Year, now.Month, dow, _cfg.WipeHour); if (fw > now) return fw; var nm = now.AddMonths(1); return FirstWeekdayOfMonth(nm.Year, nm.Month, dow, _cfg.WipeHour); } for (int off = 0; off <= 2; off++) { var m = now.AddMonths(off); var a1 = FirstWeekdayOfMonth(m.Year, m.Month, dow, _cfg.WipeHour); var a2 = a1.AddDays(14); if (a1 > now) return a1; if (a2 > now) return a2; } return NextWeekdayOnOrAfter(now, dow, _cfg.WipeHour); } private void CheckSchedule() { string sch = ResolveSchedule(); DateTime wipe = NextWipeLocal(); DateTime open = wipe.AddDays(-DaysBeforeFor(sch)); DateTime now = DateTime.Now; long wipeUnix = ((DateTimeOffset)wipe.ToUniversalTime()).ToUnixTimeSeconds(); if (_cfg.AutoOpen && now >= open && now < wipe) { bool needNew = _session == null || !_session.Open || _session.ClosesAt != wipeUnix; if (needNew && (_session == null || !_session.Open)) OpenSession(sch, wipeUnix); } if (_cfg.AutoClose && _session != null && _session.Open && now >= wipe) FinalizeSession(); HandleBroadcasts(open, wipe, now); } private long _lastBroadcast = 0; private void HandleBroadcasts(DateTime open, DateTime wipe, DateTime now) { int interval = _cfg.BroadcastIntervalMinutes; if (interval <= 0) return; if (Now() - _lastBroadcast < interval * 60L) return; if (_session != null && _session.Open && _session.StartAnnounced) { long left = _session.ClosesAt - Now(); if (left > 0) { _lastBroadcast = Now(); BroadcastChat(_cfg.MsgTimeLeft.Replace("{time}", FormatDHM(left))); } return; } if (_cfg.BroadcastBeforeOpen && (_session == null || !_session.Open) && now < open) { double daysToOpen = (open - now).TotalDays; if (daysToOpen <= _cfg.BroadcastBeforeOpenDays) { long until = (long)(open - now).TotalSeconds; _lastBroadcast = Now(); BroadcastChat(_cfg.MsgStartsIn.Replace("{time}", FormatDHM(until))); } } } private void OpenSession(string schedule, long closesAt) { _session = new VoteSession { SessionId = Guid.NewGuid().ToString("N").Substring(0, 8), Open = true, Finalized = false, OpenedAt = Now(), ClosesAt = closesAt, Schedule = schedule, Maps = new List(), Votes = new Dictionary(), }; SaveData(); Log(1, $"[Vote] Voting opened (session {_session.SessionId}), fetching {_cfg.Options} maps from RustMaps..."); FetchMaps(_cfg.Options); StartPoll(); } private void FinalizeSession() { if (_session == null || !_session.Open) return; _session.Open = false; _session.Finalized = true; StopPoll(); int winner = -1, best = -1; for (int i = 0; i < _session.Maps.Count; i++) { int v = _session.Votes.Count(kv => kv.Value == i); if (v > best) { best = v; winner = i; } } _session.WinnerIndex = winner; SaveData(); if (winner >= 0 && winner < _session.Maps.Count) { var m = _session.Maps[winner]; Log(1, $"[Vote] Winner: size={m.Size} seed={m.Seed} ({best} votes). {m.Url}"); if (_cfg.AnnounceWinner) BroadcastChat(string.Format(T("winner"), m.Size, m.Seed, best)); if (_cfg.WriteWinnerFile) Interface.Oxide.DataFileSystem.WriteObject( _cfg.WinnerFile.Replace(".json", ""), new Dictionary { ["seed"] = m.Seed, ["size"] = m.Size, ["url"] = m.Url, ["mapId"] = m.MapId, ["votes"] = best, ["decidedAt"] = Now(), }); if (_cfg.SetConvarsOnWinner) { try { ConsoleSystem.Run(ConsoleSystem.Option.Server, "server.worldsize", m.Size); ConsoleSystem.Run(ConsoleSystem.Option.Server, "server.seed", m.Seed); Log(1, "[Vote] Set convars server.worldsize / server.seed (applied after restart+wipe)."); } catch (Exception ex) { PrintWarning($"[Vote] Failed to set convars: {ex.Message}"); } } if (!string.IsNullOrEmpty(_cfg.WinnerCommand)) { string command = _cfg.WinnerCommand .Replace("{seed}", m.Seed.ToString()) .Replace("{size}", m.Size.ToString()); try { ConsoleSystem.Run(ConsoleSystem.Option.Server, command); } catch (Exception ex) { PrintWarning($"[Vote] Winner command error: {ex.Message}"); } } } foreach (var p in _activePanels.ToList()) { var bp = BasePlayer.FindByID(p); if (bp != null) OpenUI(bp); } } private readonly System.Random _rng = new System.Random(); private int SnapSize(double raw) { int step = Math.Max(1, _cfg.SizeStep); int s = (int)(Math.Round(raw / step, MidpointRounding.AwayFromZero) * step); return Mathf.Clamp(s, _cfg.SizeMin, _cfg.SizeMax); } private List DistributeSizes(int n) { var list = new List(); if (n <= 1) { list.Add(SnapSize((_cfg.SizeMin + _cfg.SizeMax) / 2.0)); return list; } if (_cfg.SizeMax <= _cfg.SizeMin) { for (int i = 0; i < n; i++) list.Add(_cfg.SizeMin); return list; } for (int i = 0; i < n; i++) { double raw = _cfg.SizeMin + (double)i * (_cfg.SizeMax - _cfg.SizeMin) / (n - 1); list.Add(SnapSize(raw)); } return list; } private long RandomSeed() => _rng.Next(1, int.MaxValue); private void FetchMaps(int count) { if (string.IsNullOrEmpty(_cfg.RustMapsApiKey)) { PrintWarning("[Vote] RustMaps API key is not set in config - cannot fetch maps."); return; } var sizes = DistributeSizes(count); for (int i = _session.Maps.Count; i < count; i++) { int size = sizes[Mathf.Clamp(i, 0, sizes.Count - 1)]; TryFetchOne(size); } } private void TryFetchOne(int size, int attempt = 0) { if (_session == null) return; if (_session.Maps.Count(m => m.Ready || m.Pending) >= _cfg.Options) return; if (attempt > 6) { PrintWarning("[Vote] Too many fetch attempts - aborting this slot."); return; } long seed = RandomSeed(); string stagingQ = _cfg.Staging ? "true" : "false"; string url = $"https://api.rustmaps.com/v4/maps/{size}/{seed}?staging={stagingQ}"; var headers = new Dictionary { ["X-API-Key"] = _cfg.RustMapsApiKey, }; var opt = new MapOption { Index = _session.Maps.Count, Seed = seed, Size = size, Pending = true }; _session.Maps.Add(opt); SaveData(); webrequest.Enqueue(url, null, (code, response) => { if (code == 200) { if (ApplyMapResponse(opt, response)) { MarkReady(opt); Log(0, $"[Vote] Map ready: size={size} seed={seed}"); } else { ReplaceFailed(opt, attempt); } } else if (code == 404 || code == 409 || code == 202) { RequestGeneration(opt, attempt); } else { PrintWarning($"[Vote] RustMaps GET {code}: {Trim(response)}"); ReplaceFailed(opt, attempt); } }, this, RequestMethod.GET, headers, 15f); } private void RequestGeneration(MapOption opt, int attempt) { string body = JsonConvert.SerializeObject(new Dictionary { ["size"] = opt.Size, ["seed"] = opt.Seed, ["staging"] = _cfg.Staging, }); var headers = new Dictionary { ["X-API-Key"] = _cfg.RustMapsApiKey, ["Content-Type"]= "application/json", }; webrequest.Enqueue("https://api.rustmaps.com/v4/maps", body, (code, response) => { if (code == 200 || code == 201 || code == 409) { Log(0, $"[Vote] Generation queued: size={opt.Size} seed={opt.Seed}"); StartPoll(); } else { PrintWarning($"[Vote] RustMaps POST {code}: {Trim(response)}"); ReplaceFailed(opt, attempt); } }, this, RequestMethod.POST, headers, 15f); } private Timer _pollTimer; private void StartPoll() { if (_pollTimer != null) return; _pollTimer = timer.Every(30f, PollPending); } private void StopPoll() { _pollTimer?.Destroy(); _pollTimer = null; } private void PollPending() { if (_session == null || !_session.Open) { StopPoll(); return; } var pend = _session.Maps.Where(m => !m.Ready).ToList(); if (pend.Count == 0) { StopPoll(); return; } foreach (var opt in pend) PollOne(opt); } private void PollOne(MapOption opt) { string stagingQ = _cfg.Staging ? "true" : "false"; string url = $"https://api.rustmaps.com/v4/maps/{opt.Size}/{opt.Seed}?staging={stagingQ}"; var headers = new Dictionary { ["X-API-Key"] = _cfg.RustMapsApiKey }; webrequest.Enqueue(url, null, (code, response) => { if (code == 200 && ApplyMapResponse(opt, response)) { MarkReady(opt); Log(0, $"[Vote] Map ready (poll): size={opt.Size} seed={opt.Seed}"); } }, this, RequestMethod.GET, headers, 15f); } private bool ApplyMapResponse(MapOption opt, string response) { try { var root = JsonConvert.DeserializeObject>(response); if (root == null) return false; Dictionary data = root; if (root.ContainsKey("data") && root["data"] is Newtonsoft.Json.Linq.JObject jo) data = jo.ToObject>(); string id = Str(data, "id", "mapId"); string u = Str(data, "url"); string img = Str(data, "imageIconUrl", "imageUrl", "thumbnailUrl", "rawImageUrl"); if (string.IsNullOrEmpty(u) && !string.IsNullOrEmpty(id)) u = "https://rustmaps.com/map/" + id; if (string.IsNullOrEmpty(u)) u = $"https://rustmaps.com/map/{opt.Size}_{opt.Seed}"; opt.MapId = id ?? ""; opt.Url = u; opt.ImageUrl = img ?? ""; opt.IconUrl = img ?? ""; return true; } catch (Exception ex) { PrintWarning($"[Vote] Parse map error: {ex.Message}"); return false; } } private void ReplaceFailed(MapOption opt, int attempt) { if (_session == null) return; int size = opt.Size; _session.Maps.Remove(opt); SaveData(); timer.Once(1f, () => TryFetchOne(size, attempt + 1)); } private void RefreshViewers() { foreach (var id in _activePanels.ToList()) { var bp = BasePlayer.FindByID(id); if (bp != null) OpenUI(bp); } } private void MarkReady(MapOption opt) { opt.Pending = false; opt.Ready = true; SaveData(); PreloadImage(opt); RefreshViewers(); timer.Once(4f, RefreshViewers); MaybeAnnounceStart(); } private string ImgKey(MapOption m) => $"mtv_{m.Size}_{m.Seed}"; private void PreloadImage(MapOption m) { if (ImageLibrary == null || string.IsNullOrEmpty(m.ImageUrl)) return; try { bool has = false; try { has = (bool)(ImageLibrary.Call("HasImage", ImgKey(m), 0UL) ?? false); } catch { } if (!has) ImageLibrary.Call("AddImage", m.ImageUrl, ImgKey(m), 0UL); } catch (Exception ex) { Log(0, $"[Vote] ImageLibrary preload error: {ex.Message}"); } } private string GetCachedPng(MapOption m) { if (ImageLibrary == null) return ""; try { bool has = (bool)(ImageLibrary.Call("HasImage", ImgKey(m), 0UL) ?? false); if (!has) return ""; return ImageLibrary.Call("GetImage", ImgKey(m), 0UL) as string ?? ""; } catch { return ""; } } private void MaybeAnnounceStart() { if (_session == null || !_session.Open || _session.StartAnnounced) return; if (!_cfg.BroadcastStart) { _session.StartAnnounced = true; return; } int ready = _session.Maps.Count(m => m.Ready); if (ready < _cfg.Options) return; _session.StartAnnounced = true; _lastBroadcast = Now(); SaveData(); BroadcastChat(_cfg.MsgStart.Replace("{maps}", _cfg.Options.ToString())); } private string FormatDHM(long sec) { if (sec < 0) sec = 0; var ts = TimeSpan.FromSeconds(sec); int d = (int)ts.TotalDays, h = ts.Hours, m = ts.Minutes; var parts = new List(); if (_cfg.TimeShowDays && d > 0) parts.Add($"{d}d"); if (_cfg.TimeShowHours && (h > 0 || (d > 0 && _cfg.TimeShowMinutes))) parts.Add($"{h}h"); if (_cfg.TimeShowMinutes) parts.Add($"{m}m"); if (parts.Count == 0) parts.Add($"{m}m"); return string.Join(" ", parts); } private static string Str(Dictionary d, params string[] keys) { foreach (var k in keys) if (d != null && d.ContainsKey(k) && d[k] != null) { string s = d[k].ToString(); if (!string.IsNullOrEmpty(s)) return s; } return ""; } private static string Trim(string s) => string.IsNullOrEmpty(s) ? "" : (s.Length > 160 ? s.Substring(0, 160) : s); private void HandleVoteChat(BasePlayer player, string command, string[] args) => OpenUI(player); [ChatCommand("voteadmin")] private void CmdVoteAdmin(BasePlayer player, string command, string[] args) { if (!permission.UserHasPermission(player.UserIDString, PermAdmin) && !player.IsAdmin) { player.ChatMessage(T("no_perm")); return; } string sub = args.Length > 0 ? args[0].ToLower() : ""; switch (sub) { case "open": OpenSession(ResolveSchedule(), ((DateTimeOffset)NextWipeLocal().ToUniversalTime()).ToUnixTimeSeconds()); player.ChatMessage("[Vote] Voting opened manually."); break; case "close": FinalizeSession(); player.ChatMessage("[Vote] Voting closed."); break; case "reroll": if (_session != null && _session.Open) { _session.Maps.Clear(); _session.Votes.Clear(); SaveData(); FetchMaps(_cfg.Options); StartPoll(); player.ChatMessage("[Vote] New maps rolled."); } else player.ChatMessage("[Vote] No active voting."); break; default: player.ChatMessage("[Vote] /voteadmin open | close | reroll"); break; } } [ConsoleCommand("majestattopvote.open")] private void CcOpen(ConsoleSystem.Arg arg) { var p = arg.Player(); if (p == null) return; OpenUI(p); } [ConsoleCommand("majestattopvote.vote")] private void CcVote(ConsoleSystem.Arg arg) { var p = arg.Player(); if (p == null) return; int idx = arg.GetInt(0, -1); CastVote(p, idx); OpenUI(p); } [ConsoleCommand("majestattopvote.close")] private void CcClose(ConsoleSystem.Arg arg) { var p = arg.Player(); if (p == null) return; CloseUI(p); } [ConsoleCommand("majestattopvote.backtotop")] private void CcBackToTop(ConsoleSystem.Arg arg) { var p = arg.Player(); if (p == null) return; CloseUI(p); if (MajestatTopCore != null) p.SendConsoleCommand("majestattopcore.ui pvp"); } private void CastVote(BasePlayer player, int idx) { if (_session == null || !_session.Open) { SendMsg(player, T("no_active")); return; } if (idx < 0 || idx >= _session.Maps.Count) return; if (!_session.Maps[idx].Ready) { SendMsg(player, T("map_not_ready")); return; } string sid = player.UserIDString; bool already = _session.Votes.ContainsKey(sid); if (already && !_cfg.AllowChangeVote) { SendMsg(player, T("already_voted")); return; } _session.Votes[sid] = idx; _connectPrompt.Remove(player.userID); SaveData(); SendMsg(player, string.Format(T("vote_cast"), _session.Maps[idx].Size, _session.Maps[idx].Seed)); RefreshViewers(); } private void OpenUI(BasePlayer player) { if (player == null) return; var c = new CuiElementContainer(); ulong id = player.userID; bool firstOpen = !_activePanels.Contains(id); if (firstOpen) { c.Add(new CuiPanel { Image = { Color = "0 0 0 0" }, RectTransform = { AnchorMin = "0 0", AnchorMax = "1 1" }, CursorEnabled = true }, "Overlay", PANEL_BG); _activePanels.Add(id); } c.Add(new CuiPanel { Image = { Color = COL_BG }, RectTransform = { AnchorMin = "0.03 0.1", AnchorMax = "0.97 0.9" }, CursorEnabled = false }, PANEL_BG, PANEL); c.Add(new CuiLabel { Text = { Text = T("title"), FontSize = 28, Align = TextAnchor.MiddleLeft, Color = COL_ACCENT }, RectTransform = { AnchorMin = "0.03 0.92", AnchorMax = "0.6 0.98" } }, PANEL); string status = BuildStatusLine(); c.Add(new CuiLabel { Text = { Text = status, FontSize = 12, Align = TextAnchor.MiddleRight, Color = COL_TEXT_DIM }, RectTransform = { AnchorMin = "0.45 0.92", AnchorMax = "0.73 0.98" } }, PANEL); if (MajestatTopCore != null) { c.Add(new CuiButton { Button = { Command = "majestattopvote.backtotop", Color = "0.10 0.40 0.55 0.9" }, Text = { Text = T("back_top"), FontSize = 12, Align = TextAnchor.MiddleCenter, Color = "0.7 0.95 1 1" }, RectTransform = { AnchorMin = "0.745 0.93", AnchorMax = "0.885 0.985" } }, PANEL); } c.Add(new CuiButton { Button = { Command = "majestattopvote.close", Color = "0.5 0.15 0.15 0.9" }, Text = { Text = "✕", FontSize = 16, Align = TextAnchor.MiddleCenter, Color = "1 0.8 0.8 1" }, RectTransform = { AnchorMin = "0.9 0.93", AnchorMax = "0.97 0.985" } }, PANEL); c.Add(new CuiPanel { Image = { Color = "0.3 0.8 1 0.4" }, RectTransform = { AnchorMin = "0.03 0.91", AnchorMax = "0.97 0.915" } }, PANEL); bool hasMaps = _session != null && _session.Maps != null && _session.Maps.Count > 0; if (!hasMaps) BuildEmptyContent(ref c); else BuildCards(ref c, player); if (firstOpen) CuiHelper.DestroyUi(player, PANEL_BG); else CuiHelper.DestroyUi(player, PANEL); CuiHelper.AddUi(player, c); } private void BuildEmptyContent(ref CuiElementContainer c) { bool open = _session != null && _session.Open; bool noKey = string.IsNullOrEmpty(_cfg.RustMapsApiKey); string main; if (open && noKey) main = T("no_key"); else if (open) main = T("loading_maps"); else main = T("no_active_panel"); c.Add(new CuiLabel { Text = { Text = main, FontSize = 18, Align = TextAnchor.MiddleCenter, Color = (open && noKey) ? COL_ORANGE : COL_TEXT_DIM }, RectTransform = { AnchorMin = "0.1 0.4", AnchorMax = "0.9 0.6" } }, PANEL); string sch = ResolveSchedule(); var wipe = NextWipeLocal(); var open2 = wipe.AddDays(-DaysBeforeFor(sch)); c.Add(new CuiLabel { Text = { Text = string.Format(T("next_window"), open2.ToString("dd.MM HH:mm"), wipe.ToString("dd.MM HH:mm"), sch), FontSize = 13, Align = TextAnchor.MiddleCenter, Color = COL_TEXT_DIM }, RectTransform = { AnchorMin = "0.1 0.32", AnchorMax = "0.9 0.4" } }, PANEL); } private void BuildCards(ref CuiElementContainer c, BasePlayer player) { var maps = _session.Maps; int n = maps.Count; if (n == 0) { BuildEmptyContent(ref c); return; } string sid = player.UserIDString; int myVote = _session.Votes.TryGetValue(sid, out int mv) ? mv : -1; int totalVotes = _session.Votes.Count; bool finished = _session.Finalized || !_session.Open; float left = 0.04f, right = 0.96f, top = 0.83f, bot = 0.07f; float gx = 0.018f, gy = 0.05f; int cols = n <= 3 ? n : Mathf.CeilToInt(n / 2f); int rows = Mathf.CeilToInt((float)n / cols); float cw = (right - left - gx * (cols - 1)) / cols; float rh = (top - bot - gy * (rows - 1)) / rows; for (int i = 0; i < n; i++) { int row = i / cols; int col = i % cols; int inThisRow = Math.Min(cols, n - row * cols); float rowW = cw * inThisRow + gx * (inThisRow - 1); float startX = left + ((right - left) - rowW) / 2f; float x0 = startX + col * (cw + gx); float x1 = x0 + cw; float y1 = top - row * (rh + gy); float y0 = y1 - rh; int votes = _session.Votes.Count(kv => kv.Value == i); float pct = totalVotes > 0 ? (float)votes / totalVotes : 0f; bool isWinner = finished && _session.WinnerIndex == i; DrawCard(ref c, maps[i], i, x0, x1, y0, y1, votes, pct, isWinner, finished, myVote); } } private void DrawCard(ref CuiElementContainer c, MapOption m, int i, float x0, float x1, float y0, float y1, int votes, float pct, bool isWinner, bool finished, int myVote) { string card = PANEL + "_card" + i; c.Add(new CuiPanel { Image = { Color = isWinner ? COL_CARD_WIN : COL_CARD }, RectTransform = { AnchorMin = $"{x0:F3} {y0:F3}", AnchorMax = $"{x1:F3} {y1:F3}" } }, PANEL, card); string png = m.Ready ? GetCachedPng(m) : ""; if (m.Ready && !string.IsNullOrEmpty(png)) { c.Add(new CuiElement { Parent = card, Components = { new CuiRawImageComponent { Png = png }, new CuiRectTransformComponent { AnchorMin = "0.08 0.44", AnchorMax = "0.92 0.95" } } }); } else if (m.Ready && !string.IsNullOrEmpty(m.ImageUrl)) { c.Add(new CuiElement { Parent = card, Components = { new CuiRawImageComponent { Url = m.ImageUrl }, new CuiRectTransformComponent { AnchorMin = "0.08 0.44", AnchorMax = "0.92 0.95" } } }); } else { c.Add(new CuiPanel { Image = { Color = "1 1 1 0.04" }, RectTransform = { AnchorMin = "0.08 0.44", AnchorMax = "0.92 0.95" } }, card); c.Add(new CuiLabel { Text = { Text = m.Ready ? T("loading") : T("loading"), FontSize = 12, Align = TextAnchor.MiddleCenter, Color = COL_TEXT_DIM }, RectTransform = { AnchorMin = "0.08 0.44", AnchorMax = "0.92 0.95" } }, card); } c.Add(new CuiLabel { Text = { Text = $"Size {m.Size} • Seed {m.Seed}", FontSize = 11, Align = TextAnchor.MiddleCenter, Color = COL_TEXT }, RectTransform = { AnchorMin = "0.02 0.36", AnchorMax = "0.98 0.43" } }, card); c.Add(new CuiPanel { Image = { Color = "1 1 1 0.08" }, RectTransform = { AnchorMin = "0.08 0.27", AnchorMax = "0.92 0.33" } }, card); float barMax = 0.08f + (0.92f - 0.08f) * Mathf.Clamp01(pct); c.Add(new CuiPanel { Image = { Color = isWinner ? "0.4 0.9 0.4 0.85" : "0.3 0.8 1 0.7" }, RectTransform = { AnchorMin = "0.08 0.27", AnchorMax = $"{barMax:F3} 0.33" } }, card); c.Add(new CuiLabel { Text = { Text = $"{votes} głosów • {pct * 100f:F0}%", FontSize = 11, Align = TextAnchor.MiddleCenter, Color = isWinner ? COL_GREEN : COL_ACCENT }, RectTransform = { AnchorMin = "0.06 0.18", AnchorMax = "0.94 0.26" } }, card); if (!finished) { bool mine = myVote == i; bool canVote = m.Ready && (myVote < 0 || (_cfg.AllowChangeVote && !mine)); string label = mine ? T("your_vote") : (canVote ? T("vote_btn") : (m.Ready ? T("voted_other") : "...")); string col = mine ? "0.2 0.5 0.2 0.95" : (canVote ? "0.1 0.4 0.6 0.95" : "0.2 0.2 0.2 0.8"); string tcol = mine ? COL_GREEN : COL_TEXT; c.Add(new CuiButton { Button = { Command = canVote ? $"majestattopvote.vote {i}" : "", Color = col }, Text = { Text = label, FontSize = 12, Align = TextAnchor.MiddleCenter, Color = tcol }, RectTransform = { AnchorMin = "0.12 0.05", AnchorMax = "0.88 0.15" } }, card); } else if (isWinner) { c.Add(new CuiLabel { Text = { Text = T("winner_badge"), FontSize = 13, Align = TextAnchor.MiddleCenter, Color = COL_GREEN }, RectTransform = { AnchorMin = "0.06 0.05", AnchorMax = "0.94 0.15" } }, card); } } private string BuildStatusLine() { if (_session == null) return ""; if (_session.Open) { long left = _session.ClosesAt - Now(); string tleft = left > 0 ? FormatLeft(left) : "0"; return string.Format(T("status_open"), _session.Votes.Count, tleft); } if (_session.Finalized) return T("status_closed"); return ""; } private string FormatLeft(long sec) { var ts = TimeSpan.FromSeconds(sec); if (ts.TotalDays >= 1) return $"{(int)ts.TotalDays}d {ts.Hours}h"; if (ts.TotalHours >= 1) return $"{(int)ts.TotalHours}h {ts.Minutes}m"; return $"{ts.Minutes}m"; } private void CloseUI(BasePlayer player) { if (player == null) return; _activePanels.Remove(player.userID); CuiHelper.DestroyUi(player, PANEL); CuiHelper.DestroyUi(player, PANEL_BG); } [HookMethod("API_IsVoteActive")] public bool API_IsVoteActive() => _session != null && _session.Open; [HookMethod("API_OpenPanel")] public void API_OpenPanel(BasePlayer player) => OpenUI(player); [HookMethod("API_GetVoteData")] public Dictionary API_GetVoteData() { if (_session == null) return new Dictionary { ["active"] = false }; int total = _session.Votes.Count; var maps = _session.Maps.Select((m, i) => { int v = _session.Votes.Count(kv => kv.Value == i); return new Dictionary { ["index"] = i, ["seed"] = m.Seed, ["size"] = m.Size, ["url"] = m.Url, ["image"] = !string.IsNullOrEmpty(m.IconUrl) ? m.IconUrl : m.ImageUrl, ["ready"] = m.Ready, ["votes"] = v, ["percent"] = total > 0 ? Math.Round(100.0 * v / total, 1) : 0.0, ["winner"] = _session.Finalized && _session.WinnerIndex == i, }; }).ToList(); return new Dictionary { ["active"] = _session.Open, ["finalized"] = _session.Finalized, ["closesAt"] = _session.ClosesAt, ["schedule"] = _session.Schedule, ["total"] = total, ["maps"] = maps, }; } [HookMethod("API_CastVoteFor")] public Dictionary API_CastVoteFor(string steamId, int index) { if (string.IsNullOrEmpty(steamId)) return VErr("Brak identyfikatora gracza."); if (_session == null || !_session.Open) return VErr("Brak aktywnego głosowania."); if (index < 0 || index >= _session.Maps.Count) return VErr("Nieprawidłowa mapa."); if (!_session.Maps[index].Ready) return VErr("Ta mapa nie jest jeszcze gotowa."); bool already = _session.Votes.ContainsKey(steamId); if (already && !_cfg.AllowChangeVote) return VErr("Już oddałeś głos."); _session.Votes[steamId] = index; SaveData(); RefreshViewers(); return new Dictionary { ["ok"] = true }; } private static Dictionary VErr(string msg) => new Dictionary { ["ok"] = false, ["error"] = msg }; [HookMethod("API_RegisterInWeb")] public void API_RegisterInWeb() { if (MajestatTopWeb == null) return; MajestatTopWeb.Call("API_RegisterVoteModule", new Dictionary { ["version"] = PluginVersion, }); } [HookMethod("API_GetVoteTabHtml")] public string API_GetVoteTabHtml() { return @"
🗳 Głosowanie na mapę
Ładowanie...
"; } private void BroadcastChat(string msg) { foreach (var p in BasePlayer.activePlayerList) SendMsg(p, msg); } private void SendMsg(BasePlayer player, string msg) { if (player == null) return; if (_cfg.ChatIconSteamID == 0) { player.ChatMessage(msg); return; } try { player.SendConsoleCommand("chat.add", 2, _cfg.ChatIconSteamID, msg); } catch { player.ChatMessage(msg); } } private int _logLevel = -1; private int LogLevelNum() { if (_logLevel >= 0) return _logLevel; switch ((_cfg.LogLevel ?? "system").ToLower()) { case "all": _logLevel = 2; break; case "alert": _logLevel = 0; break; case "none": _logLevel = -1; break; default: _logLevel = 1; break; } return _logLevel; } private void Log(int level, string msg) { if (LogLevelNum() >= level) Puts(msg); } private bool EN => (_cfg.Lang ?? "PL").ToUpper() == "EN"; private string T(string key) { if (EN) switch (key) { case "title": return "VOTE FOR MAP"; case "back_top": return "BACK TO TOP"; case "loading": return "loading..."; case "vote_btn": return "VOTE"; case "your_vote": return "YOUR VOTE"; case "voted_other": return "—"; case "winner_badge": return "★ WINNER ★"; case "no_active": return "No active voting."; case "no_active_panel":return "No active voting right now."; case "no_key": return "RustMaps API key missing in config."; case "loading_maps": return "Loading map proposals..."; case "already_voted": return "You already voted."; case "map_not_ready": return "This map is not ready yet."; case "no_perm": return "No permission."; case "vote_opened": return "[Vote] Map voting is open! Type /vote"; case "vote_cast": return "[Vote] Vote saved: size {0} seed {1}"; case "winner": return "[Vote] Winning map: size {0} seed {1} ({2} votes)"; case "status_open": return "{0} votes • {1} left"; case "status_closed": return "voting closed"; case "next_window": return "Next window: {0} → wipe {1} ({2})"; } switch (key) { case "title": return "GŁOSUJ NA MAPĘ"; case "back_top": return "POWRÓT DO TOP"; case "loading": return "ładowanie..."; case "vote_btn": return "GŁOSUJ"; case "your_vote": return "TWÓJ GŁOS"; case "voted_other": return "—"; case "winner_badge": return "★ ZWYCIĘZCA ★"; case "no_active": return "Brak aktywnego głosowania."; case "no_active_panel":return "Brak aktywnego głosowania."; case "no_key": return "Brak klucza RustMaps API w configu."; case "loading_maps": return "Pobieram propozycje map..."; case "already_voted": return "Już oddałeś głos."; case "map_not_ready": return "Ta mapa nie jest jeszcze gotowa."; case "no_perm": return "Brak uprawnień."; case "vote_opened": return "[Vote] Ruszyło głosowanie na mapę! Wpisz /vote"; case "vote_cast": return "[Vote] Głos zapisany: size {0} seed {1}"; case "winner": return "[Vote] Zwycięska mapa: size {0} seed {1} ({2} głosów)"; case "status_open": return "{0} głosów • zostało {1}"; case "status_closed": return "głosowanie zakończone"; case "next_window": return "Następne okno: {0} → wipe {1} ({2})"; } return key; } } }