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

FREE EstateUpkeep : 1.0.1 1.0.1

Нет прав для скачивания
C#:
/*
 * Estate Upkeep
 * Copyright © 2026 CrabSerg
 *
 * A Copper Dreams project
 *
 * Author: CrabSerg
 * Official identity: Estate Upkeep by CrabSerg / Copper Dreams
 * License: GPL-3.0-or-later
 * SPDX-License-Identifier: GPL-3.0-or-later
 *
 * This source code is free software distributed under the
 * GNU General Public License v3.0 or later.
 *
 * Modified versions must not be represented as official
 * CrabSerg / Copper Dreams releases without permission.
 */

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

namespace Oxide.Plugins
{
    [Info("Estate Upkeep", "CrabSerg", "1.0.1")]
    [Description("Extends TC upkeep to authorized detached estate structures and optionally protects transport from decay inside TC privilege zones.")]
    public class EstateUpkeep : RustPlugin
    {
        private const string ProjectAuthor = "CrabSerg";
        private const string ProjectFamily = "Copper Dreams";
        private const string ProjectIdentity = "Estate Upkeep by CrabSerg • Copper Dreams";
        private const string AdminPermission = "estateupkeep.admin";

        private const float RayDistance = 10f;
        private const float MinutesPerDay = 1440f;
        private const float SecondsPerDay = 86400f;

        // Estate association for doors, batteries, solar panels and other deployables.
        private const float DeployableAssociationRadius = 4.5f;

        // Runtime billing tick. Exact fractional debt is accumulated, so this does
        // not mean one whole resource is charged every minute.
        private const float BillingTickSeconds = 60f;

        // Production cycle is now enabled.
        private const bool AutomaticBillingEnabled = true;

        // Persistent runtime state is flushed periodically and on important state changes.
        private const float DataSaveIntervalSeconds = 60f;

        // Repair only HP that Estate Upkeep has tracked as decay damage.
        // This prevents free healing of raid/combat/other damage.
        private const float RepairTickSeconds = 30f;
        private const float RepairFractionOfMaxHealthPerMinute = 0.01f;
        private const float MembershipRefreshSeconds = 120f;
        private const float ProtectedTransportRefreshSeconds = 5f;
        private const float ServerSaveCheckpointFreezeSeconds = 30f;
        private const float StartupTransportReconcileFirstDelaySeconds = 5f;
        private const float StartupTransportReconcileSecondDelaySeconds = 20f;
        private const float StartupTransportReconcileThirdDelaySeconds = 45f;
        private const float StartupTransportReconcileFinalDelaySeconds = 90f;
        private const float StartupTransportSpawnReconcileDelaySeconds = 0.75f;
        private const float RestartCheckpointMaxBootAgeSeconds = 600f;

        private readonly Dictionary<string, ItemDefinition> _deployableItemByPrefab =
            new Dictionary<string, ItemDefinition>(StringComparer.OrdinalIgnoreCase);

        private readonly Dictionary<int, ItemBlueprint> _blueprintByItemId =
            new Dictionary<int, ItemBlueprint>();

        private readonly Dictionary<BuildingPrivlidge, EstateAccount> _accounts =
            new Dictionary<BuildingPrivlidge, EstateAccount>();

        private readonly HashSet<BaseEntity> _protectedEntities =
            new HashSet<BaseEntity>();

        // All currently eligible estate combat entities, paid or unpaid.
        private readonly HashSet<BaseCombatEntity> _eligibleEstateCombatEntities =
            new HashSet<BaseCombatEntity>();

        private readonly Dictionary<BaseCombatEntity, BuildingPrivlidge> _estateTcByCombatEntity =
            new Dictionary<BaseCombatEntity, BuildingPrivlidge>();

        // Only decay-caused missing HP is repaired automatically.
        private readonly Dictionary<BaseCombatEntity, float> _decayRepairDebt =
            new Dictionary<BaseCombatEntity, float>();

        // PERFORMANCE CACHE:
        // Full world enumeration happens once at plugin startup only.
        // Spawn/kill hooks maintain these collections afterwards.
        private readonly HashSet<BuildingBlock> _cachedBuildingBlocks =
            new HashSet<BuildingBlock>();

        private readonly HashSet<BaseEntity> _cachedEstateCandidates =
            new HashSet<BaseEntity>();

        // Transport cache is intentionally independent from OwnerID.
        // Rust vehicles (notably modular cars) may not satisfy the player-owned
        // candidate filter used by Estate deployables.
        private readonly HashSet<BaseCombatEntity> _cachedTransports =
            new HashSet<BaseCombatEntity>();

        // Maintained during normal runtime, when TC privilege queries are reliable.
        // OnServerSave snapshots HP from this set without re-querying TC privilege.
        private readonly HashSet<BaseCombatEntity> _runtimeProtectedTransports =
            new HashSet<BaseCombatEntity>();

        private Dictionary<string, StoredTransportShutdownState> _startupPendingTransportSnapshot =
            new Dictionary<string, StoredTransportShutdownState>(StringComparer.Ordinal);

        private readonly HashSet<BaseCombatEntity> _startupReconcileDisqualifiedEntities =
            new HashSet<BaseCombatEntity>();

        private long _transportCheckpointFrozenUntilUnix;
        private bool _startupTransportReconcileActive;
        private bool _startupSpawnReconcileQueued;

        private int _initialWorldEntityCount;
        private double _lastSnapshotMilliseconds;
        private int _lastSnapshotBuildingsScanned;
        private int _lastSnapshotCandidatesScanned;

