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

FREE BottomlessWater : 4.0.0 4.0.0

Нет прав для скачивания
C#:
// BottomlessWater - Oxide/uMod plugin for Rust (Facepunch)
// Provides infinite-water behaviour for owned liquid containers.
// Repository: https://github.com/gjdunga/bottomlesswater
// Author: gjdunga (Gabriel Dungan, DunganSoft Technologies)
// License: GPL-3.0
// Compatibility: Oxide 2.0.7022+ | Updated for Oxide 2.0.7423+ (Rust Community Update 270+)

using System;
using System.Collections.Generic;
using Oxide.Core;
using UnityEngine;

namespace Oxide.Plugins
{
    [Info("Bottomless Water", "gjdunga", "4.0.0")]
    [Description("Infinite water behaviour for owned liquid containers with per-player toggles, admin controls, security hardening, and verbose logging.")]
    public class BottomlessWater : RustPlugin
    {
        // ─────────────────────────────────────────────────────────────────────
        // Constants
        // ─────────────────────────────────────────────────────────────────────

        /// <summary>Name used for the Oxide data file (no extension).</summary>
        private const string DataFileName = "BottomlessWaterData";

        /// <summary>Name used for the plugin log file (no extension).</summary>
        private const string LogFile = "BottomlessWater";

        /// <summary>Permission required for a player to use /bw commands and receive infinite water.</summary>
        private const string PermUse = "bottomlesswater.use";

        /// <summary>Permission required to execute admin console commands targeting other players.</summary>
        private const string PermAdmin = "bottomlesswater.admin";

        /// <summary>
        /// Maximum number of characters accepted from the first chat command argument.
        /// Prevents unnecessarily long strings reaching switch/log paths.
        /// </summary>
        private const int MaxArgLength = 32;

        // ─────────────────────────────────────────────────────────────────────
        // PlayerState class
        //
        // Replaces the raw bool used in earlier versions. Using a dedicated class
        // rather than a value tuple avoids ValueTuple assembly dependencies that the
        // uMod build server may not resolve, and provides a stable serialisation
        // boundary for future fields (e.g. per-player quota, last-toggle time).
        // ─────────────────────────────────────────────────────────────────────

        /// <summary>
        /// Persistent per-player state stored in <see cref="_playerStates"/>.
        ///
        /// Design note: this is intentionally a plain class (reference type) rather than
        /// a struct or value tuple. uMod's build server historically rejects
        /// System.ValueTuple usages; plain private classes compile cleanly across all
        /// supported target frameworks.
        /// </summary>
        private class PlayerState
        {
            /// <summary>
            /// True when the player has infinite water enabled; false when explicitly
            /// disabled. Seeded from PluginConfig.EnableByDefault on first encounter.
            /// </summary>
            public bool Enabled;

            /// <summary>Parameterless constructor required for JSON deserialisation.</summary>
            public PlayerState() { }

            /// <summary>Convenience constructor.</summary>
            public PlayerState(bool enabled) { Enabled = enabled; }
        }

        // ─────────────────────────────────────────────────────────────────────
        // Fields
        // ─────────────────────────────────────────────────────────────────────

        private PluginConfig _config;
        private StoredData   _storedData;

        /// <summary>
        /// Per-player infinite-water state keyed by SteamID64.
        /// Each value is a PlayerState class instance (not a raw bool or value tuple)
        /// to satisfy uMod build-server compilation requirements.
        /// </summary>
        private readonly Dictionary<ulong, PlayerState> _playerStates = new Dictionary<ulong, PlayerState>();

        /// <summary>
        /// Tracks the realtime timestamp of the most recent accepted toggle per player.
        /// Used to enforce PluginConfig.ChatCooldownSeconds between successive
        /// on/off/toggle actions.
        /// </summary>
        private readonly Dictionary<ulong, float> _toggleCooldowns = new Dictionary<ulong, float>();

        /// <summary>
        /// Sliding 60-second window of accepted command timestamps per player.
        /// Used to enforce PluginConfig.RateLimitMaxPerMinute.
        /// </summary>
        private readonly Dictionary<ulong, List<float>> _rateLimitWindows = new Dictionary<ulong, List<float>>();

        /// <summary>All tracked LiquidContainer entities currently on the server.</summary>
        private readonly HashSet<LiquidContainer> _liquidContainers = new HashSet<LiquidContainer>();

        /// <summary>
        /// Snapshot buffer filled at the start of each tick to avoid mutating
        /// _liquidContainers while iterating.
        /// </summary>
        private readonly List<LiquidContainer> _tickBuffer = new List<LiquidContainer>();

        /// <summary>
        /// Per-tick cache of ownerIds whose PermUse permission has already been verified
        /// this tick. Avoids repeated string allocations and hash lookups when multiple
        /// containers share the same owner.
        /// </summary>
        private readonly HashSet<ulong> _tickPermittedOwners = new HashSet<ulong>();

        /// <summary>Resolved short-prefab whitelist from config. Empty means all allowed.</summary>
        private HashSet<string> _whitelistShortPrefabNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

        /// <summary>Resolved short-prefab exclusion list from config.</summary>
        private HashSet<string> _excludeShortPrefabNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

