using System; using System.Collections.Generic; using System.Globalization; using System.IO; using Newtonsoft.Json; using Oxide.Core; using Oxide.Core.Libraries; using Oxide.Core.Libraries.Covalence; using Oxide.Core.Plugins; using UnityEngine; namespace Oxide.Plugins { [Info("DiscordMapVote", "BigKids.pro", "4.0.0")] [Description("Applies the winning map from a BigKids.pro Discord vote and runs self-contained scheduled wipes (cron). Requires BigKidsCore.")] public class DiscordMapVote : CovalencePlugin { // Infrastructure (server token, HTTP, server.cfg writing) lives in BigKidsCore. // This plugin owns only the map-vote feature: scheduling, wiping, applying. [PluginReference] private Plugin BigKidsCore; private const int REQUIRED_CORE = 1; private const string PathMapVoteCurrent = "/api/v1/map-vote/current"; private bool coreOk; private bool unloaded; #region Configuration private Configuration config; private class Configuration { [JsonProperty("Check interval (minutes)")] public float CheckEveryMinutes = 30f; [JsonProperty("Auto-apply the winner when a vote finishes (ignored while Scheduled wipe is on)")] public bool AutoApply = true; [JsonProperty("Scheduled wipe")] public WipePlan Wipe = new WipePlan(); } private class WipePlan { [JsonProperty("Enabled")] public bool Enabled = false; // Empty = use the server machine's local time automatically. [JsonProperty("Timezone (empty = server local time; or IANA id e.g. Europe/Kyiv)")] public string Timezone = ""; [JsonProperty("Schedules (cron: minute hour day-of-month month day-of-week)", ObjectCreationHandling = ObjectCreationHandling.Replace)] public List Schedules = new List { new WipeRule() }; [JsonProperty("Countdown warnings (minutes before wipe)")] public int[] Countdown = { 60, 30, 15, 5, 1 }; [JsonProperty("Restart delay after wipe (seconds)")] public int RestartDelaySeconds = 30; [JsonProperty("Roll a new random seed when there is no vote winner")] public bool RandomSeedIfNoWinner = true; } private class WipeRule { [JsonProperty("Cron expression")] public string Cron = "0 19 * * 4"; // Map = world only (blueprints kept) | Full = world + blueprints [JsonProperty("Wipe type (Map/Full)")] public string WipeType = "Map"; [JsonProperty("Apply the voted map on this wipe")] public bool UseVotedMap = true; } protected override void LoadDefaultConfig() => config = new Configuration(); protected override void LoadConfig() { base.LoadConfig(); try { config = Config.ReadObject(); if (config == null) LoadDefaultConfig(); if (config.Wipe == null) config.Wipe = new WipePlan(); if (config.Wipe.Schedules == null) config.Wipe.Schedules = new List(); } catch { PrintWarning("Config is invalid, loading defaults."); LoadDefaultConfig(); } SaveConfig(); } protected override void SaveConfig() => Config.WriteObject(config); #endregion #region Stored data / flag files private const string PendingFile = "DiscordMapVote_pendingwipe"; private StoredData data; private class StoredData { public int LastAppliedVoteId = 0; public string LastLevelUrl = ""; public string LastAppliedAt = ""; } private class PendingWipe { public string Type = ""; public string QueuedAt = ""; } private void LoadData() => data = Interface.Oxide.DataFileSystem.ReadObject(Name) ?? new StoredData(); private void SaveData() => Interface.Oxide.DataFileSystem.WriteObject(Name, data); #endregion #region API models private class ApiResponse { public string status; public VoteInfo vote; public MapInfo map; } private class VoteInfo { public int id; public string question; public string decided_at; } private class MapInfo { public int id; public string type; public int size; public int? seed; public string build; public string level_url; public string page_url; } #endregion #region Lifecycle / core link private Timer checkTimer; private Timer wipeTimer; private bool wipeInProgress; private DateTime currentTargetUtc = DateTime.MinValue; private DateTime lastFiredMinuteUtc = DateTime.MinValue; private readonly HashSet firedWarnings = new HashSet(); private readonly List schedule = new List(); private class ParsedRule { public CronSchedule Cron; public string WipeType; public bool UseVotedMap; public string Raw; } private void Init() { permission.RegisterPermission("mapvote.admin", this); LoadData(); // Must run before the world save is loaded — independent of the core // (the core may not be loaded yet at Init; this needs no token/API). RunPendingWipe(); } private void OnServerInitialized() => TryLinkCore(); private void Unload() { unloaded = true; StopTimers(); } // React when the core is (re)loaded or unloaded at runtime. private void OnPluginLoaded(Plugin plugin) { if (plugin?.Name == "BigKidsCore") TryLinkCore(); } private void OnPluginUnloaded(Plugin plugin) { if (plugin?.Name == "BigKidsCore") { coreOk = false; StopTimers(); PrintWarning("BigKidsCore unloaded — DiscordMapVote is idle until it returns."); } } private void TryLinkCore() { if (BigKidsCore == null) { coreOk = false; StopTimers(); PrintError("BigKidsCore not found — DiscordMapVote is idle. Install BigKidsCore.cs."); return; } int v; try { v = Convert.ToInt32(BigKidsCore.Call("CoreApiVersion")); } catch { v = 0; } if (v < REQUIRED_CORE) { coreOk = false; StopTimers(); PrintError($"BigKidsCore is too old (need API v{REQUIRED_CORE}+, found v{v}) — DiscordMapVote is idle. Update BigKidsCore."); return; } coreOk = true; Puts($"Linked to BigKidsCore (API v{v})."); StartTimers(); } private void StartTimers() { StopTimers(); float interval = Math.Max(60f, config.CheckEveryMinutes * 60f); bool autoApply = config.AutoApply && !config.Wipe.Enabled; checkTimer = timer.Every(interval, () => Check(null, autoApply)); timer.Once(10f, () => { if (coreOk) Check(null, autoApply); }); if (config.Wipe.Enabled) { BuildSchedule(); if (schedule.Count == 0) { PrintWarning("Scheduled wipe is on but no valid cron schedules were parsed — wipes disabled."); } else { wipeTimer = timer.Every(20f, WipeTick); TimeZoneInfo tz = SafeTimeZone(config.Wipe.Timezone); DateTime next = ComputeNextWipeUtc(DateTime.UtcNow); if (next == DateTime.MaxValue) Puts($"Scheduled wipe on ({schedule.Count} rule(s), timezone {tz.Id}). No upcoming match found."); else Puts($"Scheduled wipe on ({schedule.Count} rule(s), timezone {tz.Id}). Next wipe: {TimeZoneInfo.ConvertTimeFromUtc(next, tz):yyyy-MM-dd HH:mm} local / {next:u} UTC."); } } } private void StopTimers() { checkTimer?.Destroy(); wipeTimer?.Destroy(); checkTimer = null; wipeTimer = null; } private void BuildSchedule() { schedule.Clear(); foreach (var rule in config.Wipe.Schedules) { try { schedule.Add(new ParsedRule { Cron = CronSchedule.Parse(rule.Cron), WipeType = NormalizeType(rule.WipeType), UseVotedMap = rule.UseVotedMap, Raw = rule.Cron, }); } catch (Exception e) { PrintWarning($"Ignoring invalid cron \"{rule.Cron}\": {e.Message}"); } } } private static string NormalizeType(string t) => (!string.IsNullOrEmpty(t) && t.Equals("Full", StringComparison.OrdinalIgnoreCase)) ? "Full" : "Map"; #endregion #region Commands [Command("mapvote")] private void CmdMapVote(IPlayer player, string command, string[] args) { bool isAdmin = player.IsServer || player.HasPermission("mapvote.admin"); string sub = args.Length > 0 ? args[0].ToLower() : "info"; switch (sub) { case "check": if (!isAdmin) { player.Reply("No permission."); return; } if (!coreOk) { player.Reply("DiscordMapVote: BigKidsCore is not ready."); return; } player.Reply("DiscordMapVote: checking…"); Check(player, config.AutoApply && !config.Wipe.Enabled); break; case "apply": if (!isAdmin) { player.Reply("No permission."); return; } if (config.Wipe.Enabled) { player.Reply("Scheduled wipe is on — the voted map is applied automatically at the wipe. Use /mapvote wipe to wipe now."); return; } if (!coreOk) { player.Reply("DiscordMapVote: BigKidsCore is not ready."); return; } player.Reply("DiscordMapVote: applying the latest winner…"); Check(player, true, true); break; case "wipe": if (!isAdmin) { player.Reply("No permission."); return; } string type = NormalizeType(args.Length > 1 ? args[1] : "Map"); player.Reply($"DiscordMapVote: starting a {type} wipe now…"); TriggerWipe(new ParsedRule { WipeType = type, UseVotedMap = true, Raw = "manual" }); break; case "nextwipe": if (!config.Wipe.Enabled) { player.Reply("Scheduled wipe is disabled."); return; } DateTime n = ComputeNextWipeUtc(DateTime.UtcNow); if (n == DateTime.MaxValue) { player.Reply("No upcoming wipe matches the configured schedule."); return; } TimeSpan left = n - DateTime.UtcNow; TimeZoneInfo ntz = SafeTimeZone(config.Wipe.Timezone); player.Reply($"Next wipe in {(int)left.TotalDays}d {left.Hours}h {left.Minutes}m — {TimeZoneInfo.ConvertTimeFromUtc(n, ntz):yyyy-MM-dd HH:mm} ({ntz.Id})."); break; case "schedule": if (!config.Wipe.Enabled) { player.Reply("Scheduled wipe is disabled."); return; } if (schedule.Count == 0) { player.Reply("No valid schedules parsed."); return; } foreach (var r in schedule) player.Reply($"• \"{r.Raw}\" → {r.WipeType}{(r.UseVotedMap ? " + voted map" : "")}"); break; case "info": default: string w = ""; if (config.Wipe.Enabled) { DateTime nx = ComputeNextWipeUtc(DateTime.UtcNow); w = nx == DateTime.MaxValue ? " | no upcoming wipe" : $" | next wipe {nx:u} UTC"; } string core = coreOk ? "" : " | CORE MISSING"; if (data.LastAppliedVoteId > 0) player.Reply($"DiscordMapVote: last applied vote #{data.LastAppliedVoteId} ({data.LastAppliedAt}). Map: {data.LastLevelUrl}{w}{core}"); else player.Reply($"DiscordMapVote: nothing applied yet. Use /mapvote check.{w}{core}"); break; } } #endregion #region Vote fetch / apply (through the core) private void Check(IPlayer requester, bool allowApply, bool force = false) { if (!coreOk) { requester?.Reply("DiscordMapVote: BigKidsCore is not ready."); return; } BigKidsCore.Call("ApiGet", PathMapVoteCurrent, (Action)((code, response) => OnApiResponse(code, response, requester, allowApply, force))); } private void OnApiResponse(int code, string response, IPlayer requester, bool allowApply, bool force) { if (unloaded) return; if (code != 200 || string.IsNullOrEmpty(response)) { PrintWarning($"API request failed: HTTP {code}"); requester?.Reply($"DiscordMapVote: API error (HTTP {code})."); return; } ApiResponse result = ParseApi(response); if (result == null || result.status != "ok" || result.map == null || result.vote == null) { requester?.Reply("DiscordMapVote: no finished vote with a winner yet."); return; } if (!force && result.vote.id == data.LastAppliedVoteId) { requester?.Reply($"DiscordMapVote: winner of vote #{result.vote.id} is already applied. Use /mapvote apply to re-apply."); return; } Puts($"New map-vote winner: vote #{result.vote.id} → {result.map.size} ({result.map.type}) {result.map.page_url}"); if (!allowApply) { requester?.Reply($"DiscordMapVote: winner is {result.map.size} ({result.map.type}). It will be applied on the next scheduled wipe (or use /mapvote apply)."); return; } ApplyMap(result.map.level_url, result.vote.id); requester?.Reply($"DiscordMapVote: applied {result.map.size} ({result.map.type}). Loads on the next server restart."); } private ApiResponse ParseApi(string response) { try { return JsonConvert.DeserializeObject(response); } catch (Exception e) { PrintWarning("Failed to parse API response: " + e.Message); return null; } } private void ApplyMap(string levelUrl, int voteId) { if (string.IsNullOrEmpty(levelUrl)) { PrintWarning("Winner has no level_url."); return; } if (BigKidsCore == null || !Convert.ToBoolean(BigKidsCore.Call("IsValidMapUrl", levelUrl))) { PrintWarning($"Refusing to apply a suspicious level_url: {levelUrl}"); return; } server.Command("server.levelurl", levelUrl); data.LastAppliedVoteId = voteId; data.LastLevelUrl = levelUrl; data.LastAppliedAt = DateTime.UtcNow.ToString("u"); SaveData(); BigKidsCore.Call("WriteServerCfgLevelUrl", levelUrl); // core writes server.cfg Puts($"Applied server.levelurl = {levelUrl}"); } #endregion #region Wipe — schedule tick private void WipeTick() { if (!coreOk || !config.Wipe.Enabled || wipeInProgress || schedule.Count == 0) return; DateTime nowUtc = DateTime.UtcNow; DateTime localNow = ToLocal(nowUtc); DateTime minuteKey = new DateTime(nowUtc.Year, nowUtc.Month, nowUtc.Day, nowUtc.Hour, nowUtc.Minute, 0, DateTimeKind.Utc); if (minuteKey != lastFiredMinuteUtc) { ParsedRule due = MatchNow(localNow); if (due != null) { lastFiredMinuteUtc = minuteKey; TriggerWipe(due); return; } } DateTime target = (currentTargetUtc > nowUtc) ? currentTargetUtc : ComputeNextWipeUtc(nowUtc); if (target != currentTargetUtc) { currentTargetUtc = target; firedWarnings.Clear(); } if (target == DateTime.MaxValue) return; double minutesLeft = (target - nowUtc).TotalMinutes; foreach (int m in config.Wipe.Countdown ?? new int[0]) { if (!firedWarnings.Contains(m) && minutesLeft <= m && minutesLeft > m - 0.5) { firedWarnings.Add(m); server.Broadcast($"[Wipe] Server wipe in {m} minute(s)!"); } } } private ParsedRule MatchNow(DateTime local) { foreach (var r in schedule) if (r.Cron.Matches(local)) return r; return null; } #endregion #region Wipe — execution private void TriggerWipe(ParsedRule rule) { if (wipeInProgress) return; wipeInProgress = true; server.Broadcast("[Wipe] Wipe starting — preparing the next map…"); if (rule.UseVotedMap && coreOk) { BigKidsCore.Call("ApiGet", PathMapVoteCurrent, (Action)((code, response) => OnWipeApi(code, response, rule))); } else { if (rule.UseVotedMap && !coreOk) PrintWarning("BigKidsCore not ready — wiping without applying a voted map."); FinishWipe(rule, null); } } private void OnWipeApi(int code, string response, ParsedRule rule) { if (unloaded) return; ApiResponse result = code == 200 ? ParseApi(response) : null; string url = (result != null && result.status == "ok" && result.map != null) ? result.map.level_url : null; int voteId = result?.vote?.id ?? 0; if (!string.IsNullOrEmpty(url)) ApplyMap(url, voteId); FinishWipe(rule, url); } private void FinishWipe(ParsedRule rule, string appliedUrl) { if (string.IsNullOrEmpty(appliedUrl) && config.Wipe.RandomSeedIfNoWinner) { int seed = UnityEngine.Random.Range(1, int.MaxValue); server.Command("server.levelurl", ""); server.Command("server.seed", seed); BigKidsCore?.Call("WriteServerCfgSeed", seed); Puts($"No vote winner — rolled random seed {seed}."); } WritePendingWipe(rule.WipeType); int delay = Math.Max(10, config.Wipe.RestartDelaySeconds); server.Broadcast($"[Wipe] Restarting in {delay}s to apply the new map."); Puts($"Wipe ({rule.WipeType}) queued. Restarting in {delay}s; save files are cleared on the next startup, before the world loads."); server.Command("restart", delay); timer.Once(delay + 60f, () => wipeInProgress = false); } private void WritePendingWipe(string type) { try { Interface.Oxide.DataFileSystem.WriteObject(PendingFile, new PendingWipe { Type = NormalizeType(type), QueuedAt = DateTime.UtcNow.ToString("u"), }); Puts($"Pending wipe flag written ({NormalizeType(type)})."); } catch (Exception e) { PrintWarning("Could not write pending wipe flag: " + e.Message); } } #endregion #region Wipe — startup deletion (runs in Init, before world load; no core needed) private void RunPendingWipe() { PendingWipe pending; try { if (!Interface.Oxide.DataFileSystem.ExistsDatafile(PendingFile)) return; pending = Interface.Oxide.DataFileSystem.ReadObject(PendingFile); } catch (Exception e) { PrintWarning("Could not read pending wipe flag: " + e.Message); return; } if (pending == null || string.IsNullOrEmpty(pending.Type)) return; if (!IsBooting()) { PrintWarning("Pending wipe found but the world is already loaded (hot-reload). It will run on the next real startup."); return; } DeleteSaveFiles(pending.Type); ClearPendingWipe(); } private bool IsBooting() { try { return BaseNetworkable.serverEntities == null || BaseNetworkable.serverEntities.Count == 0; } catch { return false; } } private void DeleteSaveFiles(string type) { try { string identity = ServerIdentity(); string dir = Path.Combine("server", identity); if (!Directory.Exists(dir)) { PrintWarning($"Wipe: identity folder not found ({dir}); nothing to delete."); return; } int n = DeleteByPattern(dir, "*.sav*"); // world: buildings, sleepers, loot, deployables if (NormalizeType(type) == "Full") n += DeleteByPattern(dir, "player.blueprints.*.db"); // learned blueprints Puts($"Startup wipe ({NormalizeType(type)}) complete: removed {n} file(s) from {dir} before world load."); } catch (Exception e) { PrintWarning("Wipe deletion failed: " + e.Message); } } private int DeleteByPattern(string dir, string pattern) { int n = 0; foreach (string f in Directory.GetFiles(dir, pattern)) { try { File.Delete(f); n++; } catch (Exception e) { PrintWarning($"Could not delete {Path.GetFileName(f)}: {e.Message}"); } } return n; } private void ClearPendingWipe() { try { Interface.Oxide.DataFileSystem.WriteObject(PendingFile, new PendingWipe()); } catch (Exception e) { PrintWarning("Could not clear pending wipe flag: " + e.Message); } } // Local copy (core may not be loaded at Init when the startup deletion runs). // Authoritative identity = the command line, since server.cfg can overwrite // ConVar.Server.identity after the save folder is already chosen. private string ServerIdentity() { try { string[] args = Environment.GetCommandLineArgs(); for (int i = 0; i + 1 < args.Length; i++) if (args[i].Equals("+server.identity", StringComparison.OrdinalIgnoreCase)) return args[i + 1]; } catch { } try { string id = ConVar.Server.identity; if (!string.IsNullOrEmpty(id)) return id; } catch { } return "server"; } #endregion #region Schedule maths private TimeZoneInfo SafeTimeZone(string id) { if (string.IsNullOrEmpty(id)) return TimeZoneInfo.Local; if (id.Equals("UTC", StringComparison.OrdinalIgnoreCase)) return TimeZoneInfo.Utc; try { return TimeZoneInfo.FindSystemTimeZoneById(id); } catch { PrintWarning($"Unknown timezone \"{id}\" — using server local time ({TimeZoneInfo.Local.Id})."); return TimeZoneInfo.Local; } } private DateTime ToLocal(DateTime utc) => TimeZoneInfo.ConvertTimeFromUtc(utc, SafeTimeZone(config.Wipe.Timezone)); private DateTime ComputeNextWipeUtc(DateTime fromUtc) { if (schedule.Count == 0) return DateTime.MaxValue; TimeZoneInfo tz = SafeTimeZone(config.Wipe.Timezone); DateTime local = TimeZoneInfo.ConvertTimeFromUtc(fromUtc, tz); DateTime cursor = local.AddSeconds(-local.Second).AddMilliseconds(-local.Millisecond).AddMinutes(1); int limit = 60 * 24 * 366; for (int i = 0; i < limit; i++, cursor = cursor.AddMinutes(1)) { foreach (var r in schedule) { if (r.Cron.Matches(cursor)) return TimeZoneInfo.ConvertTimeToUtc(DateTime.SpecifyKind(cursor, DateTimeKind.Unspecified), tz); } } return DateTime.MaxValue; } #endregion #region Cron parser (our own, 5-field, supports * , - / and L) private class CronSchedule { private readonly bool[] minute = new bool[60]; private readonly bool[] hour = new bool[24]; private readonly bool[] dom = new bool[32]; private readonly bool[] month = new bool[13]; private readonly bool[] dow = new bool[7]; private bool lastDayOfMonth; private bool domRestricted; private bool dowRestricted; public static CronSchedule Parse(string expr) { string[] f = (expr ?? "").Trim().Split(new[] { ' ', '\t' }, StringSplitOptions.RemoveEmptyEntries); if (f.Length != 5) throw new Exception("expected 5 fields: minute hour day-of-month month day-of-week"); var c = new CronSchedule(); FillField(f[0], 0, 59, c.minute); FillField(f[1], 0, 23, c.hour); c.domRestricted = f[2].Trim() != "*"; ParseDom(f[2], c); FillField(f[3], 1, 12, c.month); c.dowRestricted = f[4].Trim() != "*"; ParseDow(f[4], c.dow); return c; } public bool Matches(DateTime t) { int day = t.Day; int daysInMonth = DateTime.DaysInMonth(t.Year, t.Month); bool domHit = dom[day] || (lastDayOfMonth && day == daysInMonth); bool dowHit = dow[(int)t.DayOfWeek]; bool dayOk; if (domRestricted && dowRestricted) dayOk = domHit || dowHit; else if (domRestricted) dayOk = domHit; else if (dowRestricted) dayOk = dowHit; else dayOk = true; return minute[t.Minute] && hour[t.Hour] && month[t.Month] && dayOk; } private static void FillField(string field, int min, int max, bool[] arr) { foreach (string raw in field.Split(',')) { string item = raw.Trim(); if (item.Length == 0) continue; int step = 1; string range = item; int slash = item.IndexOf('/'); if (slash >= 0) { step = ParseInt(item.Substring(slash + 1)); range = item.Substring(0, slash); if (step < 1) step = 1; } int lo, hi; if (range == "*") { lo = min; hi = max; } else { int dash = range.IndexOf('-'); if (dash > 0) { lo = ParseInt(range.Substring(0, dash)); hi = ParseInt(range.Substring(dash + 1)); } else lo = hi = ParseInt(range); } if (lo < min) lo = min; if (hi > max) hi = max; for (int v = lo; v <= hi; v += step) if (v >= min && v <= max) arr[v] = true; } } private static void ParseDom(string field, CronSchedule c) { var rest = new List(); foreach (string raw in field.Split(',')) { string item = raw.Trim(); if (item.Equals("L", StringComparison.OrdinalIgnoreCase)) { c.lastDayOfMonth = true; continue; } if (item.Length > 0) rest.Add(item); } if (rest.Count > 0) FillField(string.Join(",", rest), 1, 31, c.dom); } private static void ParseDow(string field, bool[] dow) { foreach (string raw in field.Split(',')) { string item = raw.Trim(); if (item.Length == 0) continue; int step = 1; string range = item; int slash = item.IndexOf('/'); if (slash >= 0) { step = ParseInt(item.Substring(slash + 1)); range = item.Substring(0, slash); if (step < 1) step = 1; } int lo, hi; if (range == "*") { lo = 0; hi = 7; } else { int dash = range.IndexOf('-'); if (dash > 0) { lo = ParseInt(range.Substring(0, dash)); hi = ParseInt(range.Substring(dash + 1)); } else lo = hi = ParseInt(range); } for (int v = lo; v <= hi; v += step) { int d = ((v % 7) + 7) % 7; dow[d] = true; } } } private static int ParseInt(string s) { if (!int.TryParse(s.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out int v)) throw new Exception($"\"{s}\" is not a number"); return v; } } #endregion } }