using Newtonsoft.Json; using Oxide.Core.Libraries.Covalence; using System; using System.Collections.Generic; using System.Globalization; using System.Text; namespace Oxide.Plugins { [Info("Timed Workbench Unlock", "HunterZ", "2.3.0")] [Description("Provides timed/manual/disabled unlocking of workbenches")] class TimedWorkbenchUnlock : RustPlugin { #region Vars // ordered list of workbench IDs private static readonly int[] _WORKBENCH_ID = { 1524187186, -41896755, -1607980696 }; // periodic global status broadcast timer private Timer _broadcastTimer; // data managed via config file private ConfigData _configData; // "can't craft" warning suppression timers by userID private readonly Dictionary _craftWarned = new(); // string builder for producing status messages private readonly StringBuilder _statusBuilder = new(); // workbench unlock announcement broadcast timers private readonly Timer[] _unlockTimers = { null, null, null }; #region Permission Strings private const string PERMISSION_ADMIN = "admin"; private const string PERMISSION_BROADCAST = "broadcast"; private const string PERMISSION_INFO = "info"; private const string PERMISSION_MODIFY = "modify"; private const string PERMISSION_RELOAD = "reload"; private const string PERMISSION_RESET = "reset"; private const string PERMISSION_SKIPLOCK = "skiplock"; private const string PERMISSION_WIPE = "wipe"; #endregion Permission Strings #endregion Vars #region Utilities // get time since wipe in seconds // if positive optional parameter is specified, it is used as a passthrough private double GetWipeElapsedSeconds(double wipeElapsedSeconds = -1.0) { if (null == _configData) return 0; return wipeElapsedSeconds > 0 ? wipeElapsedSeconds : (DateTime.UtcNow - _configData.LastWipeUTC).TotalSeconds; } // returns unlock status for given workbench index (0-2 => level 1-3) // -1 => locked forever (requires manual unlock) // 0 => unlocked // >0 => number of seconds until auto unlock // // wipe elapsed seconds can optionally be specified to avoid repeated // lookups when calling from a loop private int GetUnlockStatus(int index, double wipeElapsedSeconds = -1.0) { if (null == _configData || index < 0 || index > 2) return 0; wipeElapsedSeconds = GetWipeElapsedSeconds(wipeElapsedSeconds); double unlockDelaySeconds = _configData.WBConfig[index]; // check for permanent lock if (unlockDelaySeconds < 0.0) return -1; // determine auto unlock status if (unlockDelaySeconds > 0.0) { double unlockSecondsRemaining = unlockDelaySeconds - wipeElapsedSeconds; return unlockSecondsRemaining > 0 ? (int)Math.Ceiling(unlockSecondsRemaining) : 0; } // unlockDelaySeconds == 0.0 => always unlocked return 0; } // returns an array of unlock times for workbenches // see GetUnlockTime() for value meanings private int[] GetUnlockStatus() { double wipeElapsedSeconds = GetWipeElapsedSeconds(); // unrolled loop for simplicity return new[] { GetUnlockStatus(0, wipeElapsedSeconds), GetUnlockStatus(1, wipeElapsedSeconds), GetUnlockStatus(2, wipeElapsedSeconds) }; } // create a timer that will fire when the given workbench should unlock, or // null if manually locked or already unlocked private Timer GetTimer(int index, double wipeElapsedSeconds = -1.0) { if (index < 0 || index > 2) { return null; } wipeElapsedSeconds = GetWipeElapsedSeconds(wipeElapsedSeconds); var status = GetUnlockStatus(index, wipeElapsedSeconds); if (status > 0) return timer.Once(status, () => { ReportUnlock(index); }); return null; } // destroy existing broadcast timer (if any) private void DestroyBroadcastTimer() { if (null == _broadcastTimer) return; _broadcastTimer.Destroy(); _broadcastTimer = null; } // (re)set broadcast timer to fire at configured interval private void SetBroadcastTimer() { DestroyBroadcastTimer(); // only set new timer if config value is positive (i.e. broadcast period // in seconds) var broadcastConfig = _configData?.BroadcastConfig ?? 0; if (broadcastConfig > 0) { _broadcastTimer = timer.Every(broadcastConfig, () => { ReportStatus(null); }); } } // destroy all existing timers managed by unlockTimers private void DestroyUnlockTimers() { for (int i = 0; i < _unlockTimers.Length; ++i) { var unlockTimer = _unlockTimers[i]; if (null == unlockTimer || unlockTimer.Destroyed) continue; unlockTimer.Destroy(); _unlockTimers[i] = null; } } // (re)set all unlock announcement timers as appropriate // this should be called whenever unlock times might have changed private void SetUnlockTimers() { // timers don't auto-destruct, so wipe them to avoid double-firing DestroyUnlockTimers(); var wipeElapsedSeconds = GetWipeElapsedSeconds(); for (int i = 0; i < _unlockTimers.Length; ++i) { _unlockTimers[i] = GetTimer(i, wipeElapsedSeconds); } } private void DestroyWarnTimer(ulong userId) { if (_craftWarned.Remove(userId, out var wTimer) && false == wTimer?.Destroyed) { wTimer.Destroy(); } } // generate color locked/unlocked status text for twinfo command private string UnlockStatusString(int status, IPlayer player) => status == 0 ? Colorize(lang.GetMessage("Unlocked", this, player.Id), "green") : Colorize(lang.GetMessage("Locked", this, player.Id), "red"); // return true if player is null, server, or admin, or has permission, else // reply with "no permission" message and return false private bool HasPermission(IPlayer player, string perm) { if (null == player) return false; var hasPermission = player.IsServer || player.HasPermission(PrefixPermission(PERMISSION_ADMIN)) || player.HasPermission(PrefixPermission(perm)); if (!hasPermission) SendMessage(player, "NoPermission"); return hasPermission; } // return a prefixed version of the given permission string // this is done to avoid hard-coding it, which would be a maintenance issue private string PrefixPermission(string perm) => Name.ToLower() + "." + perm; // report user-friendly detailed status private void ReportStatus(IPlayer player) { // don't report status if nobody is online if (null == player && BasePlayer.activePlayerList.IsNullOrEmpty()) return; var status = GetUnlockStatus(); // don't report status if everything is unlocked if (0 == status[0] && 0 == status[1] && 0 == status[2]) return; _statusBuilder.Clear(); _statusBuilder.AppendLine(FormatMessage(player, "StatusBanner")); for (int index = 0; index < 3; ++index) { string wbNumStr = (index + 1).ToString(CultureInfo.CurrentCulture); switch (status[index]) { case < 0: { _statusBuilder.AppendLine( FormatMessage(player, "StatusManual", wbNumStr)); } break; case 0: { _statusBuilder.AppendLine( FormatMessage(player, "StatusUnlocked", wbNumStr)); } break; case > 0: { _statusBuilder.AppendLine(FormatMessage( player, "StatusTime", wbNumStr, TimeSpan.FromSeconds(status[index]).ToString( "g", CultureInfo.CurrentCulture))); } break; } } SendRawMessage(player, _statusBuilder.ToString()); } // report that a workbench has unlocked private void ReportUnlock(int index) { if (index < 0 || index > 2) return; // don't report unlock if nobody is online if (BasePlayer.activePlayerList.IsNullOrEmpty()) return; SendMessage( null, "UnlockNotice", (index + 1).ToString(CultureInfo.CurrentCulture)); } // return whether crafting of the given item ID should be allowed private bool AllowCraftAttempt(BasePlayer player, int itemid) { // do nothing if any of the following are true: // - player reference invalid // - player in Tutorial Island // - player crafting something other than a workbench // - player has skiplock permission (note: admins not exempt by default) if (null == player || player.IsInTutorial || !_WORKBENCH_ID.Contains(itemid) || player.IPlayer.HasPermission(PrefixPermission(PERMISSION_SKIPLOCK))) { return true; } // get status var wbIndex = Array.IndexOf(_WORKBENCH_ID, itemid); var status = GetUnlockStatus(wbIndex); // do nothing if workbench is unlocked if (0 == status) return true; // warn player if (_configData.ReportAsSound) WarnSound(player); // abort here if text reports disabled, to avoid building time string if (status > 0 && !_configData.ReportAsChat && !_configData.ReportAsToast) { return false; } var timeString = status > 0 ? TimeSpan.FromSeconds(status).ToString("g", CultureInfo.CurrentCulture) : null; if (_configData.ReportAsChat) { WarnChat(player, timeString); } if (_configData.ReportAsToast) { WarnToast(player, timeString); } // block crafting return false; } private void WarnChat(BasePlayer player, string timeString = null) { // abort if chat spam suppression timer active for player var userId = player.userID.Get(); if (_craftWarned.ContainsKey(userId)) return; SendMessage( player.IPlayer, null == timeString ? "CannotCraftManual" : "CannotCraft", timeString); // set chat spam suppression timer _craftWarned.Add(userId, timer.Once(5.0f, () => { DestroyWarnTimer(userId); })); } private static void WarnSound(BasePlayer player) { Effect.server.Run( "assets/prefabs/locks/keypad/effects/lock.code.denied.prefab", player.transform.position); } private void WarnToast(BasePlayer player, string timeString = null) { SendToast( player.IPlayer, null == timeString ? "CannotCraftManual" : "CannotCraft", timeString); } #endregion Utilities #region Messaging // load default message text dictionary protected override void LoadDefaultMessages() { lang.RegisterMessages(new Dictionary { ["BroadcastDisabled"] = "Status broadcast disabled", ["BroadcastSet"] = "Status broadcast period set to {0} second(s)", ["CannotCraft"] = "Cannot craft this item (unlocks in {0})", ["CannotCraftManual"] = "Cannot craft this item (unlocks manually/never)", ["InfoBanner"] = "Now @{0} / T1 {1} (@{2}/{3}) / T2 {4} (@{5}/{6}) / T3 {7} (@{8}/{9})", ["InvalidWorkbench"] = "Invalid workbench number specified!", ["Locked"] = "locked", ["ModifiedManual"] = "WB {0} is now always locked", ["ModifiedTime"] = "WB {0} now unlocks in {1} second(s) after wipe", ["ModifiedUnlocked"] = "WB {0} is now always unlocked", ["NoPermission"] = "You don't have permission to use this command", ["PluginWipe"] = "Wipe time reset to {0}", ["ReloadConfig"] = "Config has been reloaded", ["ResetConfig"] = "Config has been reset", ["StatusBanner"] = "Workbenches are currently on a timed unlock system. Current status:", ["StatusManual"] = "- Workbench Level {0}: Unlocks manually/never", ["StatusTime"] = "- Workbench Level {0}: Unlocks in {1}", ["StatusUnlocked"] = "- Workbench Level {0}: Unlocked!", ["SyntaxError"] = "Syntax Error!", ["Unlocked"] = "unlocked", ["UnlockNotice"] = "Workbench Level {0} has unlocked, and can now be crafted!" }, this); } // format a message based on language dictionary, arguments, and destination private string FormatMessage( IPlayer player, string langCode, params string[] args) { var playerId = null == player || player.IsServer ? null : player.Id; var msg = string.Format(lang.GetMessage(langCode, this, playerId), args); // strip color markings out of console messages if (null == playerId) // note: cannot supply StringComparison enum value here, as it results // in a "not implemented" exception in some cases msg = msg .Replace("", string.Empty) .Replace("", string.Empty) .Replace("", string.Empty); return msg; } // send a message to player or server without additional formatting private void SendRawMessage(IPlayer player, string message) { if (null == player) { Server.Broadcast(message); } else { player.Reply(message); } } // send a message to player or server based on language dictionary and // arguments // this is the primary method that should be used to communicate to users private void SendMessage( IPlayer player, string langCode, params string[] args) { SendRawMessage(player, FormatMessage(player, langCode, args)); } private void SendToast( IPlayer player, string langCode, params string[] args) { var basePlayer = player.Object as BasePlayer; if (null == basePlayer) return; basePlayer.ShowToast(0, FormatMessage(player, langCode, args)); } // decorate a string with color codes // note that only red or green should be used, as FormatMessage() only // strips those private static string Colorize(string str, string color) => "" + str + ""; #endregion Messaging #region Hooks // called by Oxide after config load protected void Init() { SetBroadcastTimer(); SetUnlockTimers(); // Permissions permission.RegisterPermission( PrefixPermission(PERMISSION_ADMIN), this); permission.RegisterPermission( PrefixPermission(PERMISSION_BROADCAST), this); permission.RegisterPermission( PrefixPermission(PERMISSION_INFO), this); permission.RegisterPermission( PrefixPermission(PERMISSION_MODIFY), this); permission.RegisterPermission( PrefixPermission(PERMISSION_RELOAD), this); permission.RegisterPermission( PrefixPermission(PERMISSION_RESET), this); permission.RegisterPermission( PrefixPermission(PERMISSION_SKIPLOCK), this); permission.RegisterPermission( PrefixPermission(PERMISSION_WIPE), this); AddCovalenceCommand("twbroadcast", nameof(CommandBroadcast)); AddCovalenceCommand("twinfo", nameof(CommandInfo)); AddCovalenceCommand("twmodify", nameof(CommandModify)); AddCovalenceCommand("twreload", nameof(CommandReload)); AddCovalenceCommand("twreset", nameof(CommandReset)); AddCovalenceCommand("twwipe", nameof(CommandWipe)); } // called by Oxide on plugin unload protected void Unload() { // clean up any timers DestroyBroadcastTimer(); DestroyUnlockTimers(); foreach (var (_, warnTimer) in _craftWarned) { if (null == warnTimer || warnTimer.Destroyed) continue; warnTimer.Destroy(); } _craftWarned.Clear(); } private object CanCraft( PlayerBlueprints playerBlueprints, ItemDefinition itemDefinition) => !_configData.BlockCraft || AllowCraftAttempt( playerBlueprints.baseEntity, itemDefinition.itemid) ? null : false; private object CanResearchItem(BasePlayer player, Item item) => !_configData.BlockResearch || AllowCraftAttempt( player, item.info.itemid) ? null : false; private void OnPlayerConnected(BasePlayer player) => ReportStatus(player.IPlayer); private void OnPlayerDisconnected(BasePlayer player, string reason) { DestroyWarnTimer(player.userID.Get()); } #endregion Hooks #region Commands private void CommandBroadcast(IPlayer player, string command, string[] args) { if (null == _configData || !HasPermission(player, PERMISSION_BROADCAST)) { return; } if (args.Length < 1) { player.Reply(string.Format( lang.GetMessage("SyntaxError", this, player.Id), command)); return; } _configData.BroadcastConfig = Convert.ToInt32(args[0]); if (_configData.BroadcastConfig < 0) _configData.BroadcastConfig = 0; SaveConfig(); SetBroadcastTimer(); if (_configData.BroadcastConfig > 0) { SendMessage( player, "BroadcastSet", _configData.BroadcastConfig.ToString()); } else { SendMessage(player, "BroadcastDisabled"); } } private void CommandInfo(IPlayer player) { if (null == _configData || !HasPermission(player, PERMISSION_INFO)) { return; } var status = GetUnlockStatus(); SendMessage(player, "InfoBanner", GetWipeElapsedSeconds().ToString(CultureInfo.CurrentCulture), UnlockStatusString(status[0], player), status[0].ToString(CultureInfo.CurrentCulture), _configData.WBConfig[0].ToString(CultureInfo.CurrentCulture), UnlockStatusString(status[1], player), status[1].ToString(CultureInfo.CurrentCulture), _configData.WBConfig[1].ToString(CultureInfo.CurrentCulture), UnlockStatusString(status[2], player), status[2].ToString(CultureInfo.CurrentCulture), _configData.WBConfig[2].ToString(CultureInfo.CurrentCulture) ); } private void CommandModify(IPlayer player, string command, string[] args) { if (null == _configData || !HasPermission(player, PERMISSION_MODIFY)) { return; } if (args.Length < 2) { player.Reply(string.Format( lang.GetMessage("SyntaxError", this, player.Id), command)); return; } var wbIndex = Convert.ToInt32(args[0]) - 1; if (wbIndex < 0 || wbIndex > 2) { player.Reply(string.Format( lang.GetMessage("InvalidWorkbench", this, player.Id), command)); return; } var wbConfig = Convert.ToInt32(args[1]); if (wbConfig < -1) wbConfig = -1; _configData.WBConfig[wbIndex] = wbConfig; SaveConfig(); SetUnlockTimers(); if (wbConfig < 0) { SendMessage(player, "ModifiedManual", args[0]); } else if (wbConfig > 0) { SendMessage(player, "ModifiedTime", args[0], wbConfig.ToString( CultureInfo.CurrentCulture)); } else { SendMessage(player, "ModifiedUnlocked", args[0]); } } private void CommandReload(IPlayer player) { if (!HasPermission(player, PERMISSION_RELOAD)) return; LoadConfig(); SetBroadcastTimer(); SetUnlockTimers(); SendMessage(player, "ReloadConfig"); CommandInfo(player); } private void CommandReset(IPlayer player) { if (!HasPermission(player, PERMISSION_RESET)) return; LoadDefaultConfig(); SaveConfig(); SetBroadcastTimer(); SetUnlockTimers(); SendMessage(player, "ResetConfig"); CommandInfo(player); } private void CommandWipe(IPlayer player) { if (null == _configData || !HasPermission(player, PERMISSION_WIPE)) { return; } DateTime currentTime = DateTime.UtcNow; _configData.LastWipeUTC = currentTime; SaveConfig(); SetUnlockTimers(); SendMessage(player, "PluginWipe", currentTime.ToString( "R", CultureInfo.CurrentCulture)); } #endregion Commands #region Configuration // need to append logic to check for map wipe since last load protected override void LoadConfig() { base.LoadConfig(); try { _configData = Config.ReadObject(); if (null == _configData) { throw new JsonException("ReadObject() returned null"); } } catch (Exception ex) { PrintWarning($"LoadConfig(): Exception while loading configuration file:\n{ex}"); LoadDefaultConfig(); } if (null == _configData) return; if (_configData.WBConfig.Length != 3) { PrintWarning($"LoadConfig(): Got {_configData.WBConfig.Length} workbench unlock settings, but expected 3; resetting to default list"); _configData.WBConfig = ConfigData._DEFAULT_WB_SECONDS; } var serverWipeTime = SaveRestore.SaveCreatedTime; if (_configData.LastWipeUTC < serverWipeTime) { _configData.LastWipeUTC = serverWipeTime; Puts("LoadConfig(): Wipe detected - reset wipe time to " + serverWipeTime.ToString("R", CultureInfo.CurrentCulture)); } SaveConfig(); } protected override void LoadDefaultConfig() { Puts("LoadDefaultConfig(): Creating a new configuration file"); _configData = new ConfigData(); } protected override void SaveConfig() => Config.WriteObject(_configData); // config file data class private sealed class ConfigData { // default workbench unlock times [JsonIgnore] public static readonly int[] _DEFAULT_WB_SECONDS = { 86400, 172800, 259200 }; [JsonProperty(PropertyName = "Global status broadcast interval in seconds (0 to disable)")] public int BroadcastConfig { get; set; } = 300; [JsonProperty(PropertyName = "Time that current wipe started (UTC)")] public DateTime LastWipeUTC { get; set; } = SaveRestore.SaveCreatedTime; [JsonProperty(PropertyName = "Workbench unlock times (seconds from start of wipe, or 0 for unlocked, or -1 for permanently locked)")] public int[] WBConfig { get; set; } = _DEFAULT_WB_SECONDS; [JsonProperty(PropertyName = "Block crafting of locked workbench(es)")] public bool BlockCraft = true; [JsonProperty(PropertyName = "Block researching of locked workbench(es)")] public bool BlockResearch = true; [JsonProperty(PropertyName = "Report craft failure as chat message")] public bool ReportAsChat = false; [JsonProperty(PropertyName = "Report craft failure as sound effect")] public bool ReportAsSound = true; [JsonProperty(PropertyName = "Report craft failure as toast message")] public bool ReportAsToast = true; } #endregion Configuration } }