        private Timer _tickTimer;
        private Timer _saveTimer;
        private bool  _dirty;

        /// <summary>
        /// Monotonically increasing tick counter used to round-robin the container
        /// workload across PluginConfig.TickBucketCount sub-slices. See DoTick.
        /// </summary>
        private uint _tickCounter;

        /// <summary>
        /// Wall-clock timestamp (Time.realtimeSinceStartup) at which the next
        /// rate-limit sweep should run. The sweep prunes empty/expired windows
        /// from _rateLimitWindows so sleeping players do not retain entries
        /// indefinitely across long uptimes.
        /// </summary>
        private float _nextRateLimitSweepTime;

        /// <summary>
        /// Seconds between rate-limit dictionary sweeps. The window itself is
        /// 60s, so a 60s sweep cadence keeps stale entries bounded to roughly
        /// one window's worth of staleness.
        /// </summary>
        private const float RateLimitSweepIntervalSeconds = 60f;

        /// <summary>
        /// Cached ItemDefinition for "water"; resolved once in Init.
        /// Used in FillLiquidItems to restrict filling to water-type items only,
        /// preventing unintended top-up of salt water or other liquids.
        /// </summary>
        private ItemDefinition _waterDefinition;

        // ─────────────────────────────────────────────────────────────────────
        // Configuration
        // ─────────────────────────────────────────────────────────────────────

        /// <summary>Serialised configuration for BottomlessWater.</summary>
        private class PluginConfig
        {
            /// <summary>Interval in seconds between fill ticks. Minimum clamped to 0.25 s.</summary>
            public float TickSeconds = 1f;

            /// <summary>Maximum water units added to a single item stack per tick. Clamped to >= 1.</summary>
            public int MaxAddPerTick = 1000;

            /// <summary>Whether to process LiquidContainer entities at all.</summary>
            public bool AffectLiquidContainers = true;

            /// <summary>
            /// Default on/off state written the first time an unknown ownerId is
            /// encountered during a tick.
            /// </summary>
            public bool EnableByDefault = true;

            /// <summary>
            /// If true, grants PermUse to Oxide's "default" group on load so all
            /// authenticated players inherit it immediately. Defaults to false so the
            /// plugin does not alter permission state out of the box; admins opt in by
            /// setting this true or granting bottomlesswater.use to a group/user.
            /// </summary>
            public bool AutoGrantUseToDefaultGroup = false;

            /// <summary>
            /// Optional whitelist. If non-empty, ONLY containers whose ShortPrefabName
            /// appears here are processed. Comparisons are case-insensitive.
            /// </summary>
            public List<string> WhiteListShortPrefabNames = new List<string>();

            /// <summary>
            /// Optional exclusion list. Containers whose ShortPrefabName appears here are
            /// skipped. Ignored when WhiteListShortPrefabNames is non-empty.
            /// </summary>
            public List<string> ExcludeShortPrefabNames = new List<string>();

            /// <summary>
            /// Minimum seconds a player must wait between successive on/off/toggle actions.
            /// Clamped to >= 0.
            /// </summary>
            public float ChatCooldownSeconds = 2f;

            /// <summary>
            /// Maximum on/off/toggle actions a player may perform within any 60-second
            /// window. Clamped to >= 1.
            /// </summary>
            public int RateLimitMaxPerMinute = 5;

            /// <summary>
            /// If true, create a new water item in completely empty containers owned by
            /// eligible players (subject to MaxAddPerTick and the item's stackable cap).
            /// </summary>
            public bool FillEmptyContainers = false;

            /// <summary>
            /// If true, wipe all stored player states when the server detects a map wipe
            /// via OnNewSave.
            /// </summary>
            public bool ClearDataOnWipe = false;

            /// <summary>
            /// Debounce delay in seconds before a pending dirty-state write is flushed.
            /// Clamped to >= 0.1 s.
            /// </summary>
            public float SaveDebounceSeconds = 2f;

            /// <summary>
            /// Number of round-robin sub-slices to spread the tracked container workload
            /// across. Each tick processes only the slice whose index equals
            /// (tickCounter % TickBucketCount); a given container is therefore visited
            /// once every (TickSeconds * TickBucketCount) seconds.
            ///
            /// Default 1 preserves pre-3.4.0 behaviour (every container every tick).
            /// Raise to 2, 4, etc. on busy servers with hundreds of containers to
            /// amortise tick cost. Increase MaxAddPerTick proportionally if you want
            /// the same per-container fill rate. Clamped to >= 1.
            /// </summary>
            public int TickBucketCount = 1;
        }

        /// <summary>
        /// On-disk data model. Player states are keyed by SteamID64 string for JSON
        /// compatibility; the value is a plain bool to keep the file human-readable.
        /// The bool is wrapped in PlayerState only in memory.
        /// </summary>
        private class StoredData
        {
            public Dictionary<string, bool> PlayerStates = new Dictionary<string, bool>();
        }

        /// <inheritdoc/>
        protected override void LoadDefaultConfig()
        {
            _config = new PluginConfig();
            SaveConfig();
        }