        private int _blockedTransportDecayEvents;
        private int _startupTransportRestoreEntities;
        private float _startupTransportRestoreHp;
        private int _startupTransportSnapshotRecords;
        private int _startupTransportMatchedRecords;
        private int _startupTransportUnmatchedRecords;
        private bool _startupRestartCheckpointAccepted;
        private long _startupRestartCheckpointAgeAtShutdownSeconds;
        private string _startupRestartCheckpointSource = string.Empty;
        private int _lastProtectedTransportRefreshScanned;
        private int _lastProtectedTransportRefreshFound;
        private int _lastRestartCheckpointRootCount;
        private int _lastRestartCheckpointRecordCount;
        private int _startupTransportReconcilePasses;
        private float _startupTransportDelayedRestoreHp;
        private int _startupTransportDelayedRestoreEntities;
        private int _startupRestartCandidateRoots;
        private int _startupRestartCandidateHealthEntities;
        private int _startupSpawnTriggeredReconcilePasses;
        private int _startupUnsourcedTransportDamageEvents;
        private int _startupSelfSourcedTransportDamageEvents;
        private int _startupExternalSourcedTransportDamageEvents;
        private int _startupSourcedTransportDamageDisqualifications;
        private int _startupDisqualifiedTransportEntities;
        private string _lastStartupDamageDiagnostic = "NONE";

        private DynamicConfigFile _dataFile;
        private StoredData _storedData = new StoredData();
        private bool _dataDirty;
        private bool _serverInitializationComplete;

        private string _currentServerBootId = string.Empty;
        private string _startupRestartDetectionMode = "NONE";
        private bool _startupCheckpointBootMatchesCurrent;
        private long _startupRestartCheckpointAgeAtBootSeconds = -1;

        private PluginConfig _config;

        private int _woodItemId;
        private int _stoneItemId;
        private int _metalItemId;
        private int _hqmItemId;

        private void Init()
        {
            permission.RegisterPermission(AdminPermission, this);
        }

        private bool HasAdminAccess(BasePlayer player)
        {
            return
                player != null &&
                (
                    player.IsAdmin ||
                    permission.UserHasPermission(player.UserIDString, AdminPermission)
                );
        }

        private bool RequireAdmin(BasePlayer player)
        {
            if (HasAdminAccess(player))
                return true;

            if (player != null)
            {
                player.ChatMessage(
                    "<color=#ff8c42>[Estate Upkeep]</color> Admin permission required."
                );
            }

            return false;
        }

        protected override void LoadDefaultConfig()
        {
            _config = PluginConfig.CreateDefault();
            SavePluginConfig();
        }

        protected override void LoadConfig()
        {
            base.LoadConfig();

            try
            {
                _config = Config.ReadObject<PluginConfig>();

                if (_config == null)
                    throw new Exception("Config returned null.");
            }
            catch (Exception ex)
            {
                PrintWarning($"Invalid config; using defaults. {ex.Message}");
                _config = PluginConfig.CreateDefault();
            }

            NormalizeConfig();
            SavePluginConfig();
        }

        private void NormalizeConfig()
        {
            if (_config == null)
                _config = PluginConfig.CreateDefault();

            if (_config.VehicleProtection == null)
                _config.VehicleProtection = new VehicleProtectionConfig();

            if (_config.VehicleProtection.RestartMatchRadiusMeters < 1f)
                _config.VehicleProtection.RestartMatchRadiusMeters = 8f;

            if (_config.VehicleProtection.RestartCheckpointMaxAgeAtShutdownSeconds < 10f)
                _config.VehicleProtection.RestartCheckpointMaxAgeAtShutdownSeconds = 90f;
        }

        private void SavePluginConfig()
        {
            Config.WriteObject(_config, true);
        }

        private void OnServerInitialized()
        {
            if (_serverInitializationComplete)
            {
                Puts(
                    "Duplicate OnServerInitialized hook ignored; " +
                    "existing Estate Upkeep timers/state remain active."
                );
                return;
            }

            _serverInitializationComplete = true;

            NormalizeConfig();

            _currentServerBootId = GetCurrentServerBootId();

            CacheResourceItemIds();
            BuildDeployableItemIndex();
            BuildBlueprintIndex();
            LoadPersistentData();

            BuildInitialEntityCache();

            // Restore only HP that disappeared across the immediately preceding
            // clean server restart. Existing pre-shutdown damage is preserved.
            RestoreTransportAfterServerRestart();

            EstateSnapshot initialSnapshot = BuildEstateSnapshot();
            RefreshEstateMembership(initialSnapshot);
            RestorePersistentState(initialSnapshot);

            // Build transport protection membership during normal runtime.
            // Restart checkpointing later uses this cache directly.
            RefreshRuntimeProtectedTransportCache();
            RefreshLiveTransportRestartCheckpoint("StartupRuntime");

            // Run one production billing pass after restore. Restored accounts have
            // LastTick reset to "now", so server/plugin downtime is not back-billed.
            ProcessEstateBilling(true);

            timer.Every(MembershipRefreshSeconds, () =>
            {
                PruneEntityCaches();

                EstateSnapshot snapshot = BuildEstateSnapshot();
                RefreshEstateMembership(snapshot);
                RebuildProtectedEntitySet(snapshot);
            });

            timer.Every(RepairTickSeconds, () =>
            {
                ProcessEstateRepair();
            });

            timer.Every(ProtectedTransportRefreshSeconds, () =>
            {
                RefreshRuntimeProtectedTransportCache();
                RefreshLiveTransportRestartCheckpoint("Runtime");
            });

            timer.Every(BillingTickSeconds, () =>
            {
                ProcessEstateBilling(false);
            });

            timer.Every(DataSaveIntervalSeconds, () =>
            {
                SavePersistentData(false);
            });

            Puts(
                $"{ProjectIdentity} | v1.0.1 | GPL-3.0-or-later"
            );

            Puts(
                $"CORE MODE: automatic billing ON; persistence ON; " +
                $"cached blocks={_cachedBuildingBlocks.Count}, cached estate candidates={_cachedEstateCandidates.Count}, " +
                $"cached transports={_cachedTransports.Count}; " +
                $"transport anti-decay={(_config.VehicleProtection.Enabled ? "ON" : "OFF")}; " +
                $"process boot detection={(!string.IsNullOrEmpty(_currentServerBootId) ? "READY" : "UNAVAILABLE")}; " +
                $"independent TC estates only."
            );
        }

