Иконка ресурса

FREE ModernNoCupboardDecay : 6.0.1 6.0.1

Нет прав для скачивания
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] ...

Автор
15FPS
Скачиваний
0
Просмотры
1
Первый выпуск
Обновление
Оценка
0.00 звёзд 0 оценок
Сверху