        /// <inheritdoc/>
        protected override void LoadConfig()
        {
            base.LoadConfig();
            try
            {
                _config = Config.ReadObject<PluginConfig>();
                if (_config == null)
                    throw new Exception("Config was null after deserialisation.");
            }
            catch
            {
                PrintWarning("Invalid configuration detected; regenerating defaults.");
                LoadDefaultConfig();
            }

            _config.TickSeconds           = Mathf.Max(0.25f, _config.TickSeconds);
            _config.MaxAddPerTick         = Math.Max(1, _config.MaxAddPerTick);
            _config.ChatCooldownSeconds   = Mathf.Max(0f, _config.ChatCooldownSeconds);
            _config.RateLimitMaxPerMinute = Math.Max(1, _config.RateLimitMaxPerMinute);
            _config.SaveDebounceSeconds   = Mathf.Max(0.1f, _config.SaveDebounceSeconds);
            _config.TickBucketCount       = Math.Max(1, _config.TickBucketCount);

            SaveConfig();
            RebuildPrefabSets();
        }

        /// <inheritdoc/>
        protected override void SaveConfig() => Config.WriteObject(_config, true);

        /// <summary>
        /// Rebuilds the in-memory HashSets for whitelist/exclusion from config.
        /// Called after every config load or hot-reload.
        /// </summary>
        private void RebuildPrefabSets()
        {
            _whitelistShortPrefabNames = BuildPrefabSet(_config.WhiteListShortPrefabNames);
            _excludeShortPrefabNames   = BuildPrefabSet(_config.ExcludeShortPrefabNames);
        }

        /// <summary>
        /// Converts a List of raw prefab name strings into a trimmed, case-insensitive
        /// HashSet, ignoring null/whitespace entries.
        /// </summary>
        private static HashSet<string> BuildPrefabSet(List<string> values)
        {
            var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
            if (values == null) return set;
            foreach (var entry in values)
                if (!string.IsNullOrWhiteSpace(entry))
                    set.Add(entry.Trim());
            return set;
        }

        // ─────────────────────────────────────────────────────────────────────
        // Localization keys
        // ─────────────────────────────────────────────────────────────────────

        private const string MsgNoPermission = "NoPermission";
        private const string MsgAdminOnly    = "AdminOnly";
        private const string MsgUsage        = "Usage";
        private const string MsgEnabled      = "Enabled";
        private const string MsgDisabled     = "Disabled";
        private const string MsgStatusOn     = "StatusOn";
        private const string MsgStatusOff    = "StatusOff";
        private const string MsgRateLimited  = "RateLimited";
        private const string MsgCooldown     = "Cooldown";

        /// <inheritdoc/>
        protected override void LoadDefaultMessages()
        {
            lang.RegisterMessages(new Dictionary<string, string>
            {
                [MsgNoPermission] = "You don't have permission to use this.",
                [MsgAdminOnly]    = "You must be an admin to use this command.",
                [MsgUsage]        = "Usage: /bw on|off|toggle|status",
                [MsgEnabled]      = "Bottomless water is now <color=#77FF77>ENABLED</color> for you.",
                [MsgDisabled]     = "Bottomless water is now <color=#FF7777>DISABLED</color> for you.",
                [MsgStatusOn]     = "Your bottomless water setting: <color=#77FF77>ENABLED</color>.",
                [MsgStatusOff]    = "Your bottomless water setting: <color=#FF7777>DISABLED</color>.",
                [MsgRateLimited]  = "You're doing that too often; try again later.",
                [MsgCooldown]     = "Please wait a moment before toggling again."
            }, this);
        }

        /// <summary>
        /// Returns the localised message for key, optionally formatted with args.
        /// Passes null to lang.GetMessage when playerId is empty so the server
        /// default language is used rather than the implicit null path.
        /// </summary>
        private string Msg(string key, string playerId = null, params object[] args)
        {
            var template = lang.GetMessage(key, this, string.IsNullOrEmpty(playerId) ? null : playerId);
            return args.Length > 0 ? string.Format(template, args) : template;
        }

        // ─────────────────────────────────────────────────────────────────────
        // Lifecycle
        // ─────────────────────────────────────────────────────────────────────

        /// <summary>
        /// Oxide lifecycle hook: called before the server has finished initialising.
        /// Registers permissions, optionally auto-grants the use permission to the
        /// default group, and loads persisted player state.
        /// </summary>
        private void Init()
        {
            permission.RegisterPermission(PermUse,   this);
            permission.RegisterPermission(PermAdmin, this);

            if (_config.AutoGrantUseToDefaultGroup && !permission.GroupHasPermission("default", PermUse))
                permission.GrantGroupPermission("default", PermUse, this);

            LoadData();

            _waterDefinition = ItemManager.FindItemDefinition("water");
            if (_waterDefinition == null)
                PrintWarning("Could not resolve 'water' ItemDefinition. FillEmptyContainers will not function.");
        }

        /// <summary>
        /// Oxide lifecycle hook: called once the server world is fully initialised.
        /// Populates the initial container set and starts the periodic fill tick.
        /// </summary>
        private void OnServerInitialized()
        {
            RefreshLiquidContainers();
            _tickTimer = timer.Every(_config.TickSeconds, DoTick);
        }

