C#:
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using Newtonsoft.Json;
using Oxide.Core;
using Oxide.Core.Libraries.Covalence;
using Oxide.Core.Plugins;
using UnityEngine;
namespace Oxide.Plugins {
[Info("SmartCleanup", "SeesAll", "0.4.1")]
[Description("Safe adaptive cleanup with persistent activity tracking, event protection, dry-runs, and bounded processing.")]
public class SmartCleanup : CovalencePlugin {
private const string PermAdmin = "smartcleanup.admin";
private const string DataFileName = "SmartCleanup_State";
private const double RunConfirmationLifetimeSeconds = 120d;
[PluginReference]
private Plugin RaidableBases;
[PluginReference]
private Plugin MonumentAddons;
private ConfigData _config;
private RuntimeSettings _runtime;
private readonly Dictionary<uint, TrackedEntity> _tracked = new Dictionary<uint, TrackedEntity>();
private readonly Dictionary<ulong, double> _lastOwnerSeenUtc = new Dictionary<ulong, double>();
private readonly HashSet<string> _deployablePrefabs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _whitelist = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _neverCleanupPrefabs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _alwaysCleanupPrefabs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<uint> _airfieldEventEntities = new HashSet<uint>();
private readonly HashSet<uint> _registeredProtectedEntities = new HashSet<uint>();
private readonly Dictionary<uint, double> _temporaryProtectedUntilUtc = new Dictionary<uint, double>();
private readonly Dictionary<string, double> _pendingRunConfirmations = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase);
private bool _initialized;
private bool _runInProgress;
private bool _rebuildInProgress;
private bool _airfieldEventActive;
private bool _dataDirty;
private bool _configLoadFailed;
private Timer _scheduledTimer;
private Timer _dataSaveTimer;
private Timer _reconciliationTimer;
private CleanupJob _activeJob;
private StoredData _storedData;
private List<NetworkableId> _rebuildIds;
private Dictionary<uint, TrackedEntity> _rebuildPrevious;
private int _rebuildIndex;
private bool _rebuildManual;
private string _rebuildRequesterId;
private enum CleanupMode {
DryRun, Execute
}
private enum EntityKind {
Unknown, Building, Deployable
}
private enum DeployableCategory {
Unknown, Production, Lighting, Trap, Utility, WaterAndFarming, ElectricalAndIndustrial, Storage, Workbench, Privilege, Commerce
}
private class CleanupJob {
public CleanupMode Mode;
public List<uint> CandidateIds;
public int Index;
public int Removed;
public int Evaluated;
public int SkippedProtected;
public int SkippedProtectedByCategory;
public int SkippedProtectedOwnerless;
public int SkippedProtectedByRecentActivity;
public int SkippedProtectedInsidePrivilege;
public int SkippedProtectedByCupboardAuth;
public int SkippedProtectedOutsideDisabled;
public int SkippedProtectedByEvent;
public int SkippedProtectedByConnectedStructure;
public int SkippedWhitelisted;
public int SkippedInactiveTooSoon;
public int SkippedHealthTooHigh;
public int SkippedMissing;
public int BuildingMatches;
public int DeployableMatches;
public bool ManualRequest;
public string Trigger;
public string RequesterId;
public string RequesterName;
public double StartedUtc;
public readonly Dictionary<uint, bool> ConnectedStructureProtection = new Dictionary<uint, bool>();
}
private class TrackedEntity {
public uint NetId;
public ulong OwnerId;
public EntityKind Kind;
public DeployableCategory DeployableCategory;
public string ShortPrefabName;
public string PrefabName;
public double FirstSeenUtc;
public double LastActivityUtc;
public double LastRefreshUtc;
public bool InsideMonument;
}
private class PersistedEntityState {
public double FirstSeenUtc;
public double LastActivityUtc;
}
private class StoredData {
public long WipeCreatedUtcTicks;
public Dictionary<ulong, double> LastOwnerSeenUtc = new Dictionary<ulong, double>();
public Dictionary<uint, PersistedEntityState> EntityStates = new Dictionary<uint, PersistedEntityState>();
public Dictionary<uint, double> TemporaryProtectedUntilUtc = new Dictionary<uint, double>();
}
private class RuntimeSettings {
public int ResolvedProfile;
public string ResolvedProfileName;
public double ScheduledEvaluationIntervalMinutes;
public int MaxCandidatesPerTick;
public double DeployablesOutsidePrivilegeCleanupHours;
public double DisconnectedStructuresCleanupHours;
public double InsidePrivilegeCleanupHours;
public float OutsideHealthFractionThreshold;
public float InsideHealthFractionThreshold;
public double ProtectRecentlyActivePlayersHours;
public bool CleanupBuildings;
public bool CleanupDeployables;
public bool RemoveInsidePrivilege;
public bool RemoveOutsidePrivilege;
public bool CheckCupboardAuthorization;
public bool AnnounceScheduledCleanup;
public bool AutoTuneEnabled;
}
#region Oxide Hooks
private void Init() {
permission.RegisterPermission(PermAdmin, this);
AddCovalenceCommand("smartcleanup", nameof(CommandSmartCleanup));
}
private void OnServerInitialized() {
LoadStoredData();
BuildDeployableLookup();
RebuildWhitelist();
RebuildPrefabOverrides();
ResolveRuntimeSettings(writeToConfig : false, logResolution : true);
SeedRecentPlayers();
_initialized = true;
StartDataSaveTimer();
StartTrackingIndexRebuild(manualRequest : false, requester : null);
}
private void Unload() {
_scheduledTimer?.Destroy();
_scheduledTimer = null;
_dataSaveTimer?.Destroy();
_dataSaveTimer = null;
_reconciliationTimer?.Destroy();
_reconciliationTimer = null;
SaveStoredData(force : true);
_tracked.Clear();
_lastOwnerSeenUtc.Clear();
_airfieldEventEntities.Clear();
_registeredProtectedEntities.Clear();
_temporaryProtectedUntilUtc.Clear();
_pendingRunConfirmations.Clear();
_rebuildIds = null;
_rebuildPrevious = null;
_activeJob = null;
_runInProgress = false;
_rebuildInProgress = false;
_initialized = false;
}
private void OnServerSave() {
SaveStoredData(force : false);
}
private void OnNewSave(string filename) {
_lastOwnerSeenUtc.Clear();
_temporaryProtectedUntilUtc.Clear();
_storedData = new StoredData {
WipeCreatedUtcTicks = GetWipeCreatedUtcTicks()
}
;
_dataDirty = true;
SaveStoredData(force : true);
}
private void OnPlayerConnected(BasePlayer player) {
if (player == null || player.userID == 0UL) {
return;
}
_lastOwnerSeenUtc[player.userID] = UtcNow();
MarkDataDirty();
}
private void OnPlayerDisconnected(BasePlayer player, string reason) {
if (player == null || player.userID == 0UL) {
return;
}
_lastOwnerSeenUtc[player.userID] = UtcNow();
MarkDataDirty();
}
private void OnEntitySpawned(BaseNetworkable networkable) {
if (!_initialized) {
return;
}
var entity = networkable as BaseEntity;
if (entity == null) {
return;
}
NextTick(() => {
if (entity == null || entity.IsDestroyed || entity.net == null) {
return;
}
var id = (uint)entity.net.ID.Value;
if (_airfieldEventActive) {
_airfieldEventEntities.Add(id);
}
TryTrackEntity(entity);
}
);
}
private void OnEntityKill(BaseNetworkable networkable) {
var entity = networkable as BaseEntity;
if (entity?.net == null) {
return;
}
var id = (uint)entity.net.ID.Value;
_tracked.Remove(id);
_airfieldEventEntities.Remove(id);
_registeredProtectedEntities.Remove(id);
_temporaryProtectedUntilUtc.Remove(id);
if (_storedData?.EntityStates != null) {
_storedData.EntityStates.Remove(id);
}
MarkDataDirty();
}
private void OnEntityTakeDamage(BaseCombatEntity combatEntity, HitInfo info) {
if (combatEntity?.net == null) {
return;
}
TrackedEntity tracked;
if (_tracked.TryGetValue((uint)combatEntity.net.ID.Value, out tracked)) {
MarkEntityActivity(tracked, UtcNow());
}
}
private void OnStructureRepair(BaseCombatEntity entity, BasePlayer player) {
MarkEntityActivity(entity);
}
private void OnStructureUpgrade(BuildingBlock block, BasePlayer player, BuildingGrade.Enum grade) {
MarkEntityActivity(block);
}
private void AirfieldEventStarted() {
_airfieldEventActive = true;
Puts("AirfieldEvent started; newly spawned entities will be protected from cleanup.");
}
private void AirfieldEventEnded() {
_airfieldEventActive = false;
_airfieldEventEntities.Clear();
Puts("AirfieldEvent ended; temporary spawn protection was released.");
}
private void OnPasteFinished(List<BaseEntity> pastedEntities, string filename, BasePlayer player, Vector3 position) {
if (!_config.EventSafety.Enabled || pastedEntities == null || _config.EventSafety.CopyPasteProtectionHours <= 0d) {
return;
}
var expires = UtcNow() + (_config.EventSafety.CopyPasteProtectionHours * 3600d);
foreach (var entity in pastedEntities) {
if (entity?.net == null || entity.IsDestroyed) {
continue;
}
_temporaryProtectedUntilUtc[(uint)entity.net.ID.Value] = expires;
}
MarkDataDirty();
}
private void OnRaidableBaseStarted(object payload) {
RegisterRaidableBasePayload(payload);
}
private void OnRaidableBaseEnded(object payload) {
UnregisterRaidableBasePayload(payload);
}
private void OnRaidableBaseDespawned(object payload) {
UnregisterRaidableBasePayload(payload);
}
#endregion
#region Commands
private void CommandSmartCleanup(IPlayer player, string command, string[] args) {
if (!IsAuthorized(player)) {
Reply(player, "You do not have permission to use SmartCleanup.");
return;
}
if (args == null || args.Length == 0) {
Reply(player, "SmartCleanup commands: /smartcleanup status, dryrun, run confirm, retune, rebuild, help");
return;
}
switch (args[0].ToLowerInvariant()) {
case "help" : Reply(player, "Commands: /smartcleanup status, /smartcleanup dryrun, /smartcleanup run confirm, /smartcleanup retune, /smartcleanup rebuild");
break;
case "status" : Reply(player, BuildStatusText());
break;
case "dryrun" : if (_runInProgress || _rebuildInProgress) {
Reply(player, "A SmartCleanup job or index rebuild is already running.");
return;
}
StartCleanupJob(CleanupMode.DryRun, manualRequest : true, trigger : $"manual dryrun by {player.Name}", requester : player);
break;
case "run" : if (_runInProgress || _rebuildInProgress) {
Reply(player, "A SmartCleanup job or index rebuild is already running.");
return;
}
if (args.Length < 2 || !args[1].Equals("confirm", StringComparison.OrdinalIgnoreCase)) {
Reply(player, "Cleanup execution is locked. Run /smartcleanup dryrun first, review the result, then use /smartcleanup run confirm within 120 seconds.");
return;
}
double confirmationExpires;
if (!_pendingRunConfirmations.TryGetValue(player.Id, out confirmationExpires) || confirmationExpires < UtcNow()) {
_pendingRunConfirmations.Remove(player.Id);
Reply(player, "No current dry-run approval exists. Run /smartcleanup dryrun first.");
return;
}
_pendingRunConfirmations.Remove(player.Id);
StartCleanupJob(CleanupMode.Execute, manualRequest : true, trigger : $"manual run by {player.Name}", requester : player);
break;
case "retune" : RebuildWhitelist();
RebuildPrefabOverrides();
ResolveRuntimeSettings(writeToConfig : _config.AutoTuneWriteToConfig, logResolution : true);
StartSchedule();
Reply(player, $"Retuned SmartCleanup. Active profile: {_runtime.ResolvedProfileName} ({_runtime.ResolvedProfile}).");
break;
case "rebuild" : if (_runInProgress || _rebuildInProgress) {
Reply(player, "A SmartCleanup job or index rebuild is already running.");
return;
}
RebuildWhitelist();
RebuildPrefabOverrides();
StartTrackingIndexRebuild(manualRequest : true, requester : player);
break;
default : Reply(player, "Unknown command. Use /smartcleanup help");
break;
}
}
#endregion
#region Tracking / Classification
private void StartTrackingIndexRebuild(bool manualRequest, IPlayer requester) {
if (_rebuildInProgress || _runInProgress) {
Reply(requester, "A SmartCleanup job or index rebuild is already running.");
return;
}
_rebuildInProgress = true;
_rebuildManual = manualRequest;
_rebuildRequesterId = requester?.Id;
_rebuildPrevious = new Dictionary<uint, TrackedEntity>(_tracked);
_tracked.Clear();
_rebuildIds = new List<NetworkableId>();
_rebuildIndex = 0;
foreach (var networkable in BaseNetworkable.serverEntities) {
if (networkable?.net != null) {
_rebuildIds.Add(networkable.net.ID);
}
}
if (manualRequest) {
Reply(requester, $"Started bounded tracking rebuild for {_rebuildIds.Count} server entities.");
}
ProcessTrackingIndexBatch();
}
private void ProcessTrackingIndexBatch() {
if (!_rebuildInProgress || _rebuildIds == null) {
return;
}
var budget = Mathf.Clamp(_config.AdvancedPerformance.IndexCandidatesPerTick, 50, 5000);
var processed = 0;
while (processed < budget && _rebuildIndex < _rebuildIds.Count) {
var entity = BaseNetworkable.serverEntities.Find(_rebuildIds[_rebuildIndex++]) as BaseEntity;
processed++;
if (entity != null && !entity.IsDestroyed) {
TryTrackEntity(entity, _rebuildPrevious);
}
}
if (_rebuildIndex < _rebuildIds.Count) {
NextTick(ProcessTrackingIndexBatch);
return;
}
FinishTrackingIndexRebuild();
}
private void FinishTrackingIndexRebuild() {
_rebuildInProgress = false;
_rebuildIds = null;
_rebuildPrevious = null;
PruneStoredEntityStates();
SaveStoredData(force : false);
var message = $"Tracking rebuild complete. Tracking {_tracked.Count} cleanup candidate(s).";
if (_rebuildManual && !string.IsNullOrEmpty(_rebuildRequesterId)) {
Reply(players.FindPlayerById(_rebuildRequesterId), message);
}
else {
Puts(message);
}
_rebuildManual = false;
_rebuildRequesterId = null;
StartSchedule();
StartReconciliationTimer();
}
private void TryTrackEntity(BaseEntity entity, Dictionary<uint, TrackedEntity> previous = null) {
if (entity == null || entity.IsDestroyed || entity.net == null) {
return;
}
if (entity.OwnerID == 0UL) {
return;
}
if (entity.parentEntity.IsSet()) {
return;
}
var kind = Classify(entity);
if (kind == EntityKind.Unknown) {
return;
}
var id = (uint)entity.net.ID.Value;
var now = UtcNow();
TrackedEntity tracked;
if (!_tracked.TryGetValue(id, out tracked)) {
TrackedEntity existing = null;
if (previous != null) {
previous.TryGetValue(id, out existing);
}
PersistedEntityState persistedState = null;
_storedData?.EntityStates?.TryGetValue(id, out persistedState);
var firstSeen = existing?.FirstSeenUtc ?? persistedState?.FirstSeenUtc ?? now;
var lastActivity = Math.Max(existing?.LastActivityUtc ?? 0d, persistedState?.LastActivityUtc ?? 0d);
tracked = new TrackedEntity {
NetId = id, OwnerId = entity.OwnerID, Kind = kind, DeployableCategory = kind == EntityKind.Deployable ? GetDeployableCategory(entity) : DeployableCategory.Unknown, ShortPrefabName = entity.ShortPrefabName ?? string.Empty, PrefabName = entity.PrefabName ?? string.Empty, FirstSeenUtc = firstSeen, LastActivityUtc = lastActivity, LastRefreshUtc = now, InsideMonument = IsInsideMonument(entity.transform.position)
}
;
_tracked[id] = tracked;
StoreTrackedEntityState(tracked);
return;
}
tracked.OwnerId = entity.OwnerID;
tracked.Kind = kind;
tracked.DeployableCategory = kind == EntityKind.Deployable ? GetDeployableCategory(entity) : DeployableCategory.Unknown;
tracked.ShortPrefabName = entity.ShortPrefabName ?? string.Empty;
tracked.PrefabName = entity.PrefabName ?? string.Empty;
tracked.LastRefreshUtc = now;
tracked.InsideMonument = IsInsideMonument(entity.transform.position);
StoreTrackedEntityState(tracked);
}
private EntityKind Classify(BaseEntity entity) {
if (entity == null) {
return EntityKind.Unknown;
}
if (entity is StabilityEntity) {
return EntityKind.Building;
}
if (_deployablePrefabs.Contains(entity.PrefabName ?? string.Empty) || _deployablePrefabs.Contains(entity.ShortPrefabName ?? string.Empty) || _deployablePrefabs.Contains(entity.gameObject?.name ?? string.Empty)) {
return EntityKind.Deployable;
}
return EntityKind.Unknown;
}
private void BuildDeployableLookup() {
_deployablePrefabs.Clear();
foreach (var itemDef in ItemManager.GetItemDefinitions()) {
var modDeployable = itemDef?.GetComponent<ItemModDeployable>();
if (modDeployable?.entityPrefab == null) {
continue;
}
var path = modDeployable.entityPrefab.resourcePath ?? string.Empty;
if (!string.IsNullOrEmpty(path)) {
_deployablePrefabs.Add(path);
_deployablePrefabs.Add(path.ToLowerInvariant());
var pathParts = path.Split('/');
var fileName = pathParts.Length > 0 ? pathParts[pathParts.Length - 1] : path;
_deployablePrefabs.Add(fileName);
}
}
}
private void RebuildWhitelist() {
_whitelist.Clear();
foreach (var entry in _config.Whitelist ?? new string[0]) {
var normalized = NormalizePrefabKey(entry);
if (string.IsNullOrEmpty(normalized)) {
continue;
}
_whitelist.Add(normalized);
}
}
private void RebuildPrefabOverrides() {
_neverCleanupPrefabs.Clear();
_alwaysCleanupPrefabs.Clear();
foreach (var entry in _config.NeverCleanupPrefabs ?? new string[0]) {
var normalized = NormalizePrefabKey(entry);
if (!string.IsNullOrEmpty(normalized)) {
_neverCleanupPrefabs.Add(normalized);
}
}
foreach (var entry in _config.AlwaysCleanupPrefabs ?? new string[0]) {
var normalized = NormalizePrefabKey(entry);
if (!string.IsNullOrEmpty(normalized)) {
_alwaysCleanupPrefabs.Add(normalized);
}
}
}
private string NormalizePrefabKey(string value) {
return string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim().ToLowerInvariant();
}
private bool MatchesPrefabList(HashSet<string> set, BaseEntity entity, TrackedEntity tracked) {
if (set == null || set.Count == 0 || entity == null) {
return false;
}
return set.Contains(NormalizePrefabKey(entity.ShortPrefabName)) || set.Contains(NormalizePrefabKey(entity.PrefabName)) || set.Contains(NormalizePrefabKey(entity.gameObject?.name)) || (tracked != null && (set.Contains(NormalizePrefabKey(tracked.ShortPrefabName)) || set.Contains(NormalizePrefabKey(tracked.PrefabName))));
}
private DeployableCategory GetDeployableCategory(BaseEntity entity) {
var shortName = NormalizePrefabKey(entity?.ShortPrefabName);
var prefabName = NormalizePrefabKey(entity?.PrefabName);
if (ContainsAny(shortName, prefabName, "cupboard.tool", "cupboard.tool.deployed")) return DeployableCategory.Privilege;
if (ContainsAny(shortName, prefabName, "workbench1.deployed", "workbench2.deployed", "workbench3.deployed")) return DeployableCategory.Workbench;
if (ContainsAny(shortName, prefabName, "vendingmachine.deployed", "shopfront.deployed")) return DeployableCategory.Commerce;
if (ContainsAny(shortName, prefabName, "woodbox_deployed", "box.wooden.large", "coffin.storage", "small_stash_deployed", "fridge.deployed", "locker.deployed", "dropbox.deployed")) return DeployableCategory.Storage;
if (ContainsAny(shortName, prefabName, "repairbench_deployed", "researchtable_deployed", "mixingtable.deployed")) return DeployableCategory.Utility;
if (ContainsAny(shortName, prefabName, "furnace", "furnace.large", "refinery_small_deployed", "campfire", "bbq.deployed", "fireplace.deployed", "hobobarrel.deployed", "smoker.deployed", "oven")) return DeployableCategory.Production;
if (ContainsAny(shortName, prefabName, "lantern.deployed", "lantern", "tunalight.deployed", "tuna-can-lamp.deployed", "ceilinglight.deployed", "searchlight.deployed", "light", "candlehat")) return DeployableCategory.Lighting;
if (ContainsAny(shortName, prefabName, "beartrap", "bear.trap", "landmine", "spikes.floor", "spikes.floor.metal", "floor.spikes", "spike", "trap", "barricade.concrete", "barricade.metal", "barricade.wood")) return DeployableCategory.Trap;
if (ContainsAny(shortName, prefabName, "water_catcher_small", "water_catcher_large", "planter.small", "planter.large", "composter", "sprinkler.deployed")) return DeployableCategory.WaterAndFarming;
if (ContainsAny(shortName, prefabName, "electric", "battery", "generator", "switch", "conveyor", "industrial", "splitter", "combiner")) return DeployableCategory.ElectricalAndIndustrial;
return DeployableCategory.Unknown;
}
private bool ContainsAny(string shortName, string prefabName, params string[] patterns) {
foreach (var pattern in patterns) {
var normalized = NormalizePrefabKey(pattern);
if ((!string.IsNullOrEmpty(shortName) && shortName.Contains(normalized)) || (!string.IsNullOrEmpty(prefabName) && prefabName.Contains(normalized))) {
return true;
}
}
return false;
}
private bool IsDeployableCategoryEnabled(DeployableCategory category) {
switch (category) {
case DeployableCategory.Production : return _config.CategorySettings.CleanupProductionDeployables;
case DeployableCategory.Lighting : return _config.CategorySettings.CleanupLightingDeployables;
case DeployableCategory.Trap : return _config.CategorySettings.CleanupTrapDeployables;
case DeployableCategory.Utility : return _config.CategorySettings.CleanupUtilityDeployables;
case DeployableCategory.WaterAndFarming : return _config.CategorySettings.CleanupWaterAndFarmingDeployables;
case DeployableCategory.ElectricalAndIndustrial : return _config.CategorySettings.CleanupElectricalAndIndustrialDeployables;
case DeployableCategory.Storage : return _config.CategorySettings.CleanupStorageDeployables;
case DeployableCategory.Workbench : return _config.CategorySettings.CleanupWorkbenchDeployables;
case DeployableCategory.Privilege : return _config.CategorySettings.CleanupPrivilegeDeployables;
case DeployableCategory.Commerce : return _config.CategorySettings.CleanupCommerceDeployables;
default : return false;
}
}
private void SeedRecentPlayers() {
var now = UtcNow();
foreach (var player in BasePlayer.activePlayerList) {
if (player == null || player.userID == 0UL) {
continue;
}
_lastOwnerSeenUtc[player.userID] = now;
}
}
#endregion
#region Scheduling / Jobs
private void StartSchedule() {
_scheduledTimer?.Destroy();
_scheduledTimer = null;
if (_configLoadFailed) {
PrintError("Scheduled cleanup is disabled because the configuration failed to load. Repair or regenerate the config, then reload SmartCleanup.");
return;
}
if (_config.AdvancedTesting.DisableScheduledCleanupForTesting) {
Puts("Scheduled cleanup is disabled by testing mode.");
return;
}
if (_runtime.ScheduledEvaluationIntervalMinutes <= 0d) {
Puts("Scheduled cleanup is disabled.");
return;
}
_scheduledTimer = timer.Every((float)(_runtime.ScheduledEvaluationIntervalMinutes * 60d), () => {
if (_runInProgress || _rebuildInProgress) {
return;
}
StartCleanupJob(CleanupMode.Execute, manualRequest : false, trigger : "scheduled run", requester : null);
}
);
Puts($"Scheduled cleanup every {_runtime.ScheduledEvaluationIntervalMinutes:0.##} minute(s).");
}
private void StartCleanupJob(CleanupMode mode, bool manualRequest, string trigger, IPlayer requester) {
if (_runInProgress || _rebuildInProgress) {
Reply(requester, "A SmartCleanup job or index rebuild is already running.");
return;
}
_runInProgress = true;
_activeJob = new CleanupJob {
Mode = mode, CandidateIds = _tracked.Keys.ToList(), Index = 0, ManualRequest = manualRequest, Trigger = trigger, RequesterId = requester?.Id, RequesterName = requester?.Name, StartedUtc = UtcNow()
}
;
var startMessage = $"SmartCleanup {(mode == CleanupMode.DryRun ? "dry-run" : "cleanup")} started ({trigger}). Tracking {_activeJob.CandidateIds.Count} candidate(s).";
if (manualRequest && requester != null) {
Reply(requester, startMessage);
}
else if (_runtime.AnnounceScheduledCleanup) {
Broadcast(startMessage);
}
ProcessCleanupBatch();
}
private void ProcessCleanupBatch() {
if (_activeJob == null) {
_runInProgress = false;
return;
}
var processed = 0;
while (processed < _runtime.MaxCandidatesPerTick && _activeJob.Index < _activeJob.CandidateIds.Count) {
var id = _activeJob.CandidateIds[_activeJob.Index++];
processed++;
EvaluateCandidate(id, _activeJob);
}
if (_activeJob.Index >= _activeJob.CandidateIds.Count) {
FinishCleanupJob();
return;
}
NextTick(ProcessCleanupBatch);
}
private void FinishCleanupJob() {
var job = _activeJob;
_activeJob = null;
_runInProgress = false;
var elapsedSeconds = Math.Max(0d, UtcNow() - job.StartedUtc);
var matchCount = job.BuildingMatches + job.DeployableMatches;
var protectedBreakdown = $"Protected={job.SkippedProtected} [Event={job.SkippedProtectedByEvent}, Connected={job.SkippedProtectedByConnectedStructure}, Category={job.SkippedProtectedByCategory}, Ownerless={job.SkippedProtectedOwnerless}, Recent={job.SkippedProtectedByRecentActivity}, InsidePrivilege={job.SkippedProtectedInsidePrivilege}, CupboardAuth={job.SkippedProtectedByCupboardAuth}, OutsideDisabled={job.SkippedProtectedOutsideDisabled}]";
var actionSegment = job.Mode == CleanupMode.DryRun ? $"WouldRemove={matchCount}, Removed=0" : $"Matches={matchCount}, Removed={job.Removed}";
var summary = $"SmartCleanup {(job.Mode == CleanupMode.DryRun ? "dry-run" : "run")} finished. " + $"Evaluated={job.Evaluated}, {actionSegment}, " + $"Buildings={job.BuildingMatches}, Deployables={job.DeployableMatches}, " + $"{protectedBreakdown}, TooSoon={job.SkippedInactiveTooSoon}, HealthTooHigh={job.SkippedHealthTooHigh}, " + $"Whitelisted={job.SkippedWhitelisted}, Missing={job.SkippedMissing}, Elapsed={elapsedSeconds:0.##}s.";
if (ShouldLogSummary(job)) {
Puts(summary);
}
if (job.ManualRequest && !string.IsNullOrEmpty(job.RequesterId)) {
var requester = players.FindPlayerById(job.RequesterId);
if (requester != null) {
Reply(requester, summary);
if (job.Mode == CleanupMode.DryRun) {
_pendingRunConfirmations[job.RequesterId] = UtcNow() + RunConfirmationLifetimeSeconds;
Reply(requester, "Dry-run reviewed. To execute this result, use /smartcleanup run confirm within 120 seconds.");
}
}
}
else if (_runtime.AnnounceScheduledCleanup && (job.Mode == CleanupMode.DryRun || job.Removed > 0 || _config.AdvancedLogging.DebugLogging)) {
Broadcast(summary);
}
else if (_config.AdvancedLogging.NotifyAdminsOnScheduledCleanup) {
NotifyAdmins(summary);
}
}
private bool ShouldLogSummary(CleanupJob job) {
if (job == null) {
return false;
}
if (job.ManualRequest) {
return true;
}
if (_config.AdvancedLogging.DebugLogging) {
return true;
}
if (!_config.AdvancedLogging.LogScheduledCleanupSummary) {
return false;
}
if (_config.AdvancedLogging.LogScheduledSummariesOnlyWhenRemovalsOccur) {
return job.Removed > 0;
}
return true;
}
private void EvaluateCandidate(uint id, CleanupJob job) {
TrackedEntity tracked;
if (!_tracked.TryGetValue(id, out tracked)) {
job.SkippedMissing++;
return;
}
var entity = BaseNetworkable.serverEntities.Find(new NetworkableId(id)) as BaseEntity;
if (entity == null || entity.IsDestroyed) {
_tracked.Remove(id);
job.SkippedMissing++;
return;
}
if (entity.OwnerID == 0UL || entity.parentEntity.IsSet()) {
_tracked.Remove(id);
job.SkippedMissing++;
return;
}
tracked.LastRefreshUtc = UtcNow();
job.Evaluated++;
if (MatchesPrefabList(_whitelist, entity, tracked) || MatchesPrefabList(_neverCleanupPrefabs, entity, tracked)) {
job.SkippedWhitelisted++;
return;
}
var evaluation = ShouldCleanup(entity, tracked, job);
if (!evaluation.ShouldRemove) {
switch (evaluation.Reason) {
case SkipReason.Protected : job.SkippedProtected++;
break;
case SkipReason.ProtectedByCategory : job.SkippedProtected++;
job.SkippedProtectedByCategory++;
break;
case SkipReason.ProtectedOwnerless : job.SkippedProtected++;
job.SkippedProtectedOwnerless++;
break;
case SkipReason.ProtectedByRecentActivity : job.SkippedProtected++;
job.SkippedProtectedByRecentActivity++;
break;
case SkipReason.ProtectedInsidePrivilege : job.SkippedProtected++;
job.SkippedProtectedInsidePrivilege++;
break;
case SkipReason.ProtectedByCupboardAuth : job.SkippedProtected++;
job.SkippedProtectedByCupboardAuth++;
break;
case SkipReason.ProtectedOutsideDisabled : job.SkippedProtected++;
job.SkippedProtectedOutsideDisabled++;
break;
case SkipReason.ProtectedByEvent : job.SkippedProtected++;
job.SkippedProtectedByEvent++;
break;
case SkipReason.ProtectedByConnectedStructure : job.SkippedProtected++;
job.SkippedProtectedByConnectedStructure++;
break;
case SkipReason.TooSoon : job.SkippedInactiveTooSoon++;
break;
case SkipReason.HealthTooHigh : job.SkippedHealthTooHigh++;
break;
}
return;
}
if (tracked.Kind == EntityKind.Building) {
job.BuildingMatches++;
}
else if (tracked.Kind == EntityKind.Deployable) {
job.DeployableMatches++;
}
if (job.Mode == CleanupMode.Execute) {
entity.Kill(BaseNetworkable.DestroyMode.Gib);
_tracked.Remove(id);
job.Removed++;
}
}
private enum SkipReason {
None, Protected, ProtectedByCategory, ProtectedOwnerless, ProtectedByRecentActivity, ProtectedInsidePrivilege, ProtectedByCupboardAuth, ProtectedOutsideDisabled, ProtectedByEvent, ProtectedByConnectedStructure, TooSoon, HealthTooHigh
}
private struct EvaluationResult {
public bool ShouldRemove;
public SkipReason Reason;
}
private EvaluationResult ShouldCleanup(BaseEntity entity, TrackedEntity tracked, CleanupJob job) {
var result = new EvaluationResult {
ShouldRemove = false, Reason = SkipReason.None
}
;
if (IsProtectedEventEntity(entity, tracked)) {
result.Reason = SkipReason.ProtectedByEvent;
return result;
}
if (tracked.Kind == EntityKind.Building && IsConnectedStructureProtected(entity as BuildingBlock, job)) {
result.Reason = SkipReason.ProtectedByConnectedStructure;
return result;
}
if (tracked.Kind == EntityKind.Building && !_runtime.CleanupBuildings) {
return result;
}
if (tracked.Kind == EntityKind.Deployable) {
if (!_runtime.CleanupDeployables) {
return result;
}
var forceAllow = MatchesPrefabList(_alwaysCleanupPrefabs, entity, tracked);
if (!forceAllow && !IsDeployableCategoryEnabled(tracked.DeployableCategory)) {
result.Reason = SkipReason.ProtectedByCategory;
return result;
}
}
if (entity.OwnerID == 0UL) {
result.Reason = SkipReason.ProtectedOwnerless;
return result;
}
if (IsProtectedByRecentActivity(entity.OwnerID)) {
result.Reason = SkipReason.ProtectedByRecentActivity;
return result;
}
var privilege = entity.GetBuildingPrivilege();
var hasPrivilege = privilege != null;
var now = UtcNow();
var lastEntityActivity = Math.Max(tracked.FirstSeenUtc, tracked.LastActivityUtc);
var ageHours = Math.Max(0d, (now - lastEntityActivity) / 3600d);
var healthFraction = GetHealthFraction(entity);
if (hasPrivilege) {
if (!_runtime.RemoveInsidePrivilege) {
result.Reason = SkipReason.ProtectedInsidePrivilege;
return result;
}
if (_runtime.CheckCupboardAuthorization && privilege.IsAuthed(entity.OwnerID)) {
result.Reason = SkipReason.ProtectedByCupboardAuth;
return result;
}
if (_runtime.InsidePrivilegeCleanupHours > 0d && ageHours < _runtime.InsidePrivilegeCleanupHours) {
result.Reason = SkipReason.TooSoon;
return result;
}
if (_runtime.InsideHealthFractionThreshold > 0f && healthFraction > _runtime.InsideHealthFractionThreshold) {
result.Reason = SkipReason.HealthTooHigh;
return result;
}
result.ShouldRemove = true;
return result;
}
if (!_runtime.RemoveOutsidePrivilege) {
result.Reason = SkipReason.ProtectedOutsideDisabled;
return result;
}
var requiredHours = tracked.Kind == EntityKind.Building ? _runtime.DisconnectedStructuresCleanupHours : _runtime.DeployablesOutsidePrivilegeCleanupHours;
if (requiredHours > 0d && ageHours < requiredHours) {
result.Reason = SkipReason.TooSoon;
return result;
}
if (_runtime.OutsideHealthFractionThreshold > 0f && healthFraction > _runtime.OutsideHealthFractionThreshold) {
result.Reason = SkipReason.HealthTooHigh;
return result;
}
result.ShouldRemove = true;
return result;
}
#endregion
#region Runtime Resolution / Auto Tune
private void ResolveRuntimeSettings(bool writeToConfig, bool logResolution) {
var resolvedProfile = ResolveProfile();
var settings = GetProfileDefaults(resolvedProfile);
ApplyOverrides(settings);
_runtime = settings;
if (writeToConfig) {
if (_config.ServerProfile == 1 && _config.AutoTuneWriteToConfig) {
_config.ServerProfile = resolvedProfile;
PrintWarning($"AutoTuneWriteToConfig selected and saved profile {resolvedProfile} ({settings.ResolvedProfileName}). Auto-detection is now pinned until ServerProfile is set back to 1.");
}
SaveConfig();
}
if (logResolution) {
Puts($"Active profile: {_runtime.ResolvedProfileName} ({_runtime.ResolvedProfile}). " + $"Buildings={_runtime.CleanupBuildings}, Deployables={_runtime.CleanupDeployables}, " + $"OutsideHours(Buildings/Deployables)={_runtime.DisconnectedStructuresCleanupHours:0.##}/{_runtime.DeployablesOutsidePrivilegeCleanupHours:0.##}, " + $"InsideHours={_runtime.InsidePrivilegeCleanupHours:0.##}, Batch={_runtime.MaxCandidatesPerTick}, Interval={_runtime.ScheduledEvaluationIntervalMinutes:0.##}m.");
}
}
private int ResolveProfile() {
var configured = Mathf.Clamp(_config.ServerProfile, 1, 5);
if (configured != 1 || !_config.AutoTuneEnabled) {
return configured;
}
var score = 0;
var decayScale = SafeDecayScale();
var decayTick = SafeDecayTick();
var cleanupInterval = SafeCleanupInterval();
var itemDespawn = SafeItemDespawn();
if (decayScale > 0f && decayScale < 0.65f) {
score += 2;
}
else if (decayScale >= 0.65f && decayScale <= 0.9f) {
score += 1;
}
if (decayTick >= 1200f) {
score += 2;
}
else if (decayTick >= 600f) {
score += 1;
}
if (cleanupInterval >= 1800f) {
score += 2;
}
else if (cleanupInterval >= 900f) {
score += 1;
}
if (itemDespawn >= 600f) {
score += 1;
}
var entityCount = BaseNetworkable.serverEntities?.Count ?? 0;
if (entityCount >= 250000) {
score += 2;
}
else if (entityCount >= 150000) {
score += 1;
}
var onlineCount = BasePlayer.activePlayerList?.Count ?? 0;
if (onlineCount >= 120) {
score += 2;
}
else if (onlineCount >= 60) {
score += 1;
}
if (score >= 6) {
return 2;
// FastPvP-like
}
if (score >= 3) {
return 3;
// Balanced
}
return 4;
// PvE conservative fallback
}
private RuntimeSettings GetProfileDefaults(int profile) {
switch (profile) {
case 2 : return new RuntimeSettings {
ResolvedProfile = 2, ResolvedProfileName = "FastPvP", ScheduledEvaluationIntervalMinutes = 20d, MaxCandidatesPerTick = 150, DeployablesOutsidePrivilegeCleanupHours = 24d, DisconnectedStructuresCleanupHours = 48d, InsidePrivilegeCleanupHours = 0d, OutsideHealthFractionThreshold = 0.75f, InsideHealthFractionThreshold = 0f, ProtectRecentlyActivePlayersHours = 24d, CleanupBuildings = true, CleanupDeployables = true, RemoveInsidePrivilege = false, RemoveOutsidePrivilege = true, CheckCupboardAuthorization = true, AnnounceScheduledCleanup = false, AutoTuneEnabled = _config.AutoTuneEnabled
}
;
case 3 : return new RuntimeSettings {
ResolvedProfile = 3, ResolvedProfileName = "Balanced", ScheduledEvaluationIntervalMinutes = 30d, MaxCandidatesPerTick = 100, DeployablesOutsidePrivilegeCleanupHours = 48d, DisconnectedStructuresCleanupHours = 72d, InsidePrivilegeCleanupHours = 0d, OutsideHealthFractionThreshold = 0.6f, InsideHealthFractionThreshold = 0f, ProtectRecentlyActivePlayersHours = 72d, CleanupBuildings = true, CleanupDeployables = true, RemoveInsidePrivilege = false, RemoveOutsidePrivilege = true, CheckCupboardAuthorization = true, AnnounceScheduledCleanup = false, AutoTuneEnabled = _config.AutoTuneEnabled
}
;
case 4 : return new RuntimeSettings {
ResolvedProfile = 4, ResolvedProfileName = "PvEConservative", ScheduledEvaluationIntervalMinutes = 45d, MaxCandidatesPerTick = 60, DeployablesOutsidePrivilegeCleanupHours = 96d, DisconnectedStructuresCleanupHours = 168d, InsidePrivilegeCleanupHours = 0d, OutsideHealthFractionThreshold = 0.35f, InsideHealthFractionThreshold = 0f, ProtectRecentlyActivePlayersHours = 168d, CleanupBuildings = true, CleanupDeployables = true, RemoveInsidePrivilege = false, RemoveOutsidePrivilege = true, CheckCupboardAuthorization = true, AnnounceScheduledCleanup = false, AutoTuneEnabled = _config.AutoTuneEnabled
}
;
case 5 : return new RuntimeSettings {
ResolvedProfile = 5, ResolvedProfileName = "Custom", ScheduledEvaluationIntervalMinutes = _config.AdvancedTimingOverrides.EnableAdvancedTimingOverrides && _config.AdvancedTimingOverrides.ScheduledEvaluationIntervalMinutesOverride > 0d ? _config.AdvancedTimingOverrides.ScheduledEvaluationIntervalMinutesOverride : 30d, MaxCandidatesPerTick = _config
// ... [Code preview truncated due to length. Full source in attached file] ...