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] ...