        /// <summary>
        /// Oxide lifecycle hook: called when the plugin is unloaded (server shutdown or
        /// explicit unload). Destroys timers and flushes any unsaved state to disk.
        /// </summary>
        private void Unload()
        {
            _tickTimer?.Destroy();
            _saveTimer?.Destroy();
            SaveDataImmediate();
        }

        /// <summary>Oxide hook: flushes state to disk whenever the server autosaves.</summary>
        private void OnServerSave() => SaveDataImmediate();

        /// <summary>
        /// Oxide hook: fires when a new save file is detected (map wipe).
        /// Clears all stored player states if PluginConfig.ClearDataOnWipe is true.
        /// </summary>
        private void OnNewSave(string filename)
        {
            if (!_config.ClearDataOnWipe) return;
            _playerStates.Clear();
            MarkDirty();
            SaveDataImmediate();
            Puts($"Cleared BottomlessWater player state due to wipe ({filename}).");
        }

        /// <summary>
        /// Oxide hook: fires when a player disconnects.
        /// Removes ephemeral per-player rate-limit and cooldown entries to prevent
        /// unbounded dictionary growth over long server uptime.
        /// </summary>
        private void OnPlayerDisconnected(BasePlayer player, string reason)
        {
            if (player == null) return;
            _toggleCooldowns.Remove(player.userID);
            _rateLimitWindows.Remove(player.userID);
        }

        // ─────────────────────────────────────────────────────────────────────
        // Data persistence
        // ─────────────────────────────────────────────────────────────────────

        /// <summary>
        /// Reads stored player states from the Oxide data file and hydrates
        /// _playerStates. The on-disk format is Dictionary-string-bool for human
        /// readability; each bool is wrapped in a PlayerState instance when loaded
        /// into memory. Silently resets to empty on any read/parse error.
        /// </summary>
        private void LoadData()
        {
            try
            {
                _storedData = Interface.Oxide.DataFileSystem.ReadObject<StoredData>(DataFileName) ?? new StoredData();
            }
            catch
            {
                _storedData = new StoredData();
            }

            _playerStates.Clear();
            foreach (var entry in _storedData.PlayerStates)
            {
                if (ulong.TryParse(entry.Key, out var userId))
                    _playerStates[userId] = new PlayerState(entry.Value);
            }
        }

        /// <summary>
        /// Writes _playerStates to disk immediately.
        /// No-ops when the dirty flag is not set, preventing unnecessary I/O.
        /// Direct callers: Unload and OnServerSave only. All other callers should use
        /// MarkDirty to debounce writes.
        /// </summary>
        private void SaveDataImmediate()
        {
            if (!_dirty) return;

            _storedData = new StoredData();
            foreach (var entry in _playerStates)
                _storedData.PlayerStates[entry.Key.ToString()] = entry.Value.Enabled;

            Interface.Oxide.DataFileSystem.WriteObject(DataFileName, _storedData);
            _dirty     = false;
            _saveTimer = null;
        }

        /// <summary>
        /// Marks the in-memory state as modified and schedules a debounced disk write
        /// after PluginConfig.SaveDebounceSeconds to coalesce rapid changes.
        /// </summary>
        private void MarkDirty()
        {
            _dirty = true;
            _saveTimer?.Destroy();
            _saveTimer = timer.Once(_config.SaveDebounceSeconds, SaveDataImmediate);
        }

        // ─────────────────────────────────────────────────────────────────────
        // Entity tracking
        // ─────────────────────────────────────────────────────────────────────

        /// <summary>
        /// Scans all server entities and populates _liquidContainers, filtering by the
        /// resolved whitelist/exclude lists up-front so the per-tick path can skip the
        /// prefab check entirely.
        ///
        /// Called once during OnServerInitialized, and again from CmdReload after the
        /// configuration is re-read, so a changed whitelist/exclude list takes effect
        /// immediately.
        /// </summary>
        private void RefreshLiquidContainers()
        {
            _liquidContainers.Clear();

            // Snapshot into a list first. BaseNetworkable.serverEntities uses a
            // ListHashSet whose enumerator may throw if entries are registered
            // concurrently during server startup (observed in Facepunch CU270+).
            var snapshot = new List<BaseNetworkable>(BaseNetworkable.serverEntities);
            foreach (var networkable in snapshot)
            {
                var liquid = networkable as LiquidContainer;
                if (liquid == null) continue;
                if (!ShouldProcessPrefab(liquid.ShortPrefabName)) continue;
                _liquidContainers.Add(liquid);
            }
        }

        /// <summary>
        /// Oxide hook: fires when any LiquidContainer entity spawns. Containers that
        /// fail the whitelist/exclude filter are dropped here rather than at tick time,
        /// so DoTick never has to revisit the prefab check.
        /// </summary>
        private void OnEntitySpawned(LiquidContainer liquid)
        {
            if (liquid == null) return;
            if (!ShouldProcessPrefab(liquid.ShortPrefabName)) return;
            _liquidContainers.Add(liquid);
        }

