C#:
// ============================================================================
// ModernNoCupboardDecay v6.0.1
// Author : Gabriel Dungan -- DunganSoft Technologies
// License : GPL-3.0 -- see LICENSE
//
// What this plugin does
// ---------------------
// Prevents decay damage for all building blocks and deployables that fall
// within the configured radius of any Tool Cupboard on the server.
//
// Optional CheckAuth mode (default OFF) restricts protection to entities
// whose OwnerID is directly authorized on a nearby TC, or is a Rust-team
// member of such an authorized player (TeamAwareProtection, default ON).
//
// A wipe-timer UI panel is shown to players who open a TC. The panel
// displays the active wipe schedule and time remaining. Wipe mode can be
// detected automatically from ConVar.Server.tags or set manually.
//
// Permissions
// -----------
// modernnocupboarddecay.admin -- /mncdset, /mncdui, /mncduiadd, /mncdresetui
// modernnocupboarddecay.debug -- /mncddebug (protection-status overlay)
// modernnocupboarddecay.preview -- /mncdpreview (TC radius rings)
// (only enforced when PreviewRequiresPermission = true)
//
// Compatibility
// -------------
// Oxide / uMod 2.0.7022+
// Rust Naval Update (BuildingPrivlidge.authorizedPlayers as HashSet<ulong>)
// Verified against Oxide 2.0.7338 (May 2026 Rust patch series). No breaking
// hook changes for OnEntityTakeDamage, OnLootEntity[End], OnPlayerDisconnected,
// OnNewSave, OnServerInitialized, Init, or Unload from 2.0.7022 through 2.0.7338.
//
// Value-tuple policy
// ------------------
// No C# value tuples ((T1, T2, ...) syntax) are used anywhere in this file.
// Every multi-value return uses a named private class or out-parameters so
// uMod's build server (which strips ValueTuple shims) can compile cleanly.
//
// Change log (full history in CHANGELOG.md)
// ------------------------------------------
// v5.3.8
// License changed from MIT to GPL-3.0 (LICENSE, manifest, .umod, header).
// Versions <= 5.3.7 remain MIT. No code changes.
//
// v5.3.7
// Adopted the DunganSoft Plugin Standard: canonical manifest/.umod schema,
// LICENSE rename, SECURITY/.editorconfig/.gitattributes, vendored conformance
// checker + standards CI, canonical release workflow. No code changes.
//
// v5.3.6
// Localization: added de/fr/pt translations (now 8 locales: en, es, ru, la,
// zh-CN, de, fr, pt). Removed 2 orphan Usage.UIReset.* lang keys.
//
// v5.3.5
// [Info] author set to uMod username "gjdunga" (approval requirement).
// COMPILE-FIX Built Different retyped ConsoleSystem.Arg.Args to
// Facepunch.StringView[]; console commands now materialise
// arguments to string[] (ToStringArgs helper / .ToString()).
// Added out-of-server compile-validation chain (build/, tools/, CI).
//
// v5.3.3
// Ownership transferred to Gabriel Dungan (DunganSoft Technologies).
// P1 Debug-overlay timer now only rewrites the CUI when the player's
// protection state actually changes, eliminating ~120 CUI rebuilds
// per minute per debug user. Last-state is tracked per userID.
// P2 _previewLastUsed dictionary is now cleared on player disconnect
// so its size cannot grow without bound over a long wipe.
// P3 Removed redundant Mathf.Max(0.1f, ...) in HandlePreview: the
// value is already clamped to [0.1, 10] by ValidateConfig.
// P4 Cached the protection-mask lookup in a static field so reloads do
// not re-run LayerMask.GetMask. (Negligible runtime, but cleaner.)
// Verified compatible with Oxide 2.0.7338.
//
// v5.3.2 Verified compatible with Oxide 2.0.7182 (Community Update 268).
// v5.3.1 S1-S5 security fixes; HashSet<ulong> authorizedPlayers; HitBuffer 4086.
// v5.2.0 / v5.1.0 -- see CHANGELOG.md for full history
// ============================================================================
using System;
using System.Collections.Generic;
using System.Globalization;
using Oxide.Game.Rust.Cui;
using Rust;
using UnityEngine;
namespace Oxide.Plugins
{
[Info("ModernNoCupboardDecay", "gjdunga", "6.0.1")]
[Description("Prevents decay within Tool Cupboard radius. Wipe-aware UI, team auth, debug tools. Oxide 2.0.7022+ verified against 2.0.7338.")]
public class ModernNoCupboardDecay : RustPlugin
{
// ====================================================================
// #region Fields
// ====================================================================
#region Fields
/// <summary>Loaded and validated configuration. Null only before OnServerInitialized.</summary>
private ConfigData _config;
/// <summary>True only after OnServerInitialized completes without error.</summary>
private bool _initialized;
/// <summary>Human-readable status shown by /mncd.</summary>
private string _statusMsg = "Plugin not yet initialized.";
/// <summary>Active wipe mode, set by DetectWipeModeFromTagsOrConfig.</summary>
private WipeMode _wipeMode = WipeMode.Manual;
/// <summary>Unity physics layer mask used for the overlap sphere query.</summary>
private int _protectionMask;
// --- Permission node constants ---------------------------------------
private const string PermAdmin = "modernnocupboarddecay.admin";
private const string PermDebug = "modernnocupboarddecay.debug";
private const string PermPreview = "modernnocupboarddecay.preview";
// --- CUI element name constants -------------------------------------
// These names must be unique per plugin instance. If you fork this
// plugin, rename these constants to avoid panel conflicts.
private const string UiWipe = "MNCD_WipeTimer";
private const string UiDebug = "MNCD_DebugOverlay";
// --- Physics overlap buffer -----------------------------------------
// SECURITY (C1 / v5.1.0): Instance field, not static. A static buffer
// is shared across all calls on the same AppDomain; concurrent decay
// hooks and debug-overlay timers in the same Oxide frame would corrupt
// each other's result counts.
//
// Size 4086: OverlapSphereNonAlloc silently truncates at buffer length.
// Mask is intentionally narrow ("Deployed" only): BuildingPrivlidge lives
// on the Deployed layer. Including Construction/Trigger layers adds
// thousands of irrelevant colliders per dense base, saturating the buffer
// without contributing a single TC match.
// A server-console warning is emitted when the limit is reached.
private readonly Collider[] _hitBuffer = new Collider[4086];
/// <summary>Set of userIDs that currently have the debug overlay active.</summary>
private readonly HashSet<ulong> _debugUsers = new HashSet<ulong>();
/// <summary>Per-player Oxide timers driving the debug overlay refresh.</summary>
private readonly Dictionary<ulong, Timer> _debugTimers = new Dictionary<ulong, Timer>();
/// <summary>
/// Last protection state rendered into the debug-overlay CUI for each
/// user. PERF (P1 / v5.3.3): the timer ticks at 2 Hz, but the visible
/// state changes only when the player crosses a TC boundary. Storing
/// the last value lets UpdateDebugOverlayUI skip the AddUi/DestroyUi
/// round-trip on the ~95% of ticks where nothing has changed.
/// Sentinel byte: 0 = unknown, 1 = protected, 2 = not protected.
/// </summary>
private readonly Dictionary<ulong, byte> _debugLastState = new Dictionary<ulong, byte>();
/// <summary>
/// Per-player Unix timestamp of the last /mncdpreview call.
/// Enforces PreviewCooldownSeconds per player. (SECURITY C3 / v5.1.0.)
/// </summary>
private readonly Dictionary<ulong, long> _previewLastUsed = new Dictionary<ulong, long>();
/// <summary>Wipe schedule modes supported by the plugin.</summary>
private enum WipeMode { Manual, Weekly, BiWeekly, Monthly, CustomDays }
#endregion
// ====================================================================
// #region Configuration
// ====================================================================
#region Configuration
/// <summary>
/// Strongly-typed configuration object serialized to/from
/// oxide/config/ModernNoCupboardDecay.json by Oxide.
///
/// All fields carry safe defaults. ValidateConfig clamps numeric
/// ranges after deserialization to prevent hand-edited extremes.
///
/// NOTE: No C# value tuples are used anywhere in this class or in
/// any nested class. uMod's build server does not ship the
/// System.ValueTuple shim, so tuple syntax must never be used.
/// </summary>
private class ConfigData
{
// --- Protection behaviour ---------------------------------------
/// <summary>
/// When false (default), any entity near ANY TC is protected.
/// When true, an entity's OwnerID must be authorized on a nearby TC
/// (or be a Rust-team member of an authed player if
/// TeamAwareProtection = true).
/// </summary>
public bool CheckAuth { get; set; } = false;
/// <summary>
/// Only meaningful when CheckAuth = true.
/// When true, teammates of a TC-authorized player are also protected
/// via RelationshipManager team membership.
/// </summary>
public bool TeamAwareProtection { get; set; } = true;
/// <summary>
/// Radius in meters around each TC that defines the protection bubble.
/// Clamped to [1, 500] by ValidateConfig. Default 30 m matches
/// Rust's built-in TC upkeep radius.
/// </summary>
public float EntityRadius { get; set; } = 30f;
// --- Wipe schedule ----------------------------------------------
/// <summary>
/// When true, attempt to read wipe mode from ConVar.Server.tags
/// ("weekly", "biweekly", "monthly", or "Nd" patterns).
/// WipeModeOverride is ignored when a tag is found.
/// </summary>
public bool AutoDetectWipeFromTags { get; set; } = true;
/// <summary>
/// Fallback wipe mode used when AutoDetectWipeFromTags = false or
/// no matching tag is found. Valid values: Manual, Weekly, BiWeekly,
/// Monthly, or a day-count string like "5d".
/// Sanitised to printable ASCII, max 64 chars, before storage.
/// </summary>
public string WipeModeOverride { get; set; } = "Manual";
/// <summary>
/// Day count used when WipeMode resolves to CustomDays.
/// Set automatically by tag detection or /mncdset wipemode.
/// Clamped to [0, 365] by ValidateConfig.
/// </summary>
public int CustomWipeDays { get; set; } = 0;
/// <summary>
/// Unix timestamp (UTC seconds) when the current wipe started.
/// Set automatically by OnNewSave; can be reset with
/// /mncdset wipestartnow.
/// </summary>
public long WipeStartUnixTime { get; set; } = 0;
// --- Wipe-timer UI panel ----------------------------------------
/// <summary>When true, shows the wipe-timer CUI panel when a player opens a TC.</summary>
public bool EnableTcWipeUI { get; set; } = true;
/// <summary>
/// CUI background color in "R G B A" format (floats 0-1).
/// Validated as four space-separated floats in ValidateConfig.
/// </summary>
public string UiBackgroundColor { get; set; } = "0.05 0.05 0.05 0.85";
/// <summary>
/// CUI text color in "R G B A" format (floats 0-1).
/// Validated as four space-separated floats in ValidateConfig.
/// </summary>
public string UiTextColor { get; set; } = "0.9 0.9 0.9 1.0";
/// <summary>
/// Normalized screen-space anchor min for the wipe-timer panel.
/// Format: "X Y" with both values in [0, 1].
/// </summary>
public string UiAnchorMin { get; set; } = "0.4 0.92";
/// <summary>
/// Normalized screen-space anchor max for the wipe-timer panel.
/// Format: "X Y" with both values in [0, 1].
/// </summary>
public string UiAnchorMax { get; set; } = "0.6 0.98";
// --- Preview command --------------------------------------------
/// <summary>
/// When false (default), every player can use /mncdpreview.
/// When true, the modernnocupboarddecay.preview permission or
/// IsAdmin is required.
/// </summary>
public bool PreviewRequiresPermission { get; set; } = false;
/// <summary>
/// Minimum seconds between /mncdpreview calls per player.
/// Prevents repeated full serverEntities scans.
/// Clamped to [5, 300] by ValidateConfig.
/// </summary>
public float PreviewCooldownSeconds { get; set; } = 15f;
/// <summary>How long (seconds) the preview spheres remain visible. Clamped [1, 300].</summary>
public float PreviewRingDuration { get; set; } = 30f;
/// <summary>Multiplies EntityRadius for the visual preview ring only. Clamped [0.1, 10].</summary>
public float PreviewRingRadiusMultiplier { get; set; } = 1.0f;
}
// --- Config lifecycle -----------------------------------------------
/// <summary>
/// Called by Oxide when no config file exists.
/// Writes a new file populated with ConfigData defaults.
/// </summary>
protected override void LoadDefaultConfig()
{
PrintWarning("Creating new configuration file with default values.");
_config = new ConfigData();
}
/// <summary>
/// Reads the config file, falls back to defaults on failure, validates
/// all fields, and writes the corrected config back to disk.
/// </summary>
private void LoadConfigData()
{
try
{
_config = Config.ReadObject<ConfigData>();
if (_config == null)
throw new Exception("Deserialized to null.");
}
catch (Exception e)
{
PrintWarning($"Config load error - using defaults: {e.Message}");
_config = new ConfigData();
}
ValidateConfig();
SaveConfig(_config);
_statusMsg = "Configuration loaded.";
}
/// <summary>
/// Clamps numeric ranges and validates string fields used in CUI JSON.
///
/// SECURITY (M1): UiBackgroundColor and UiTextColor are validated as
/// four space-separated floats in [0, 1]. Invalid values fall back to
/// safe defaults so CUI JSON is never built from arbitrary text.
///
/// SECURITY (H3): WipeModeOverride is sanitised before storage.
/// </summary>
private void ValidateConfig()
{
_config.EntityRadius = Mathf.Clamp(_config.EntityRadius, 1f, 500f);
_config.CustomWipeDays = Mathf.Clamp(_config.CustomWipeDays, 0, 365);
_config.PreviewRingDuration = Mathf.Clamp(_config.PreviewRingDuration, 1f, 300f);
_config.PreviewRingRadiusMultiplier = Mathf.Clamp(_config.PreviewRingRadiusMultiplier, 0.1f, 10f);
_config.PreviewCooldownSeconds = Mathf.Clamp(_config.PreviewCooldownSeconds, 5f, 300f);
if (!IsValidCuiColor(_config.UiBackgroundColor))
{
PrintWarning($"UiBackgroundColor '{_config.UiBackgroundColor}' is invalid; resetting to default.");
_config.UiBackgroundColor = "0.05 0.05 0.05 0.85";
}
if (!IsValidCuiColor(_config.UiTextColor))
{
PrintWarning($"UiTextColor '{_config.UiTextColor}' is invalid; resetting to default.");
_config.UiTextColor = "0.9 0.9 0.9 1.0";
}
_config.WipeModeOverride = SanitiseWipeModeString(_config.WipeModeOverride);
float minX, minY, maxX, maxY;
if (!TryParseAnchor(_config.UiAnchorMin, out minX, out minY))
{ minX = 0.4f; minY = 0.92f; }
if (!TryParseAnchor(_config.UiAnchorMax, out maxX, out maxY))
{ maxX = 0.6f; maxY = 0.98f; }
WriteAnchorsSafe(minX, minY, maxX, maxY);
}
/// <summary>Writes config to disk.</summary>
private void SaveConfig(ConfigData cfg) => Config.WriteObject(cfg, true);
#endregion
// ====================================================================
// #region Localization
// ====================================================================
#region Localization
/// <summary>
/// Registers all English (fallback) localisation keys.
/// Per-locale overrides live in oxide/lang/{locale}/ModernNoCupboardDecay.json.
/// </summary>
private void LoadDefaultMessages()
{
lang.RegisterMessages(new Dictionary<string, string>
{
// Status
["Status.Report"] =
"[{0}] v{1} | State: {2} | Radius: {3}m | CheckAuth: {4} | TeamAware: {5} | " +
"AutoDetect: {6} | WipeMode: {7} (Override: {8}) | WipeRemaining: {9} | Status: {10}",
// Errors
["Error.NoPermission"] = "[MNCD] You do not have permission to change settings.",
["Error.NoDebugPermission"] = "[MNCD] You do not have permission to use the debug overlay.",
["Error.ConfigOption"] = "[MNCD] Unknown option. Valid options: checkauth, teamaware, radius, autodetect, wipemode, wipestartnow.",
["Error.ConfigApply"] = "[MNCD] Error applying '{0}': {1}",
["Error.CheckAuthValue"] = "[MNCD] checkauth requires true/false.",
["Error.TeamAwareValue"] = "[MNCD] teamaware requires true/false.",
["Error.AutoDetectValue"] = "[MNCD] autodetect requires true/false.",
["Error.RadiusValue"] = "[MNCD] radius requires a positive number in meters (1-500).",
["Error.WipeModeValue"] = "[MNCD] wipemode requires: Manual, Weekly, BiWeekly, Monthly, or a day count like 5d.",
["Error.BoolExpected"] = "[MNCD] {0} requires true or false.",
// Preview cooldown (SECURITY S3 / v5.3.1: was missing from lang JSON files)
["Preview.Cooldown"] = "[MNCD] Please wait {0} more seconds before using /mncdpreview again.",
// Usage
["Usage.MncdSet.Chat"] = "[MNCD] Usage: /mncdset <option> <value>. Options: checkauth, teamaware, radius, autodetect, wipemode, wipestartnow.",
["Usage.MncdSet.Console"] = "[MNCD] Usage: mncd.set <option> <value>. Options: checkauth, teamaware, radius, autodetect, wipemode, wipestartnow.",
["Usage.UIAdd.Chat"] = "[MNCD] Usage: /mncduiadd <deltaX> <deltaY> (normalized 0..1 offsets).",
["Usage.UIAdd.Console"] = "[MNCD] Usage: mncd.uiadd <deltaX> <deltaY> (normalized 0..1 offsets).",
// Config confirmations
["Config.CheckAuth.Set"] = "[MNCD] CheckAuth is now {0}.",
["Config.TeamAware.Set"] = "[MNCD] TeamAwareProtection is now {0}.",
["Config.Radius.Set"] = "[MNCD] EntityRadius (TC decay bubble) is now {0} meters.",
["Config.AutoDetect.Set"] = "[MNCD] AutoDetectWipeFromTags is now {0}. Active WipeMode: {1}.",
["Config.WipeMode.Set"] = "[MNCD] WipeModeOverride set to '{0}'. Active WipeMode: {1}. AutoDetect disabled.",
["Config.WipeStartNow.Set"] = "[MNCD] WipeStartUnixTime set to now. WipeMode: {0}. Remaining: {1}.",
["Config.UIReset.Set"] = "[MNCD] UI anchors reset to default. Reopen a TC to see the new position.",
["Config.UIAdd.Set"] = "[MNCD] UI anchors shifted by ({0}, {1}). New Min({2}, {3}) Max({4}, {5}).",
// TC loot messages (shown when UI is disabled)
["TcLoot.NoRemaining"] = "[MNCD] This TC's radius is fully protected from decay.\nWipe schedule: {0}. Wipe end not calculated (manual or missing data).",
["TcLoot.WithRemaining"] = "[MNCD] This TC's radius is fully protected from decay.\nWipe schedule: {0}. Wipe ends in: {1}.",
// Wipe-timer UI text
["UI.WipeTitle"] = "ModernNoCupboardDecay",
["UI.WipeLine"] = "Wipe: {0} - {1} remaining",
["UI.WipeExtra"] = "Decay disabled within this Tool Cupboard radius.",
// UI Anchor commands
["UIAnchor.Usage.Chat"] = "[MNCD] Usage: /mncdui <minX> <minY> <maxX> <maxY>",
["UIAnchor.Usage.Console"] = "[MNCD] Usage: mncd.ui <minX> <minY> <maxX> <maxY>",
["UIAnchor.Set"] = "[MNCD] UI Anchor set: Min({0}, {1}) Max({2}, {3})",
["UIAnchor.Error.Numeric"] = "[MNCD] All 4 values must be numeric.",
["UIAnchor.Error.Order"] = "[MNCD] minX < maxX and minY < maxY is required.",
// Debug overlay
["Debug.Enabled"] = "[MNCD] Debug overlay enabled. Shows whether you are inside a protection zone.",
["Debug.Disabled"] = "[MNCD] Debug overlay disabled.",
["Debug.UI.Protected"] = "MNCD: Protected",
["Debug.UI.NotProtected"] = "MNCD: Not Protected",
// Preview
["Preview.NoTC"] = "[MNCD] No Tool Cupboards found. Nothing to preview.",
["Preview.Drawn"] = "[MNCD] Drawing TC protection rings for {0} cupboards for {1}s (radius {2}m).",
["Preview.NoPerm"] = "[MNCD] You do not have permission to use /mncdpreview.",
// Help
["Help.Header"] = "[MNCD] ModernNoCupboardDecay v{0}",
["Help.General"] =
"ModernNoCupboardDecay prevents decay for entities within TC radius and shows wipe info.\n" +
"Basic commands:\n" +
" /mncd - Show plugin status.\n" +
" /mncdhelp [topic] - Help topics: basic, ui, set, debug, preview, wipe.\n" +
" /mncdpreview - Draw TC protection rings.\n" +
" /mncddebug - Toggle protection-status overlay.\n" +
"Admin commands:\n" +
" /mncdset - Live config changes.\n" +
" /mncdui, /mncduiadd - Move the wipe UI panel.\n" +
" /mncdresetui - Reset wipe UI to default position.",
["Help.UnknownTopic"] = "[MNCD] Unknown topic '{0}'. Valid topics: basic, ui, set, debug, preview, wipe.",
["Help.Topic.basic"] =
"MNCD basics:\n" +
" All entities within the configured radius of any TC are protected from decay.\n" +
" Optional CheckAuth (default false): only entities owned by a TC-authed player\n" +
" (or their Rust team, if TeamAwareProtection = true) are protected.\n" +
" The TC upkeep display shows time-until-wipe in the MNCD panel.",
["Help.Topic.ui"] =
"UI positioning (admin only):\n" +
" /mncdui <minX> <minY> <maxX> <maxY> - Set panel anchors (0..1 each).\n" +
" /mncduiadd <dx> <dy> - Nudge panel position.\n" +
" /mncdresetui - Reset to default top-center.\n" +
"Reopen the TC after changing the panel position.",
["Help.Topic.set"] =
"Live configuration (/mncdset or mncd.set):\n" +
" checkauth <true|false> - Restrict to TC-authed owners/teams.\n" +
" teamaware <true|false> - Include Rust team members (needs checkauth = true).\n" +
" radius <meters> - TC protection radius (1-500).\n" +
" autodetect <true|false> - Auto-read wipe mode from server.tags.\n" +
" wipemode <mode> - Manual|Weekly|BiWeekly|Monthly|Nd (e.g. 5d).\n" +
" wipestartnow - Reset wipe start time to now.",
["Help.Topic.debug"] =
"Debug overlay (/mncddebug or mncd.debug):\n" +
" Toggles a small 'MNCD: Protected / Not Protected' banner.\n" +
" Updates every 0.5 s. Requires admin or modernnocupboarddecay.debug perm.\n" +
" Automatically stops on disconnect.",
["Help.Topic.preview"] =
"TC preview rings (/mncdpreview or mncd.preview):\n" +
" Draws client-side spheres showing every TC's protection bubble.\n" +
" Requires PreviewRequiresPermission = false (default) or the preview perm.\n" +
" Subject to a per-player cooldown (PreviewCooldownSeconds, default 15 s).\n" +
"Config keys: PreviewRequiresPermission, PreviewCooldownSeconds,\n" +
" PreviewRingDuration, PreviewRingRadiusMultiplier.",
["Help.Topic.wipe"] =
"Wipe modes:\n" +
" AutoDetectWipeFromTags = true: reads server.tags for weekly/biweekly/monthly/Nd.\n" +
" AutoDetectWipeFromTags = false: uses WipeModeOverride.\n" +
" Modes: Manual (no timer), Weekly (7d), BiWeekly (14d), Monthly (30d), CustomDays.\n" +
" WipeStartUnixTime is set by OnNewSave or /mncdset wipestartnow.",
}, this);
}
/// <summary>
/// Retrieves a localised message, formats it with args, and returns
/// the raw key on FormatException so a bad translation never crashes
/// the plugin.
/// </summary>
private string Msg(string key, string userId = null, params object[] args)
{
string msg = lang.GetMessage(key, this, userId);
if (args == null || args.Length == 0) return msg;
try { return string.Format(msg, args); }
catch (FormatException) { return msg; }
}
#endregion
// ====================================================================
// #region Lifecycle
// ====================================================================
#region Lifecycle
/// <summary>
/// Called by Oxide before OnServerInitialized.
/// Registers permissions and language strings; does not access game state.
/// </summary>
private void Init()
{
_initialized = false;
_statusMsg = "Initializing...";
permission.RegisterPermission(PermAdmin, this);
permission.RegisterPermission(PermDebug, this);
permission.RegisterPermission(PermPreview, this);
LoadDefaultMessages();
}
/// <summary>
/// Called by Oxide after the server has fully started.
/// Loads config, detects wipe mode, and sets _initialized = true.
/// Any exception here leaves the plugin in a safe disabled state;
/// OnEntityTakeDamage returns null immediately so vanilla decay is
/// unaffected.
/// </summary>
private void OnServerInitialized()
{
try
{
_protectionMask = LayerMask.GetMask("Deployed");
LoadConfigData();
DetectWipeModeFromTagsOrConfig();
if (_config.WipeStartUnixTime <= 0)
{
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
_config.WipeStartUnixTime = now;
SaveConfig(_config);
_statusMsg = $"Wipe start seeded to {DateTimeOffset.FromUnixTimeSeconds(now):u}.";
}
_initialized = true;
if (_statusMsg.StartsWith("Initializing", StringComparison.OrdinalIgnoreCase))
_statusMsg = "Initialized successfully.";
PrintStartupSummary();
}
catch (Exception ex)
{
_initialized = false;
_statusMsg = $"Init failed: {ex.Message}";
PrintError(_statusMsg);
}
}
/// <summary>
/// Called by Oxide on plugin unload or hot-reload.
/// Destroys all timers and removes all CUI panels for connected players.
/// </summary>
private void Unload()
{
foreach (var kvp in _debugTimers)
kvp.Value?.Destroy();
_debugTimers.Clear();
_debugUsers.Clear();
_debugLastState.Clear();
_previewLastUsed.Clear();
foreach (var player in BasePlayer.activePlayerList)
{
if (player == null) continue;
CuiHelper.DestroyUi(player, UiWipe);
CuiHelper.DestroyUi(player, UiDebug);
}
}
/// <summary>
/// Called by Oxide when the server saves a new map (wipe event).
/// Resets WipeStartUnixTime to now so the wipe-timer UI counts down
/// from the correct baseline.
/// </summary>
private void OnNewSave(string filename)
{
if (_config == null) return;
long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
_config.WipeStartUnixTime = now;
SaveConfig(_config);
_statusMsg = $"Wipe detected ('{filename}'); start time reset to {DateTimeOffset.FromUnixTimeSeconds(now):u}.";
}
#endregion
// ====================================================================
// #region Wipe Detection
// ====================================================================
#region Wipe Detection
/// <summary>
/// Sets _wipeMode from ConVar.Server.tags (when AutoDetectWipeFromTags
/// is true) or falls back to ParseWipeMode(_config.WipeModeOverride).
///
/// SECURITY (M4 / v5.1.0): "biweekly" / "bi-weekly" are tested BEFORE
/// "weekly" so the shorter pattern cannot shadow the longer one.
/// </summary>
private void DetectWipeModeFromTagsOrConfig()
{
WipeMode detected = WipeMode.Manual;
if (_config.AutoDetectWipeFromTags)
{
try
{
string tags = ConVar.Server.tags ?? string.Empty;
if (!string.IsNullOrEmpty(tags))
{
string lower = tags.ToLowerInvariant();
if (lower.Contains("biweekly") || lower.Contains("bi-weekly"))
detected = WipeMode.BiWeekly;
else if (lower.Contains("weekly"))
detected = WipeMode.Weekly;
else if (lower.Contains("monthly"))
detected = WipeMode.Monthly;
else
{
int customDays = TryExtractDaysFromTags(lower);
if (customDays > 0)
{
detected = WipeMode.CustomDays;
_config.CustomWipeDays = customDays;
}
}
}
}
catch (Exception e)
{
PrintWarning($"Could not read server tags: {e.Message}");
}
}
if (detected != WipeMode.Manual)
{
_wipeMode = detected;
_statusMsg = $"Wipe mode auto-detected: {_wipeMode}" +
(_wipeMode == WipeMode.CustomDays && _config.CustomWipeDays > 0
? $" ({_config.CustomWipeDays}d)." : ".");
return;
}
_wipeMode = ParseWipeMode(_config.WipeModeOverride);
_statusMsg = $"Wipe mode from config override: {_wipeMode}" +
(_wipeMode == WipeMode.CustomDays && _config.CustomWipeDays > 0
? $" ({_config.CustomWipeDays}d)." : ".");
}
/// <summary>
/// Scans comma-separated tag tokens for a "Nd" pattern (e.g. "5d",
/// "5day", "5days") and returns the day count if in [2, 60], else 0.
/// </summary>
private int TryExtractDaysFromTags(string lowerTags)
{
if (string.IsNullOrEmpty(lowerTags)) return 0;
foreach (string piece in lowerTags.Split(','))
{
foreach (string token in piece.Trim().Split(' ', '-', '_'))
{
string t = token.Trim();
if (string.IsNullOrEmpty(t)) continue;
if (t.EndsWith("days")) t = t.Substring(0, t.Length - 4);
else if (t.EndsWith("day")) t = t.Substring(0, t.Length - 3);
else if (t.EndsWith("d")) t = t.Substring(0, t.Length - 1);
int days;
if (int.TryParse(t, NumberStyles.Integer, CultureInfo.InvariantCulture, out days)
&& days >= 2 && days <= 60)
return days;
}
}
return 0;
}
/// <summary>
/// Converts a wipe-mode string to a WipeMode enum value.
/// Sets _config.CustomWipeDays when a numeric day-count is parsed.
/// Returns WipeMode.Manual for unrecognised input.
/// </summary>
private WipeMode ParseWipeMode(string mode)
{
if (string.IsNullOrEmpty(mode)) return WipeMode.Manual;
string lower = mode.Trim().ToLowerInvariant();
string numPart = lower;
if (numPart.EndsWith("days")) numPart = numPart.Substring(0, numPart.Length - 4);
else if (numPart.EndsWith("day")) numPart = numPart.Substring(0, numPart.Length - 3);
else if (numPart.EndsWith("d")) numPart = numPart.Substring(0, numPart.Length - 1);
int days;
if (int.TryParse(numPart, NumberStyles.Integer, CultureInfo.InvariantCulture, out days)
&& days > 0)
{
_config.CustomWipeDays = Mathf.Clamp(days, 1, 365);
return WipeMode.CustomDays;
}
switch (lower)
{
case "weekly": return WipeMode.Weekly;
case "biweekly":
case "bi-weekly": return WipeMode.BiWeekly;
case "monthly": return WipeMode.Monthly;
default: return WipeMode.Manual;
}
}
// --- Time helpers ---------------------------------------------------
/// <summary>Returns the wipe duration for the active mode, or TimeSpan.Zero for Manual.</summary>
private TimeSpan GetWipeDuration()
{
switch (_wipeMode)
{
case WipeMode.Weekly: return TimeSpan.FromDays(7);
case WipeMode.BiWeekly: return TimeSpan.FromDays(14);
case WipeMode.Monthly: return TimeSpan.FromDays(30);
case WipeMode.CustomDays:
return (_config != null && _config.CustomWipeDays > 0)
? TimeSpan.FromDays(_config.CustomWipeDays)
: TimeSpan.Zero;
default: return TimeSpan.Zero;
}
}
/// <summary>
/// Returns the Unix timestamp when the current wipe ends, or 0 if
/// the mode is Manual or WipeStartUnixTime has not been set.
/// </summary>
private long GetWipeEndUnixTime()
{
if (_config == null || _config.WipeStartUnixTime <= 0) return 0;
TimeSpan dur = GetWipeDuration();
return dur == TimeSpan.Zero ? 0 : _config.WipeStartUnixTime + (long)dur.TotalSeconds;
}
/// <summary>
/// Returns time remaining until wipe end, or null if indeterminate.
/// Returns TimeSpan.Zero if the wipe end has already passed.
/// </summary>
private TimeSpan? GetWipeTimeRemaining()
{
long wipeEnd = GetWipeEndUnixTime();
if (wipeEnd <= 0) return null;
long delta = wipeEnd - DateTimeOffset.UtcNow.ToUnixTimeSeconds();
return delta <= 0 ? TimeSpan.Zero : TimeSpan.FromSeconds(delta);
}
/// <summary>Formats a TimeSpan as "Xd Xh Xm" without seconds.</summary>
private static string FormatTimeSpan(TimeSpan ts)
=> $"{ts.Days}d {ts.Hours}h {ts.Minutes}m";
/// <summary>
/// Returns a human-readable wipe mode string, including day count
/// for CustomDays.
///
/// SECURITY (S2 / v5.3.1): Guards _config == null so a CustomDays
/// lookup cannot throw NullReferenceException if called before
/// OnServerInitialized completes.
/// </summary>
private string GetWipeModeDisplayString()
{
string mode = _wipeMode.ToString();
if (_wipeMode == WipeMode.CustomDays && _config != null && _config.CustomWipeDays > 0)
mode += $" ({_config.CustomWipeDays}d)";
return mode;
}
/// <summary>Prints a startup summary to the server console for operators.</summary>
private void PrintStartupSummary()
{
Puts("[ModernNoCupboardDecay] Startup summary:");
Puts($" Version: {Version}");
Puts($" CheckAuth: {_config.CheckAuth} | TeamAware: {_config.TeamAwareProtection}");
Puts($" EntityRadius: {_config.EntityRadius}m");
Puts($" AutoDetect: {_config.AutoDetectWipeFromTags} | WipeMode: {_wipeMode}");
Puts($" WipeStart: {(_config.WipeStartUnixTime > 0 ? DateTimeOffset.FromUnixTimeSeconds(_config.WipeStartUnixTime).ToString("u") : "unset")}");
TimeSpan? remaining = GetWipeTimeRemaining();
Puts(remaining.HasValue
? $" Wipe ends in: {FormatTimeSpan(remaining.Value)}"
: " Wipe end: N/A (manual mode or missing data).");
}
#endregion
// ====================================================================
// #region Core Decay Logic
// ====================================================================
#region Core Decay Logic
/// <summary>
/// Oxide hook: called whenever any entity takes any damage.
/// Intercepts Decay-type damage only. If the entity's position is
/// within range of a qualifying TC, the decay damage scale is set to
/// zero (Rust treats this as no damage for that tick).
///
/// Returns null in all cases to allow other plugins to continue
/// processing the hit.
/// </summary>
private object OnEntityTakeDamage(BaseCombatEntity entity, HitInfo info)
{
// SECURITY (M3): also guards _config == null explicitly.
if (!_initialized || _config == null || entity == null || info == null)
return null;
if (!info.damageTypes.Has(DamageType.Decay))
return null;
if (IsPositionProtected(entity.transform.position, GetOwnerId(entity)))
info.damageTypes.Scale(DamageType.Decay, 0f);
return null;
}
#endregion
// ====================================================================
// #region Protection Logic
// ====================================================================
#region Protection Logic
/// <summary>
/// Returns the OwnerID of the entity, or 0 for server-spawned entities.
/// </summary>
private static ulong GetOwnerId(BaseCombatEntity entity)
=> entity?.OwnerID ?? 0;
/// <summary>
/// Core protection query using Physics.OverlapSphereNonAlloc with a
/// pre-allocated instance-level buffer (zero GC allocation per call).
///
/// Algorithm:
/// 1. Find all colliders within EntityRadius of the position.
/// 2. Walk each collider's hierarchy for a BuildingPrivlidge component.
/// 3a. CheckAuth = false: any TC found means protected.
/// 3b. CheckAuth = true: ownerId must be authorized on the TC, or be a
/// team member of an authorized player (when TeamAwareProtection = true).
/// </summary>
private bool IsPositionProtected(Vector3 position, ulong ownerId)
{
if (_config == null) return false;
int count = Physics.OverlapSphereNonAlloc(
position, _config.EntityRadius, _hitBuffer, _protectionMask);
// SECURITY (M2): warn if results were truncated at buffer capacity.
if (count == _hitBuffer.Length)
PrintWarning(
$"[MNCD] HitBuffer capacity ({_hitBuffer.Length}) reached near {position}. " +
"Some TCs may have been missed. Consider increasing the buffer size in the source.");
if (!_config.CheckAuth)
{
for (int i = 0; i < count; i++)
{
if (_hitBuffer[i].GetComponentInParent<BuildingPrivlidge>() != null)
return true;
}
return false;
}
if (ownerId == 0) return false;
for (int i = 0; i < count; i++)
{
var priv = _hitBuffer[i].GetComponentInParent<BuildingPrivlidge>();
if (priv != null && IsOwnerAuthorizedOrTeammate(priv, ownerId))
return true;
}
return false;
}
/// <summary>
/// Returns true when ownerId is directly authorized on the TC, or is a
/// Rust team member of any authorized player (when TeamAwareProtection = true).
///
/// Uses ProtoBuf.PlayerNameID.userid for the correct access pattern on
/// Oxide v2.0.7022+ / Naval Update.
/// </summary>
private bool IsOwnerAuthorizedOrTeammate(BuildingPrivlidge priv, ulong ownerId)
{
if (priv == null || ownerId == 0) return false;
if (IsAuthorizedOnCupboard(priv, ownerId)) return true;
if (!_config.TeamAwareProtection) return false;
var rm = RelationshipManager.ServerInstance;
if (rm == null) return false;
var ownerTeam = rm.FindPlayersTeam(ownerId);
if (ownerTeam == null) return false;
if (priv.OwnerID != 0 && ownerTeam.members.Contains(priv.OwnerID))
return true;
var authList = priv.authorizedPlayers;
if (authList != null)
{
foreach (ulong authedId in authList)
{
if (ownerTeam.members.Contains(authedId))
return true;
}
}
return false;
}
/// <summary>
/// Iterates the TC's authorizedPlayers list and returns true if ownerId
/// is present. Uses a plain for-loop to avoid enumerator allocation.
/// </summary>
private static bool IsAuthorizedOnCupboard(BuildingPrivlidge priv, ulong ownerId)
{
var authList = priv.authorizedPlayers;
if (authList == null || authList.Count == 0) return false;
foreach (ulong authedId in authList)
{
if (authedId == ownerId) return true;
}
return false;
}
#endregion
// ====================================================================
// #region Permissions and Shared Helpers
// ====================================================================
#region Permissions and Shared Helpers
/// <summary>True when the player is a server admin or holds the admin permission node.</summary>
private bool HasAdminPerm(BasePlayer player)
=> player != null && (player.IsAdmin || permission.UserHasPermission(player.UserIDString, PermAdmin));
/// <summary>True when the player is a server admin or holds the debug permission node.</summary>
private bool HasDebugPerm(BasePlayer player)
=> player != null && (player.IsAdmin || permission.UserHasPermission(player.UserIDString, PermDebug));
/// <summary>
/// True when the player can use /mncdpreview.
/// When PreviewRequiresPermission = false, all players pass.
/// </summary>
private bool HasPreviewPerm(BasePlayer player)
{
if (player == null) return false;
if (!_config.PreviewRequiresPermission) return true;
return player.IsAdmin || permission.UserHasPermission(player.UserIDString, PermPreview);
}
/// <summary>
/// Parses common boolean representations.
/// Truthy: "true", "1", "yes", "on". Falsy: "false", "0", "no", "off".
/// </summary>
private static bool TryParseBool(string value, out bool result)
{
result = false;
if (string.IsNullOrEmpty(value)) return false;
switch (value.Trim().ToLowerInvariant())
{
case "true": case "1": case "yes": case "on": result = true; return true;
case "false": case "0": case "no": case "off": result = false; return true;
default: return false;
}
}
/// <summary>Culture-invariant float parser.</summary>
private static bool TryParseFloat(string s, out float f)
=> float.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out f);
/// <summary>
/// Parses a CUI anchor string of the form "X Y" into two floats.
/// Returns false if the format is wrong or either value is non-numeric.
/// </summary>
private static bool TryParseAnchor(string anchor, out float x, out float y)
{
x = y = 0f;
if (string.IsNullOrEmpty(anchor)) return false;
string[] parts = anchor.Trim().Split(' ');
return parts.Length == 2
&& TryParseFloat(parts[0], out x)
// ... [Code preview truncated due to length. Full source in attached file] ...