        private void OnServerSave()
        {
            if (_storedData == null)
                _storedData = new StoredData();

            // A save can be part of shutdown. Do not replace the already-live
            // checkpoint here: testing showed the vehicle can lose restart HP before
            // this hook completes. Freeze periodic checkpoint updates briefly instead.
            _transportCheckpointFrozenUntilUnix =
                GetUnixTimeSeconds() + (long)ServerSaveCheckpointFreezeSeconds;

            SavePersistentData(true);

            Puts(
                $"Server save: preserved live transport checkpoint; records=" +
                $"{(_storedData.TransportShutdown != null ? _storedData.TransportShutdown.Count : 0)}, " +
                $"updates frozen for {ServerSaveCheckpointFreezeSeconds:0}s."
            );
        }

        private void OnServerShutdown()
        {
            if (_storedData == null)
                _storedData = new StoredData();

            _storedData.CleanShutdownPending = true;
            _storedData.CleanShutdownMarkedUnix = GetUnixTimeSeconds();

            // IMPORTANT: do NOT recapture transport HP here. Testing showed Rust can
            // already have applied restart/shutdown decay by the time this hook runs.
            MarkDataDirty();
            SavePersistentData(true);

            long age =
                _storedData.TransportCheckpointCapturedUnix > 0
                    ? Mathf.Max(
                        0,
                        (int)(
                            _storedData.CleanShutdownMarkedUnix -
                            _storedData.TransportCheckpointCapturedUnix
                        )
                    )
                    : -1;

            Puts(
                $"Best-effort clean shutdown marker saved. Restart detection primarily uses " +
                $"Rust process boot identity. Checkpoint source=" +
                $"{_storedData.TransportCheckpointSource}, records=" +
                $"{(_storedData.TransportShutdown != null ? _storedData.TransportShutdown.Count : 0)}, " +
                $"ageAtShutdown={(age >= 0 ? age.ToString() + "s" : "none")}."
            );
        }

        private void Unload()
        {
            SavePersistentData(true);
            _serverInitializationComplete = false;

            _protectedEntities.Clear();
            _eligibleEstateCombatEntities.Clear();
            _estateTcByCombatEntity.Clear();
            _decayRepairDebt.Clear();
            _accounts.Clear();
            _cachedBuildingBlocks.Clear();
            _cachedEstateCandidates.Clear();
            _cachedTransports.Clear();
            _runtimeProtectedTransports.Clear();
            _startupPendingTransportSnapshot.Clear();
            _startupReconcileDisqualifiedEntities.Clear();
        }

        [ChatCommand("estateperf")]
        private void EstatePerformanceCommand(BasePlayer player, string command, string[] args)
        {
            if (player == null)
                return;

            if (!RequireAdmin(player))
                return;

            player.ChatMessage("<color=#e0a000>[Estate Upkeep v1.0.1 — Performance]</color>");
            player.ChatMessage($"Initial full-world scan: {_initialWorldEntityCount} entities (startup only)");
            player.ChatMessage($"Cached BuildingBlocks: {_cachedBuildingBlocks.Count}");
            player.ChatMessage($"Cached player-owned estate candidates: {_cachedEstateCandidates.Count}");
            player.ChatMessage($"Cached supported transports: {_cachedTransports.Count}");
            player.ChatMessage($"Last snapshot blocks scanned: {_lastSnapshotBuildingsScanned}");
            player.ChatMessage($"Last snapshot candidates scanned: {_lastSnapshotCandidatesScanned}");
            player.ChatMessage($"Last snapshot build time: {_lastSnapshotMilliseconds:0.###} ms");
            player.ChatMessage($"Active TC accounts: {_accounts.Count}");
            player.ChatMessage($"Tracked repair debts: {_decayRepairDebt.Count}");
            player.ChatMessage($"Transport anti-decay: {(_config.VehicleProtection.Enabled ? "ON" : "OFF")}");
            player.ChatMessage($"Blocked transport decay events since load: {_blockedTransportDecayEvents}");
            player.ChatMessage($"Runtime protected transport cache: {_runtimeProtectedTransports.Count}");
            player.ChatMessage($"Last protection refresh: scanned {_lastProtectedTransportRefreshScanned}, protected {_lastProtectedTransportRefreshFound}");
            player.ChatMessage($"Last restart checkpoint capture: roots {_lastRestartCheckpointRootCount}, records {_lastRestartCheckpointRecordCount}");
            player.ChatMessage($"Checkpoint updates frozen: {(GetUnixTimeSeconds() < _transportCheckpointFrozenUntilUnix ? "YES" : "NO")}");
            player.ChatMessage($"Restart checkpoint accepted: {(_startupRestartCheckpointAccepted ? "YES" : "NO")}");
            player.ChatMessage($"Restart detection mode: {_startupRestartDetectionMode}");
            player.ChatMessage($"Checkpoint process matches current: {(_startupCheckpointBootMatchesCurrent ? "YES" : "NO")}");
            player.ChatMessage($"Restart checkpoint source: {(string.IsNullOrEmpty(_startupRestartCheckpointSource) ? "NONE" : _startupRestartCheckpointSource)}");
            player.ChatMessage($"Checkpoint age at boot: {(_startupRestartCheckpointAgeAtBootSeconds >= 0 ? _startupRestartCheckpointAgeAtBootSeconds.ToString() + "s" : "N/A")}");
            player.ChatMessage($"Legacy checkpoint age at shutdown: {(_startupRestartCheckpointAgeAtShutdownSeconds >= 0 ? _startupRestartCheckpointAgeAtShutdownSeconds.ToString() + "s" : "N/A")}");
            player.ChatMessage($"Restart snapshot records loaded: {_startupTransportSnapshotRecords}");
            player.ChatMessage($"Restart snapshot records matched: {_startupTransportMatchedRecords}");
            player.ChatMessage($"Restart snapshot records unmatched: {_startupTransportUnmatchedRecords}");
            player.ChatMessage($"Startup transport HP restored: {_startupTransportRestoreHp:0.##} across {_startupTransportRestoreEntities} entity(s)");
            player.ChatMessage($"Startup delayed reconcile active: {(_startupTransportReconcileActive ? "YES" : "NO")}");
            player.ChatMessage($"Startup reconcile passes: {_startupTransportReconcilePasses}");
            player.ChatMessage($"Spawn-triggered reconcile passes: {_startupSpawnTriggeredReconcilePasses}");
            player.ChatMessage($"Startup restart candidates: roots {_startupRestartCandidateRoots}, health entities {_startupRestartCandidateHealthEntities}");
            player.ChatMessage($"Unsourced startup transport damage events: {_startupUnsourcedTransportDamageEvents}");
            player.ChatMessage($"Self-sourced startup transport damage events: {_startupSelfSourcedTransportDamageEvents}");
            player.ChatMessage($"External sourced startup transport damage events: {_startupExternalSourcedTransportDamageEvents}");
            player.ChatMessage($"Sourced startup damage disqualifications: {_startupSourcedTransportDamageDisqualifications}");
            player.ChatMessage($"Disqualified startup transport entities: {_startupDisqualifiedTransportEntities}");
            player.ChatMessage($"Last startup damage diagnostic: {_lastStartupDamageDiagnostic}");
            player.ChatMessage($"Delayed HP restored: {_startupTransportDelayedRestoreHp:0.##} across {_startupTransportDelayedRestoreEntities} entity(s)");
            player.ChatMessage("<color=#9bd36a>Startup restart matching does NOT depend on TC privilege readiness.</color>");
            player.ChatMessage("<color=#9bd36a>Recurring estate snapshots do NOT enumerate BaseNetworkable.serverEntities.</color>");
        }