        /// <summary>
        /// Oxide hook: fires when a LiquidContainer entity is destroyed.
        /// Typed parameter lets Oxide's hook router filter non-matching entities
        /// before invoking us, avoiding a per-entity cast on every destruction.
        /// </summary>
        private void OnEntityKill(LiquidContainer liquid)
        {
            // Guard both null and IsDestroyed: Oxide may dispatch this hook with a
            // partially-torn-down reference during rapid entity recycling (CU270+).
            if (liquid != null && liquid.IsDestroyed)
                _liquidContainers.Remove(liquid);
            else if (liquid != null)
                _liquidContainers.Remove(liquid);
        }

        // ─────────────────────────────────────────────────────────────────────
        // Tick logic
        // ─────────────────────────────────────────────────────────────────────

        /// <summary>
        /// Core periodic fill tick. Iterates the current round-robin slice of tracked
        /// containers, validates each one, checks ownership and permission, and calls
        /// FillLiquidItems for eligible containers.
        ///
        /// The prefab whitelist/exclude filter is NOT applied here: ineligible
        /// containers are dropped at spawn time in OnEntitySpawned, so the per-tick
        /// path never has to revisit it.
        ///
        /// Round-robin: with PluginConfig.TickBucketCount = N, each tick processes
        /// containers at indices where (index % N) == (_tickCounter % N). A container
        /// is therefore visited once every (TickSeconds * N) seconds. N defaults to 1
        /// (every container every tick, pre-3.4.0 behaviour).
        ///
        /// Permission model enforced here (defence-in-depth beyond stored state):
        ///   1. Container must not be destroyed.
        ///   2. OwnerID must be non-zero (unowned containers are never filled).
        ///   3. Owner must hold PermUse (checked once per unique owner per tick via
        ///      _tickPermittedOwners cache; permission revocation takes effect next tick).
        ///   4. Owner's stored state must be enabled.
        ///
        /// Design note: we deliberately keep the per-tick PermUse re-check here rather
        /// than hedging into a reactive permission cache (option #1 in the perf review).
        /// The reactive design would require subscribing to OnUserPermissionGranted,
        /// OnUserPermissionRevoked, OnGroupPermissionGranted, OnGroupPermissionRevoked,
        /// OnUserGroupAdded, OnUserGroupRemoved, and re-resolving on plugin reload, all
        /// of which add coupling surface where a missed hook leaves the cache wrong (a
        /// player keeps infinite water after permission revocation). The current lazy
        /// per-tick check is O(unique owners) per tick and is cheap enough.
        /// </summary>
        private void DoTick()
        {
            SweepRateLimitWindows();

            if (!_config.AffectLiquidContainers || _liquidContainers.Count == 0)
                return;

            _tickBuffer.Clear();
            foreach (var liquid in _liquidContainers)
                _tickBuffer.Add(liquid);

            _tickPermittedOwners.Clear();

            var bucketCount = _config.TickBucketCount;
            var bucketIndex = bucketCount > 1 ? (int)(_tickCounter % (uint)bucketCount) : 0;
            _tickCounter++;

            for (var i = 0; i < _tickBuffer.Count; i++)
            {
                if (bucketCount > 1 && i % bucketCount != bucketIndex) continue;

                var liquid = _tickBuffer[i];
                if (liquid == null || liquid.IsDestroyed)
                {
                    _liquidContainers.Remove(liquid);
                    continue;
                }

                var entity = liquid as BaseEntity;
                if (entity == null) continue;

                var ownerId = entity.OwnerID;
                if (ownerId == 0UL) continue;

                // SECURITY: always re-verify PermUse each tick via the per-tick cache.
                // Revocation of the permission takes effect on the next tick without
                // requiring the player to explicitly run /bw off.
                if (!_tickPermittedOwners.Contains(ownerId))
                {
                    if (!permission.UserHasPermission(ownerId.ToString(), PermUse))
                        continue;
                    _tickPermittedOwners.Add(ownerId);
                }

                if (!IsEnabledFor(ownerId)) continue;

                FillLiquidItems(liquid);
            }
        }

        /// <summary>
        /// Periodically prunes _rateLimitWindows entries whose sliding 60-second
        /// window is empty or contains only expired timestamps. Without this,
        /// sleeping players who never explicitly disconnect would retain entries
        /// for the lifetime of the server.
        ///
        /// Runs at most once per RateLimitSweepIntervalSeconds (60s by default),
        /// inline with DoTick to avoid an additional timer.
        ///
        /// Design note: we keep this lazy bookkeeping rather than hedging into a
        /// reactive "skip already-full containers" optimisation (option #4 in the
        /// perf review). That optimisation would require subscribing to
        /// OnItemUseConsume / CanMoveItem hooks to re-add containers when liquid
        /// drains; a missed event would silently leave a container un-filled,
        /// which is exactly the user-visible bug class this plugin exists to
        /// prevent.
        /// </summary>
        private void SweepRateLimitWindows()
        {
            var now = Time.realtimeSinceStartup;
            if (now < _nextRateLimitSweepTime) return;
            _nextRateLimitSweepTime = now + RateLimitSweepIntervalSeconds;

            if (_rateLimitWindows.Count == 0) return;

            List<ulong> drop = null;
            foreach (var entry in _rateLimitWindows)
            {
                var window = entry.Value;
                for (var i = window.Count - 1; i >= 0; i--)
                    if (now - window[i] > 60f)
                        window.RemoveAt(i);

                if (window.Count == 0)
                {
                    if (drop == null) drop = new List<ulong>();
                    drop.Add(entry.Key);
                }
            }

            if (drop == null) return;
            for (var i = 0; i < drop.Count; i++)
                _rateLimitWindows.Remove(drop[i]);
        }

        /// <summary>
        /// Returns true if a container with the given shortPrefabName should be processed.
        /// If the whitelist is non-empty the name must appear in it; otherwise the name
        /// must not appear in the exclusion list.
        /// </summary>
        private bool ShouldProcessPrefab(string shortPrefabName)
        {
            if (_whitelistShortPrefabNames.Count > 0)
                return _whitelistShortPrefabNames.Contains(shortPrefabName);
            return !_excludeShortPrefabNames.Contains(shortPrefabName);
        }

        /// <summary>
        /// Returns the stored infinite-water state for userId.
        /// If the player has no stored state, seeds EnableByDefault and calls MarkDirty.
        /// Note: this has a write side-effect on first access; only call it after the
        /// permission check so non-permitted players do not pollute the dictionary.
        /// </summary>
        private bool IsEnabledFor(ulong userId)
        {
            PlayerState state;
            if (_playerStates.TryGetValue(userId, out state))
                return state.Enabled;

            var newState = new PlayerState(_config.EnableByDefault);
            _playerStates[userId] = newState;
            MarkDirty();
            return newState.Enabled;
        }

        /// <summary>
        /// Fills water-type items in the container's inventory up to their stack cap,
        /// adding at most MaxAddPerTick units per item per tick.
        ///
        /// SECURITY: Only items whose info matches _waterDefinition are filled.
        /// Without this guard the plugin would also top up salt water, crude oil,
        /// or other liquids that share a LiquidContainer, which is exploitable if
        /// those items have economic value in the game economy.
        ///
        /// If FillEmptyContainers is true and the inventory is completely empty,
        /// attempts to create a new water item and move it into the container.
        ///
        /// A network update is sent only when at least one item actually changed,
        /// via the debounced SendNetworkUpdate rather than the immediate variant,
        /// to avoid forcing a full network flush per container per tick under load.
        /// </summary>
        private void FillLiquidItems(LiquidContainer liquid)
        {
            try
            {
                // Guard against async teardown: inventory may be nulled before the
                // tick reaches this container even when the entity appears live.
                var inventory = liquid?.inventory;
                if (inventory == null) return;

                if (_config.FillEmptyContainers && inventory.itemList.Count == 0 && _waterDefinition != null)
                {
                    var stackable = _waterDefinition.stackable;
                    // Guard stackable > 0 to prevent creating a zero-amount item, which
                    // produces a broken/invisible entry in Rust's inventory system.
                    if (stackable > 0)
                    {
                        var item = ItemManager.Create(_waterDefinition, Math.Min(_config.MaxAddPerTick, stackable));
                        if (item != null && !item.MoveToContainer(inventory))
                            item.Remove();
                    }
                }

                var changed = false;
                var items   = inventory.itemList;
                for (var i = 0; i < items.Count; i++)
                {
                    var item = items[i];
                    if (item == null) continue;

                    // SECURITY: only top up fresh water items. This prevents inadvertent
                    // or exploitable filling of salt water, crude oil, or other liquids.
                    if (_waterDefinition != null && item.info != _waterDefinition) continue;

                    var maxStack = SafeMaxStackable(item);
                    if (maxStack <= 0 || item.amount >= maxStack) continue;

                    var add = Math.Min(_config.MaxAddPerTick, maxStack - item.amount);
                    if (add <= 0) continue;

                    item.amount += add;
                    item.MarkDirty();
                    changed = true;
                }

                if (changed)
                    liquid.SendNetworkUpdate();
            }
            catch (Exception ex)
            {
                // Log once per bad entity and move on; never let a single broken container
                // crash the entire tick loop (defensive against future Facepunch API changes).
                PrintWarning($"FillLiquidItems error on {liquid?.ShortPrefabName ?? "unknown"}: {ex.Message}");
                _liquidContainers.Remove(liquid);
            }
        }

        /// <summary>
        /// Returns the maximum stackable amount for an item.
        /// Calls <see cref="Item.MaxStackable()"/> and falls back to
        /// <see cref="ItemDefinition.stackable"/> if the call throws, guarding against
        /// signature changes in the Facepunch assembly across Rust updates.
        /// </summary>
        private static int SafeMaxStackable(Item item)
        {
            try
            {
                return item.MaxStackable();
            }
            catch
            {
                return item.info?.stackable ?? 0;
            }
        }

        // ─────────────────────────────────────────────────────────────────────
        // Chat command
        // ─────────────────────────────────────────────────────────────────────