        [ChatCommand("estatecheck")]
        private void EstateCheckCommand(BasePlayer player, string command, string[] args)
        {
            if (player == null)
                return;

            if (!RequireAdmin(player))
                return;

            BuildingBlock block = GetLookedAtBuildingBlock(player);
            if (block == null)
            {
                player.ChatMessage("<color=#e0a000>[Estate Upkeep]</color> Look directly at a foundation, wall, floor, roof, etc.");
                return;
            }

            BuildingPrivlidge ownTc = block.GetBuildingPrivilege();
            BuildingPrivlidge privilegeTc = GetSpatialPrivilege(block);

            player.ChatMessage("<color=#e0a000>[Estate Upkeep v1.0.1]</color>");
            player.ChatMessage($"BuildingID: {block.buildingID}");
            player.ChatMessage($"OwnerID: {block.OwnerID}");
            player.ChatMessage($"Own-building TC: {(ownTc == null ? "NOT FOUND" : "FOUND")}");
            player.ChatMessage($"Privilege TC: {(privilegeTc == null ? "NOT FOUND" : $"FOUND | Net ID: {privilegeTc.net?.ID}")}");

            if (privilegeTc == null)
            {
                player.ChatMessage("Eligible: NO");
                return;
            }

            bool ownerAuthorized = IsAuthorized(privilegeTc, block.OwnerID);
            bool detached = ownTc == null;

            player.ChatMessage($"Detached: {(detached ? "YES" : "NO")}");
            player.ChatMessage($"Block owner authorized: {(ownerAuthorized ? "YES" : "NO")}");
            player.ChatMessage($"Eligible: {(detached && ownerAuthorized ? "YES" : "NO")}");

            EstateSnapshot snapshot = BuildEstateSnapshot();

            EstateTcSnapshot tcSnapshot;
            if (!snapshot.ByTc.TryGetValue(privilegeTc, out tcSnapshot))
            {
                player.ChatMessage("Estate total: no eligible detached structures found for this TC.");
                return;
            }

            player.ChatMessage("<color=#e0a000>--- TC Estate Summary ---</color>");
            player.ChatMessage($"Detached buildings: {tcSnapshot.Buildings.Count}");
            player.ChatMessage($"BuildingBlocks: {tcSnapshot.BlockCount}");
            player.ChatMessage($"Associated entities: {tcSnapshot.AssociatedEntities.Count}");
            player.ChatMessage($"Doors/windows: {tcSnapshot.DoorWindowCount}");
            player.ChatMessage($"Batteries: {tcSnapshot.BatteryCount} | Solar: {tcSnapshot.SolarCount}");
            player.ChatMessage($"Other electrical: {tcSnapshot.OtherElectricalCount}");
            player.ChatMessage($"Other deployables/entities: {tcSnapshot.OtherEntityCount}");

            player.ChatMessage("<color=#e0a000>--- Extra Estate Upkeep / 24h ---</color>");
            player.ChatMessage($"Wood: {CeilPositive(tcSnapshot.Rate.WoodPerDay)}");
            player.ChatMessage($"Stone: {CeilPositive(tcSnapshot.Rate.StonePerDay)}");
            player.ChatMessage($"Metal Fragments: {CeilPositive(tcSnapshot.Rate.MetalPerDay)}");
            player.ChatMessage($"HQM: {CeilPositive(tcSnapshot.Rate.HqmPerDay)}");

            EstateAccount account;
            if (_accounts.TryGetValue(privilegeTc, out account))
            {
                player.ChatMessage($"Billing state: {(account.Paid ? "<color=#9bd36a>PAID / DECAY PROTECTED</color>" : "<color=#ff8c42>UNPAID / DECAY ALLOWED</color>")}");
                player.ChatMessage(
                    $"Pending fractional debt: W {account.DebtWood:0.###}, S {account.DebtStone:0.###}, " +
                    $"M {account.DebtMetal:0.###}, HQM {account.DebtHqm:0.###}"
                );

                if (!string.IsNullOrEmpty(account.LastFailure))
                    player.ChatMessage($"Last billing issue: {account.LastFailure}");
            }

            BaseCombatEntity lookedCombat = block as BaseCombatEntity;
            float trackedRepairDebt;
            if (lookedCombat != null && _decayRepairDebt.TryGetValue(lookedCombat, out trackedRepairDebt))
            {
                player.ChatMessage($"Tracked decay repair debt: {trackedRepairDebt:0.##} HP");
            }

            player.ChatMessage("<color=#9bd36a>Automatic billing: ON.</color> Persistent state: ON. Core scope: one TC manages its own detached Estate.");
        }

        [ChatCommand("estate")]
        private void EstateStatusCommand(BasePlayer player, string command, string[] args)
        {
            if (player == null)
                return;

            if (args != null && args.Length > 0)
            {
                string subCommand = args[0].ToLowerInvariant();

                if (subCommand == "help")
                {
                    SendEstateHelp(player);
                    return;
                }

                if (subCommand == "status")
                {
                    // Continue into the normal Estate status view below.
                }
                else if (subCommand == "transport" || subCommand == "vehicles")
                {
                    HandleTransportProtectionCommand(player, args);
                    return;
                }
                else if (subCommand == "transportcheck" || subCommand == "vehiclecheck")
                {
                    HandleTransportCheckCommand(player);
                    return;
                }
                else
                {
                    player.ChatMessage("<color=#e0a000>[Estate Upkeep]</color> Unknown subcommand. Use /estate help");
                    return;
                }
            }

            BuildingPrivlidge tc = player.GetBuildingPrivilege();
            if (tc == null)
            {
                player.ChatMessage("<color=#e0a000>[Estate Upkeep]</color> Stand inside the privilege zone of the TC you want to inspect.");
                return;
            }

            EstateSnapshot snapshot = BuildEstateSnapshot();
            EstateTcSnapshot tcSnapshot;

            if (!snapshot.ByTc.TryGetValue(tc, out tcSnapshot))
            {
                player.ChatMessage("<color=#e0a000>[Estate Upkeep]</color> This TC currently has no eligible detached estate structures.");
                return;
            }

            EstateAccount account;
            _accounts.TryGetValue(tc, out account);

            player.ChatMessage("<color=#e0a000>[Estate Upkeep v1.0.1 — Estate]</color>");
            player.ChatMessage($"TC Net ID: {tc.net?.ID}");
            player.ChatMessage($"Detached buildings: {tcSnapshot.Buildings.Count}");
            player.ChatMessage($"Associated entities: {tcSnapshot.AssociatedEntities.Count}");
            player.ChatMessage(
                $"Extra /24h -> Wood {CeilPositive(tcSnapshot.Rate.WoodPerDay)}, " +
                $"Stone {CeilPositive(tcSnapshot.Rate.StonePerDay)}, " +
                $"Metal {CeilPositive(tcSnapshot.Rate.MetalPerDay)}, " +
                $"HQM {CeilPositive(tcSnapshot.Rate.HqmPerDay)}"
            );
            player.ChatMessage($"Status: {(account != null && account.Paid ? "<color=#9bd36a>PAID / PROTECTED / REPAIR ACTIVE</color>" : "<color=#ff8c42>UNPAID / DECAY ALLOWED</color>")}");
            player.ChatMessage("Automatic billing: ON | Persistent state: ON | Decay repair: ON");
            player.ChatMessage($"Transport anti-decay: {(_config.VehicleProtection.Enabled ? "ON" : "OFF")}");
        }

        private void SendEstateHelp(BasePlayer player)
        {
            player.ChatMessage("<color=#e0a000>[Estate Upkeep v1.0.1 — Help]</color>");
            player.ChatMessage("<color=#ffd479>by CrabSerg • A Copper Dreams project</color>");
            player.ChatMessage("/estate — status for the TC privilege zone you are standing in");
            player.ChatMessage("/estate status — same status view");
            player.ChatMessage("/estate transport status — transport anti-decay status");

            if (HasAdminAccess(player))
            {
                player.ChatMessage("<color=#ffd479>Admin:</color> /estate transport on|off");
                player.ChatMessage("<color=#ffd479>Admin:</color> /estate transportcheck");
                player.ChatMessage("<color=#ffd479>Admin:</color> /estatecheck");
                player.ChatMessage("<color=#ffd479>Admin:</color> /estateperf");
                player.ChatMessage("<color=#ffd479>Admin:</color> /estatechargecheck");
                player.ChatMessage("<color=#ffd479>Admin:</color> /estatecharge confirm");
                player.ChatMessage("<color=#ffd479>Admin:</color> /estaterepaircheck");
                player.ChatMessage("<color=#ffd479>Admin:</color> /estaterepairmark");
            }

            player.ChatMessage("<color=#9bd36a>Transport protection never consumes TC upkeep resources.</color>");
        }

        private void HandleTransportProtectionCommand(BasePlayer player, string[] args)
        {
            if (args.Length < 2)
            {
                player.ChatMessage("<color=#e0a000>[Estate Upkeep]</color> Usage: /estate transport on | off | status");
                return;
            }

            string action = args[1].ToLowerInvariant();

            if (action == "status")
            {
                SendTransportProtectionStatus(player);
                return;
            }

            if (!RequireAdmin(player))
                return;

            if (action != "on" && action != "off")
            {
                player.ChatMessage("<color=#e0a000>[Estate Upkeep]</color> Usage: /estate transport on | off | status");
                return;
            }

            bool enabled = action == "on";
            _config.VehicleProtection.Enabled = enabled;
            SavePluginConfig();

            player.ChatMessage(
                $"<color=#9bd36a>[Estate Upkeep]</color> Transport anti-decay protection: " +
                $"<color=#{(enabled ? "9bd36a" : "ff8c42")}>{(enabled ? "ON" : "OFF")}</color>"
            );

            player.ChatMessage(
                "Only Rust Decay damage is suppressed while supported transport is inside a TC privilege zone. " +
                "TC resources are never consumed by transport protection."
            );
        }

        private void SendTransportProtectionStatus(BasePlayer player)
        {
            player.ChatMessage("<color=#e0a000>[Estate Upkeep v1.0.1 — Transport Protection]</color>");
            player.ChatMessage($"Enabled: {(_config.VehicleProtection.Enabled ? "<color=#9bd36a>YES</color>" : "<color=#ff8c42>NO</color>")}");
            player.ChatMessage($"Prevent decay inside TC: {(_config.VehicleProtection.PreventDecayInsideTc ? "YES" : "NO")}");
            player.ChatMessage($"Land transport: {(_config.VehicleProtection.ProtectLand ? "YES" : "NO")}");
            player.ChatMessage($"Water transport: {(_config.VehicleProtection.ProtectWater ? "YES" : "NO")}");
            player.ChatMessage($"Air transport: {(_config.VehicleProtection.ProtectAir ? "YES" : "NO")}");
            player.ChatMessage($"Restart HP preservation: {(_config.VehicleProtection.RestoreProtectedHpAfterCleanRestart ? "YES" : "NO")}");
            player.ChatMessage($"Restart match radius: {_config.VehicleProtection.RestartMatchRadiusMeters:0.#} m");
            player.ChatMessage($"Protected transport/checkpoint refresh: {ProtectedTransportRefreshSeconds:0}s");
            player.ChatMessage($"Server-save checkpoint freeze: {ServerSaveCheckpointFreezeSeconds:0}s");
            player.ChatMessage(
                $"Startup reconcile window: immediate + " +
                $"{StartupTransportReconcileFirstDelaySeconds:0}s + " +
                $"{StartupTransportReconcileSecondDelaySeconds:0}s + " +
                $"{StartupTransportReconcileThirdDelaySeconds:0}s + " +
                $"{StartupTransportReconcileFinalDelaySeconds:0}s; transport spawns trigger extra passes"
            );
            player.ChatMessage("Startup restart matching requires current TC privilege: NO");
            player.ChatMessage("Restart detection: Rust process boot identity (clean-shutdown hook not required)");
            player.ChatMessage("Startup damage safety: canonical transport roots identify self/module damage; only external sourced damage blocks restore");
            player.ChatMessage($"Max process-restart checkpoint age: {RestartCheckpointMaxBootAgeSeconds:0}s");
            player.ChatMessage($"Legacy max checkpoint age at shutdown: {_config.VehicleProtection.RestartCheckpointMaxAgeAtShutdownSeconds:0}s");
            player.ChatMessage("Upkeep resource cost: 0 (transport protection never withdraws from TC)");
        }

        private void HandleTransportCheckCommand(BasePlayer player)
        {
            if (!RequireAdmin(player))
                return;

            BaseCombatEntity directHit = GetLookedAtCombatEntity(player);

            if (directHit == null)
            {
                player.ChatMessage("<color=#e0a000>[Estate Upkeep]</color> Look at the transport or something immediately beside/under it.");
                return;
            }

            BaseCombatEntity resolved;
            string category;
            bool resolvedByHierarchy =
                TryResolveProtectedTransport(directHit, out resolved, out category);

            if (!resolvedByHierarchy)
            {
                resolved = FindNearbySupportedTransport(directHit.transform.position, 5f);

                if (resolved != null && !TryClassifyProtectedTransport(resolved, out category))
                    resolved = null;
            }

            player.ChatMessage("<color=#e0a000>[Estate Upkeep v1.0.1 — Transport Check]</color>");
            player.ChatMessage($"Direct hit entity: {directHit.ShortPrefabName}");
            player.ChatMessage($"Direct hit runtime type: {directHit.GetType().Name}");

            if (resolved == null)
            {
                player.ChatMessage("<color=#ff8c42>Supported transport nearby/parent: NO</color>");
                player.ChatMessage($"Transport cache currently contains: {_cachedTransports.Count} entity(s).");
                return;
            }

            bool directIsTransport = resolved == directHit;
            bool parentResolution =
                !directIsTransport && IsTransformAncestor(resolved.transform, directHit.transform);
            bool nearbyResolution = !directIsTransport && !parentResolution;

            player.ChatMessage($"Resolved transport: {resolved.ShortPrefabName}");
            player.ChatMessage($"Resolved runtime type: {resolved.GetType().Name}");
            player.ChatMessage($"Resolved via: {(directIsTransport ? "DIRECT" : parentResolution ? "PARENT" : "NEARBY")}");
            player.ChatMessage($"Supported transport: <color=#9bd36a>YES</color>");
            player.ChatMessage($"Category: {category}");

            BuildingPrivlidge tc = GetSpatialPrivilege(resolved);
            player.ChatMessage($"TC privilege at transport: {(tc == null ? "NOT FOUND" : $"FOUND | Net ID: {tc.net?.ID}")}");
            player.ChatMessage($"Protection setting: {(_config.VehicleProtection.Enabled ? "ON" : "OFF")}");

            bool categoryEnabled = IsTransportCategoryEnabled(category);
            player.ChatMessage($"Category enabled: {(categoryEnabled ? "YES" : "NO")}");

            bool protectedNow =
                _config.VehicleProtection.Enabled &&
                _config.VehicleProtection.PreventDecayInsideTc &&
                categoryEnabled &&
                tc != null;

            if (protectedNow)
                _runtimeProtectedTransports.Add(resolved);
            else
                _runtimeProtectedTransports.Remove(resolved);

            player.ChatMessage($"Decay protected right now: {(protectedNow ? "<color=#9bd36a>YES</color>" : "<color=#ff8c42>NO</color>")}");
            player.ChatMessage($"Blocked decay events since load: {_blockedTransportDecayEvents}");
        }

        // Admin dry-run command: calculates the exact 24h estate charge against the
        // current TC inventory and DOES NOT modify any item or protection state.
        [ChatCommand("estatechargecheck")]
        private void EstateChargeCheckCommand(BasePlayer player, string command, string[] args)
        {
            if (player == null)
                return;

            if (!RequireAdmin(player))
                return;

            BuildingPrivlidge tc = player.GetBuildingPrivilege();
            if (tc == null)
            {
                player.ChatMessage("<color=#e0a000>[Estate Upkeep]</color> Stand inside the target TC privilege zone.");
                return;
            }

            EstateSnapshot snapshot = BuildEstateSnapshot();
            EstateTcSnapshot tcSnapshot;

            if (!snapshot.ByTc.TryGetValue(tc, out tcSnapshot))
            {
                player.ChatMessage("<color=#e0a000>[Estate Upkeep]</color> No eligible detached structures for this TC.");
                return;
            }

            int wood = CeilPositive(tcSnapshot.Rate.WoodPerDay);
            int stone = CeilPositive(tcSnapshot.Rate.StonePerDay);
            int metal = CeilPositive(tcSnapshot.Rate.MetalPerDay);
            int hqm = CeilPositive(tcSnapshot.Rate.HqmPerDay);

            int woodBefore = GetResourceAmount(tc.inventory, _woodItemId);
            int stoneBefore = GetResourceAmount(tc.inventory, _stoneItemId);
            int metalBefore = GetResourceAmount(tc.inventory, _metalItemId);
            int hqmBefore = GetResourceAmount(tc.inventory, _hqmItemId);

            bool enough =
                woodBefore >= wood &&
                stoneBefore >= stone &&
                metalBefore >= metal &&
                hqmBefore >= hqm;

            player.ChatMessage("<color=#e0a000>[Estate Upkeep v1.0.1 — DRY RUN]</color>");
            player.ChatMessage($"TC Net ID: {tc.net?.ID}");
            player.ChatMessage($"Detached buildings: {tcSnapshot.Buildings.Count}");
            player.ChatMessage($"Associated entities: {tcSnapshot.AssociatedEntities.Count}");

            player.ChatMessage("<color=#e0a000>Would consume for 24h:</color>");
            player.ChatMessage($"Wood {wood}, Stone {stone}, Metal {metal}, HQM {hqm}");

            player.ChatMessage("<color=#e0a000>TC totals BEFORE:</color>");
            player.ChatMessage($"Wood {woodBefore}, Stone {stoneBefore}, Metal {metalBefore}, HQM {hqmBefore}");

            player.ChatMessage("<color=#e0a000>TC totals AFTER (simulation):</color>");
            player.ChatMessage(
                $"Wood {woodBefore - wood}, Stone {stoneBefore - stone}, " +
                $"Metal {metalBefore - metal}, HQM {hqmBefore - hqm}"
            );

            player.ChatMessage($"Enough resources: {(enough ? "<color=#9bd36a>YES</color>" : "<color=#ff8c42>NO</color>")}");
            player.ChatMessage("<color=#9bd36a>DRY RUN ONLY — NOTHING WAS CONSUMED OR MODIFIED. Automatic billing continues independently.</color>");
        }

        [ChatCommand("estaterepaircheck")]
        private void EstateRepairCheckCommand(BasePlayer player, string command, string[] args)
        {
            if (player == null)
                return;

            if (!RequireAdmin(player))
                return;

            BaseCombatEntity target = GetLookedAtCombatEntity(player);
            if (target == null)
            {
                player.ChatMessage("<color=#e0a000>[Estate Upkeep]</color> Look directly at an estate block/entity.");
                return;
            }

            RefreshEstateMembership();

            BuildingPrivlidge tc;
            bool mapped = _estateTcByCombatEntity.TryGetValue(target, out tc);

            EstateAccount account = null;
            bool paid = mapped && tc != null && _accounts.TryGetValue(tc, out account) && account.Paid;

            float debt;
            _decayRepairDebt.TryGetValue(target, out debt);

            player.ChatMessage("<color=#e0a000>[Estate Upkeep v1.0.1 — Repair Check]</color>");
            player.ChatMessage($"Entity: {target.ShortPrefabName}");
            player.ChatMessage($"HP: {target.Health():0.##}/{target.MaxHealth():0.##}");
            player.ChatMessage($"Estate mapped: {(mapped ? "YES" : "NO")}");
            player.ChatMessage($"TC: {(tc == null ? "NONE" : tc.net?.ID.ToString())}");
            player.ChatMessage($"Paid/protected: {(paid ? "<color=#9bd36a>YES</color>" : "<color=#ff8c42>NO</color>")}");
            player.ChatMessage($"Tracked decay repair debt: {debt:0.##} HP");
            player.ChatMessage($"Repair tick: {RepairTickSeconds:0}s | Rate: {RepairFractionOfMaxHealthPerMinute * 100f:0.##}% max HP/min");
        }

        // Admin-only migration/test helper:
        // Marks the CURRENT missing HP on one looked-at eligible estate entity as
        // known historical decay damage. This is intentionally manual so raid or
        // other damage is never assumed to be decay.
        [ChatCommand("estaterepairmark")]
        private void EstateRepairMarkCommand(BasePlayer player, string command, string[] args)
        {
            if (player == null)
                return;

            if (!RequireAdmin(player))
                return;

            BaseCombatEntity target = GetLookedAtCombatEntity(player);
            if (target == null)
            {
                player.ChatMessage("<color=#e0a000>[Estate Upkeep]</color> Look directly at the damaged estate block/entity.");
                return;
            }

            RefreshEstateMembership();

            if (!_eligibleEstateCombatEntities.Contains(target))
            {
                player.ChatMessage("<color=#ff8c42>[Estate Upkeep]</color> Target is not currently an eligible detached-estate entity.");
                return;
            }

            float maxHealth = target.MaxHealth();
            float currentHealth = target.Health();
            float missing = Mathf.Max(0f, maxHealth - currentHealth);

            if (missing <= 0.01f)
            {
                player.ChatMessage("<color=#e0a000>[Estate Upkeep]</color> Target is already at full health.");
                return;
            }

            float existing;
            _decayRepairDebt.TryGetValue(target, out existing);

            // Set to at least the current missing HP; do not repeatedly stack the same deficit.
            _decayRepairDebt[target] = Mathf.Max(existing, missing);
            MarkDataDirty();

            player.ChatMessage(
                $"<color=#9bd36a>[Estate Upkeep]</color> Marked {missing:0.##} HP as historical decay repair debt."
            );
            player.ChatMessage(
                $"Current HP: {currentHealth:0.##}/{maxHealth:0.##}. Repair starts only while its Estate TC is PAID."
            );
        }

        // Admin test command: forces exactly one 24h estate bill immediately.
        // This REALLY removes resources if enough are present.
        [ChatCommand("estatecharge")]
        private void EstateChargeCommand(BasePlayer player, string command, string[] args)
        {
            if (player == null)
                return;

            if (!RequireAdmin(player))
                return;

            if (args == null || args.Length == 0 || !args[0].Equals("confirm", StringComparison.OrdinalIgnoreCase))
            {
                player.ChatMessage("<color=#ffd479>[Estate Upkeep]</color> Real charge is guarded.");
                player.ChatMessage("Run /estatechargecheck first. To actually remove resources, use: /estatecharge confirm");
                return;
            }

            BuildingPrivlidge tc = player.GetBuildingPrivilege();
            if (tc == null)
            {
                player.ChatMessage("<color=#e0a000>[Estate Upkeep]</color> Stand inside the target TC privilege zone.");
                return;
            }

            EstateSnapshot snapshot = BuildEstateSnapshot();
            EstateTcSnapshot tcSnapshot;

            if (!snapshot.ByTc.TryGetValue(tc, out tcSnapshot))
            {
                player.ChatMessage("<color=#e0a000>[Estate Upkeep]</color> No eligible detached structures for this TC.");
                return;
            }

            int wood = CeilPositive(tcSnapshot.Rate.WoodPerDay);
            int stone = CeilPositive(tcSnapshot.Rate.StonePerDay);
            int metal = CeilPositive(tcSnapshot.Rate.MetalPerDay);
            int hqm = CeilPositive(tcSnapshot.Rate.HqmPerDay);

            string failure;
            if (!TryConsumeAtomic(tc, wood, stone, metal, hqm, out failure))
            {
                player.ChatMessage($"<color=#ff8c42>[Estate Upkeep]</color> Forced 24h charge FAILED: {failure}");
                return;
            }

            EstateAccount account = GetOrCreateAccount(tc);
            account.Paid = true;
            account.LastFailure = string.Empty;

            RebuildProtectedEntitySet(snapshot);
            RefreshEstateMembership(snapshot);
            ProcessEstateRepair();
            MarkDataDirty();
            SavePersistentData(true);

            player.ChatMessage(
                $"<color=#9bd36a>[Estate Upkeep]</color> CONFIRMED 24h test charge PAID: " +
                $"Wood {wood}, Stone {stone}, Metal {metal}, HQM {hqm}."
            );
        }

        private object OnEntityTakeDamage(BaseCombatEntity entity, HitInfo info)
        {
            if (entity == null || info == null || info.damageTypes == null)
                return null;

            float decayDamage = info.damageTypes.Get(Rust.DamageType.Decay);

            if (decayDamage <= 0f)
            {
                if (_startupTransportReconcileActive)
                {
                    BaseCombatEntity startupTransportRoot;
                    string startupTransportCategory;

                    if (
                        TryResolveProtectedTransport(
                            entity,
                            out startupTransportRoot,
                            out startupTransportCategory
                        ) &&
                        IsTransportCategoryEnabled(startupTransportCategory)
                    )
                    {
                        // Important restart distinction:
                        //
                        // Rust can apply unsourced/server-side/physics HP changes while
                        // vehicles are settling during world startup. Those are exactly
                        // the losses the restart checkpoint is intended to reconcile.
                        //
                        // Real sourced damage has an Initiator (player/NPC/entity). In
                        // that case we disqualify the transport from startup healing so
                        // the plugin cannot undo legitimate damage after the server opens.
                        if (info.Initiator == null)
                        {
                            _startupUnsourcedTransportDamageEvents++;
                            _lastStartupDamageDiagnostic =
           
// ... [Code preview truncated due to length. Full source in attached file] ...

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