        /// <summary>
        /// Chat command /bw: lets a permitted player enable, disable, toggle, or query
        /// their infinite-water state.
        ///
        /// Subcommands: on | off | toggle | status
        ///
        /// Rate-limiting and cooldown apply only to mutating actions (on/off/toggle).
        /// The cooldown timestamp is recorded only after BOTH guards pass to prevent a
        /// failed rate-limit check from advancing the cooldown clock.
        /// </summary>
        [ChatCommand("bw")]
        private void CmdBottomlessWater(BasePlayer player, string command, string[] args)
        {
            if (player == null) return;

            if (!permission.UserHasPermission(player.UserIDString, PermUse))
            {
                SendReply(player, Msg(MsgNoPermission, player.UserIDString));
                return;
            }

            if (args.Length == 0)
            {
                SendReply(player, Msg(MsgUsage, player.UserIDString));
                return;
            }

            // Cap argument length before any further string work to limit attack surface.
            var rawAction = args[0].Length > MaxArgLength ? args[0].Substring(0, MaxArgLength) : args[0];
            var action    = rawAction.ToLowerInvariant();
            var isMutating = action == "on" || action == "off" || action == "toggle";

            if (isMutating)
            {
                // Rate-limit check must precede the cooldown check so a rate-limited player
                // does not have their cooldown clock advanced by a rejected command.
                if (IsRateLimited(player)) return;

                if (IsOnToggleCooldown(player.userID))
                {
                    SendReply(player, Msg(MsgCooldown, player.UserIDString));
                    return;
                }

                _toggleCooldowns[player.userID] = Time.realtimeSinceStartup;
            }

            var enabled = IsEnabledFor(player.userID);
            switch (action)
            {
                case "on":
                    SetPlayerState(player.userID, true);
                    SendReply(player, Msg(MsgEnabled, player.UserIDString));
                    LogToggle(player.displayName, player.UserIDString, true);
                    break;
                case "off":
                    SetPlayerState(player.userID, false);
                    SendReply(player, Msg(MsgDisabled, player.UserIDString));
                    LogToggle(player.displayName, player.UserIDString, false);
                    break;
                case "toggle":
                    enabled = !enabled;
                    SetPlayerState(player.userID, enabled);
                    SendReply(player, Msg(enabled ? MsgEnabled : MsgDisabled, player.UserIDString));
                    LogToggle(player.displayName, player.UserIDString, enabled);
                    break;
                case "status":
                    SendReply(player, Msg(enabled ? MsgStatusOn : MsgStatusOff, player.UserIDString));
                    break;
                default:
                    SendReply(player, Msg(MsgUsage, player.UserIDString));
                    break;
            }
        }

        // ─────────────────────────────────────────────────────────────────────
        // Console commands
        // ─────────────────────────────────────────────────────────────────────

        /// <summary>
        /// Console command: bottomlesswater.toggle &lt;steamid64&gt; &lt;on|off|toggle&gt;
        /// Admin command to override the infinite-water state for another player.
        /// Accepts server console (no Connection) or an in-game admin with PermAdmin.
        /// </summary>
        [ConsoleCommand("bottomlesswater.toggle")]
        private void CmdToggleConsole(ConsoleSystem.Arg arg)
        {
            if (!HasAdminAccess(arg)) return;

            if (arg.Args == null || arg.Args.Length < 2)
            {
                Reply(arg, "Usage: bottomlesswater.toggle <steamid64> <on|off|toggle>");
                return;
            }

            if (!TryFindPlayerBySteamId(arg.GetString(0), out var target))
            {
                Reply(arg, "Player not found. Use full SteamID64.");
                return;
            }

            var action  = arg.GetString(1).ToLowerInvariant();
            var current = IsEnabledFor(target.userID);
            bool? next  = null;

            switch (action)
            {
                case "on":     next = true;     break;
                case "off":    next = false;    break;
                case "toggle": next = !current; break;
            }

            if (!next.HasValue)
            {
                Reply(arg, "Usage: bottomlesswater.toggle <steamid64> <on|off|toggle>");
                return;
            }

            SetPlayerState(target.userID, next.Value);
            Reply(arg, $"Set {target.displayName} ({target.UserIDString}) to {(next.Value ? "ENABLED" : "DISABLED")}");

            var actor = arg.Player();
            LogToggle(actor?.displayName ?? "Console", actor?.UserIDString ?? "Console",
                      next.Value, target.displayName, target.UserIDString, isAdmin: true);
        }

        /// <summary>
        /// Console command: bottomlesswater.status [steamid64]
        /// Reports the infinite-water state for one player, or all known players if
        /// no argument is provided. Requires admin access.
        /// </summary>
        [ConsoleCommand("bottomlesswater.status")]
        private void CmdStatusConsole(ConsoleSystem.Arg arg)
        {
            if (!HasAdminAccess(arg)) return;

            if (arg.Args == null || arg.Args.Length == 0)
            {
                foreach (var entry in _playerStates)
                {
                    var p    = BasePlayer.FindAwakeOrSleeping(entry.Key.ToString());
                    var name = p != null ? p.displayName : entry.Key.ToString();
                    Reply(arg, $"{name} ({entry.Key}): {(entry.Value.Enabled ? "ENABLED" : "DISABLED")}");
                }
                return;
            }

            if (!TryFindPlayerBySteamId(arg.GetString(0), out var target))
            {
                Reply(arg, "Player not found. Use full SteamID64.");
                return;
            }

            var enabled = IsEnabledFor(target.userID);
            Reply(arg, $"{target.displayName} ({target.UserIDString}): {(enabled ? "ENABLED" : "DISABLED")}");
        }

        /// <summary>
        /// Console command: bottomlesswater.reload
        /// Hot-reloads plugin configuration from disk and restarts the tick timer.
        /// Requires admin access.
        /// </summary>
        [ConsoleCommand("bottomlesswater.reload")]
        private void CmdReload(ConsoleSystem.Arg arg)
        {
            if (!HasAdminAccess(arg)) return;
            LoadConfig();
            // Re-classify tracked containers under the new whitelist/exclude lists so
            // the per-tick path stays prefab-check-free after a config change.
            RefreshLiquidContainers();
            _tickTimer?.Destroy();
            _tickTimer = timer.Every(_config.TickSeconds, DoTick);
            Reply(arg, "BottomlessWater configuration reloaded.");
        }

        // ─────────────────────────────────────────────────────────────────────
        // Command helpers
        // ─────────────────────────────────────────────────────────────────────

        /// <summary>
        /// Returns true if the player has performed too many mutating actions within the
        /// last 60 seconds (sliding window rate limit). Sends MsgRateLimited and returns
        /// true without adding an entry if the limit is already reached.
        ///
        /// The player reference is passed directly to avoid a redundant BasePlayer.FindByID
        /// lookup that earlier implementations performed inside this method.
        /// </summary>
        private bool IsRateLimited(BasePlayer player)
        {
            var userId = player.userID;
            List<float> window;
            if (!_rateLimitWindows.TryGetValue(userId, out window))
            {
                window = new List<float>();
                _rateLimitWindows[userId] = window;
            }

            var now = Time.realtimeSinceStartup;

            for (var i = window.Count - 1; i >= 0; i--)
                if (now - window[i] > 60f)
                    window.RemoveAt(i);

            if (window.Count >= _config.RateLimitMaxPerMinute)
            {
                SendReply(player, Msg(MsgRateLimited, player.UserIDString));
                return true;
            }

            window.Add(now);
            return false;
        }

        /// <summary>
        /// Returns true if the player last performed a mutating action less than
        /// ChatCooldownSeconds ago.
        /// </summary>
        private bool IsOnToggleCooldown(ulong userId)
        {
            float lastToggleTime;
            if (!_toggleCooldowns.TryGetValue(userId, out lastToggleTime))
                return false;
            return Time.realtimeSinceStartup - lastToggleTime < _config.ChatCooldownSeconds;
        }

        /// <summary>
        /// Returns true if the console argument represents an authorised admin call.
        /// Server-console connections (null Connection) are always trusted.
        /// In-game callers must be flagged as server admin OR hold PermAdmin.
        /// On denial, sends MsgAdminOnly and returns false.
        /// </summary>
        private bool HasAdminAccess(ConsoleSystem.Arg arg)
        {
            if (arg == null) return false;
            if (arg.Connection == null) return true;

            var player = arg.Player();
            if (player != null && player.IsAdmin) return true;
            if (player != null && permission.UserHasPermission(player.UserIDString, PermAdmin)) return true;

            // Pass string.Empty (not null) so lang.GetMessage uses the server default
            // language rather than an implicit null fallback.
            Reply(arg, Msg(MsgAdminOnly, player?.UserIDString ?? string.Empty));
            return false;
        }

        /// <summary>
        /// Attempts to locate a connected or sleeping player by their SteamID64 string.
        /// Returns false if input is null/whitespace, not parseable as a ulong, or no
        /// matching player is found in the awake/sleeping lists.
        /// </summary>
        private static bool TryFindPlayerBySteamId(string input, out BasePlayer player)
        {
            player = null;
            ulong userId;
            if (string.IsNullOrWhiteSpace(input) || !ulong.TryParse(input, out userId))
                return false;
            player = BasePlayer.FindAwakeOrSleeping(userId.ToString());
            return player != null;
        }

        /// <summary>
        /// Sets the stored infinite-water state for userId via its PlayerState instance,
        /// creating one if it does not yet exist, and schedules a debounced save.
        /// </summary>
        private void SetPlayerState(ulong userId, bool enabled)
        {
            PlayerState state;
            if (_playerStates.TryGetValue(userId, out state))
                state.Enabled = enabled;
            else
                _playerStates[userId] = new PlayerState(enabled);

            MarkDirty();
        }

        /// <summary>
        /// Sends text to the appropriate destination: SendReply for in-game console
        /// connections, or Puts for the server console.
        /// </summary>
        private void Reply(ConsoleSystem.Arg arg, string text)
        {
            if (arg?.Connection != null)
                SendReply(arg, text);
            else
                Puts(text);
        }

        /// <summary>
        /// Writes a structured toggle event to the server console and the plugin's log file.
        ///
        /// Format: [ADMIN|PLAYER] actorName ENABLED|DISABLED BottomlessWater for target at utc
        /// </summary>
        private void LogToggle(string actorName, string actorId, bool state,
                               string targetName = null, string targetId = null, bool isAdmin = false)
        {
            var targetDesc = targetName != null
                ? $"{targetName} ({targetId})"
               
// ... [Code preview truncated due to length. Full source in attached file] ...

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