using System; using System.Collections.Generic; using System.Globalization; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Oxide.Core; using Oxide.Game.Rust.Cui; using Rust; using Rust.Ai.Gen2; using UnityEngine; namespace Oxide.Plugins { [Info("Wipe Raid Timer", "Sigilo", "2.0.0")] [Description("Protects cupboard-covered buildings and deployables from raiding for a configurable time after wipe")] public class WipeRaidTimer : RustPlugin { private const string BypassPermission = "wiperaidtimer.bypass"; private const string UiTimerBarName = "wiperaidtimer.timerbar"; private const string StatusBarBackgroundColor = "0.1 0.1 0.1 0.7"; private const string StatusBarBorderColor = "0.3 0.3 0.3 0.7"; private const string StatusBarFillColor = "0.7 0.2 0.2 0.9"; private const string StatusBarTextColor = "1 1 1 0.9"; private const string StatusBarAnchorMin = "0.10 0.03"; private const string StatusBarAnchorMax = "0.28 0.05"; private readonly HashSet deployablePrefabPaths = new HashSet(StringComparer.Ordinal); private readonly Dictionary notificationTimes = new Dictionary(); private readonly Dictionary buildingQualificationCache = new Dictionary(); private ConfigData configData; private Timer blockExpiryTimer; private Timer uiUpdateTimer; private bool blockActive; private bool serverInitialized; #region Oxide Hooks private void Init() { permission.RegisterPermission(BypassPermission, this); // Damage handling is enabled only while protection is active. Unsubscribe(nameof(OnEntityTakeDamage)); } private void OnServerInitialized() { serverInitialized = true; BuildDeployablePrefabCache(); RefreshBlockState(); Puts($"Loaded with {deployablePrefabPaths.Count} player-deployable prefabs cached. " + $"Raid protection is {(blockActive ? "active" : "inactive")}."); } private void OnNewSave(string filename) { if (!serverInitialized) { return; } NextTick(RefreshBlockState); } private void OnPlayerConnected(BasePlayer player) { if (player == null) { return; } NextTick(() => UpdateStatusBar(player)); } private void OnPlayerDisconnected(BasePlayer player) { if (player == null) { return; } CuiHelper.DestroyUi(player, UiTimerBarName); notificationTimes.Remove(player.userID); } private object OnEntityTakeDamage(BaseEntity entity, HitInfo info) { if (!blockActive || entity == null || entity.IsDestroyed || info?.damageTypes == null) { return null; } float totalDamage = info.damageTypes.Total(); if (totalDamage <= 0f || IsAllowedDecayDamage(info, totalDamage) || !IsProtectedTarget(entity)) { return null; } BuildingPrivlidge privilege = FindCoveringPrivilege(entity); if (privilege == null || privilege.IsDestroyed) { return null; } if (!IsQualifiedProtectedBuilding(privilege)) { return null; } if (configData.Protection.RequireDeployableOwnerAuthorization && IsGenericPlayerDeployable(entity) && !IsDeployableOwnerAuthorized(entity, privilege)) { return null; } BasePlayer attacker; ulong attackerId = ResolveResponsiblePlayer(info, out attacker); if (attackerId.IsSteamId()) { if (HasBypassPermission(attackerId, attacker)) { return null; } if (configData.Protection.AllowCupboardAuthorizedDamage && ((configData.Protection.AllowCupboardOwnerDamage && privilege.OwnerID == attackerId) || privilege.IsAuthed(attackerId))) { return null; } } else if (!configData.Protection.BlockUnattributedDamage) { return null; } info.damageTypes.Clear(); info.DidHit = false; info.DoHitEffects = false; NotifyBlockedAttacker(attacker, entity, privilege, totalDamage); return true; } private void Unload() { blockActive = false; Unsubscribe(nameof(OnEntityTakeDamage)); blockExpiryTimer?.Destroy(); blockExpiryTimer = null; uiUpdateTimer?.Destroy(); uiUpdateTimer = null; notificationTimes.Clear(); buildingQualificationCache.Clear(); DestroyAllStatusBars(); } #endregion #region Protection Lifecycle private void RefreshBlockState() { int remainingSeconds = GetRemainingBlockTime(); if (remainingSeconds > 0) { ActivateProtection(remainingSeconds); } else { DeactivateProtection(); } } private void ActivateProtection(int remainingSeconds) { bool wasActive = blockActive; blockActive = true; Subscribe(nameof(OnEntityTakeDamage)); blockExpiryTimer?.Destroy(); blockExpiryTimer = timer.Once(Math.Max(1f, remainingSeconds + 0.25f), RefreshBlockState); if (!wasActive) { StartUiUpdates(); } UpdateAllStatusBars(); } private void DeactivateProtection() { bool wasActive = blockActive; blockActive = false; Unsubscribe(nameof(OnEntityTakeDamage)); blockExpiryTimer?.Destroy(); blockExpiryTimer = null; uiUpdateTimer?.Destroy(); uiUpdateTimer = null; notificationTimes.Clear(); buildingQualificationCache.Clear(); DestroyAllStatusBars(); if (wasActive) { Puts("Wipe raid protection has expired."); } } private void StartUiUpdates() { uiUpdateTimer?.Destroy(); uiUpdateTimer = null; if (!configData.StatusBar.Enabled) { return; } uiUpdateTimer = timer.Every(configData.StatusBar.RefreshIntervalSeconds, UpdateAllStatusBars); } #endregion #region Target Classification and Privilege private void BuildDeployablePrefabCache() { deployablePrefabPaths.Clear(); foreach (ItemDefinition definition in ItemManager.GetItemDefinitions()) { if (!definition.TryGetComponent(out ItemModDeployable deployable) || deployable?.entityPrefab == null) { continue; } string prefabPath = deployable.entityPrefab.resourcePath; if (!string.IsNullOrEmpty(prefabPath)) { deployablePrefabPaths.Add(prefabPath); } } } private bool IsProtectedTarget(BaseEntity entity) { if (entity is BasePlayer || entity.IsNpc || entity is BaseNpc || entity is BaseNPC2 || entity is ResourceEntity || entity is BaseVehicle || entity is RidableHorse || HasVehicleParent(entity)) { return false; } if (entity is BuildingBlock buildingBlock) { if (buildingBlock.grade == BuildingGrade.Enum.Twigs) { return configData.Protection.ProtectTwigBuildingBlocks; } return configData.Protection.ProtectBuildingBlocks; } if (entity is Door) { return configData.Protection.ProtectDoors; } if (entity is BaseLock) { return configData.Protection.ProtectDoors || configData.Protection.ProtectPlayerDeployables; } if (entity is BuildingPrivlidge) { return configData.Protection.ProtectPlayerDeployables; } return configData.Protection.ProtectPlayerDeployables && IsGenericPlayerDeployable(entity); } private bool IsGenericPlayerDeployable(BaseEntity entity) { return !string.IsNullOrEmpty(entity.PrefabName) && deployablePrefabPaths.Contains(entity.PrefabName); } private bool IsDeployableOwnerAuthorized(BaseEntity entity, BuildingPrivlidge privilege) { BaseEntity current = entity; for (int depth = 0; current != null && depth < 4; depth++) { ulong ownerId = current.OwnerID; if (ownerId.IsSteamId()) { return IsOwnerAuthorized(ownerId, privilege); } current = current.GetParentEntity(); } return false; } private bool IsQualifiedProtectedBuilding(BuildingPrivlidge privilege) { BuildingManager.Building building = privilege.GetBuilding(); if (building == null) { return false; } float now = Time.realtimeSinceStartup; if (buildingQualificationCache.TryGetValue(building.ID, out BuildingQualificationCacheEntry cached) && cached.ExpiresAt > now) { return cached.IsQualified; } bool isQualified = BuildingMeetsMinimumRequirements(building, privilege); if (buildingQualificationCache.Count >= 2048) { buildingQualificationCache.Clear(); } buildingQualificationCache[building.ID] = new BuildingQualificationCacheEntry { IsQualified = isQualified, ExpiresAt = now + configData.BaseQualification.CacheSeconds }; return isQualified; } private bool BuildingMeetsMinimumRequirements( BuildingManager.Building building, BuildingPrivlidge privilege) { int foundationCount = 0; foreach (BuildingBlock block in building.buildingBlocks) { if (block == null || block.IsDestroyed || block.grade == BuildingGrade.Enum.Twigs || !IsOwnerAuthorized(block.OwnerID, privilege) || !block.ShortPrefabName.StartsWith("foundation", StringComparison.Ordinal)) { continue; } if (++foundationCount >= configData.BaseQualification.MinimumNonTwigFoundations) { break; } } if (foundationCount < configData.BaseQualification.MinimumNonTwigFoundations) { return false; } int doorCount = 0; foreach (Door door in building.doors) { if (door == null || door.IsDestroyed || !IsOwnerAuthorized(door.OwnerID, privilege)) { continue; } if (++doorCount >= configData.BaseQualification.MinimumDoors) { return true; } } return doorCount >= configData.BaseQualification.MinimumDoors; } private bool IsOwnerAuthorized(ulong ownerId, BuildingPrivlidge privilege) { return ownerId.IsSteamId() && (privilege.OwnerID == ownerId || privilege.IsAuthed(ownerId)); } private class BuildingQualificationCacheEntry { public bool IsQualified; public float ExpiresAt; } private bool HasVehicleParent(BaseEntity entity) { BaseEntity parent = entity.GetParentEntity(); for (int depth = 0; parent != null && depth < 4; depth++) { if (parent is BaseVehicle || parent is RidableHorse) { return true; } parent = parent.GetParentEntity(); } return false; } private BuildingPrivlidge FindCoveringPrivilege(BaseEntity entity) { if (entity is BuildingPrivlidge cupboard && !cupboard.IsDestroyed) { return cupboard; } if (entity is DecayEntity decayEntity) { BuildingManager.Building building = decayEntity.GetBuilding(); BuildingPrivlidge dominatingPrivilege = building?.GetDominatingBuildingPrivilege(); if (dominatingPrivilege != null && !dominatingPrivilege.IsDestroyed) { return dominatingPrivilege; } } BaseEntity boundsEntity = entity; if (entity is BaseLock) { BaseEntity parent = entity.GetParentEntity(); if (parent != null) { boundsEntity = parent; } } return boundsEntity.GetBuildingPrivilege( boundsEntity.WorldSpaceBounds(), true, configData.Protection.PrivilegeLookupCacheSeconds ); } private bool IsAllowedDecayDamage(HitInfo info, float totalDamage) { if (!configData.Protection.AllowDecayDamage) { return false; } float decayDamage = info.damageTypes.Get(DamageType.Decay); return decayDamage > 0f && decayDamage >= totalDamage - 0.01f; } #endregion #region Damage Attribution private ulong ResolveResponsiblePlayer(HitInfo info, out BasePlayer player) { player = info.InitiatorPlayer; if (IsHumanPlayer(player)) { return player.userID; } ulong userId = ResolvePlayerFromEntity(info.Initiator, out player); if (userId.IsSteamId()) { return userId; } userId = ResolvePlayerFromEntity(info.Weapon, out player); if (userId.IsSteamId()) { return userId; } return ResolvePlayerFromEntity(info.WeaponPrefab, out player); } private ulong ResolvePlayerFromEntity(BaseEntity source, out BasePlayer player) { player = null; BaseEntity current = source; for (int depth = 0; current != null && depth < 6; depth++) { if (current is BasePlayer directPlayer && IsHumanPlayer(directPlayer)) { player = directPlayer; return directPlayer.userID; } if (current is BaseVehicle vehicle) { BasePlayer driver = vehicle.GetDriver(); if (IsHumanPlayer(driver)) { player = driver; return driver.userID; } } if (current.OwnerID.IsSteamId()) { player = BasePlayer.FindAwakeOrSleepingByID(current.OwnerID); return current.OwnerID; } BaseEntity creator = current.creatorEntity; if (creator != null && creator != current) { current = creator; continue; } BaseEntity parent = current.GetParentEntity(); if (parent == null || parent == current) { break; } current = parent; } return 0UL; } private bool IsHumanPlayer(BasePlayer player) { return player != null && !player.IsNpc && player.userID.IsSteamId(); } private bool HasBypassPermission(ulong userId, BasePlayer player) { return userId.IsSteamId() && ((configData.Protection.AllowAdminBypass && player != null && player.IsAdmin) || permission.UserHasPermission(userId.ToString(CultureInfo.InvariantCulture), BypassPermission)); } #endregion #region Notifications private void NotifyBlockedAttacker( BasePlayer attacker, BaseEntity target, BuildingPrivlidge privilege, float attemptedDamage) { if ((!configData.Notifications.NotifyBlockedAttacker && !configData.DebugLogging) || !IsHumanPlayer(attacker) || !attacker.IsConnected) { return; } float now = Time.realtimeSinceStartup; if (notificationTimes.TryGetValue(attacker.userID, out float lastNotification) && now - lastNotification < configData.Notifications.CooldownSeconds) { return; } notificationTimes[attacker.userID] = now; if (configData.Notifications.NotifyBlockedAttacker) { attacker.ChatMessage(GetMessage( "DamageBlocked", attacker.UserIDString, FormatTimeString(GetRemainingBlockTime()) )); } if (configData.DebugLogging) { Puts($"Blocked {attemptedDamage:0.##} damage from {attacker.displayName} ({attacker.userID}) " + $"to {target.ShortPrefabName} under cupboard {privilege.net?.ID.Value ?? 0UL}."); } } #endregion #region Status Bar UI private void UpdateAllStatusBars() { if (!blockActive || !configData.StatusBar.Enabled) { DestroyAllStatusBars(); return; } foreach (BasePlayer player in BasePlayer.activePlayerList) { UpdateStatusBar(player); } } private void UpdateStatusBar(BasePlayer player) { if (player == null || !player.IsConnected) { return; } if (!blockActive || !configData.StatusBar.Enabled || HasBypassPermission(player.userID, player)) { CuiHelper.DestroyUi(player, UiTimerBarName); return; } int remainingTime = GetRemainingBlockTime(); if (remainingTime <= 0) { CuiHelper.DestroyUi(player, UiTimerBarName); return; } float progress = configData.BlockTimeAfterWipe > 0 ? Mathf.Clamp01((float)remainingTime / configData.BlockTimeAfterWipe) : 0f; string progressAnchor = progress.ToString("0.####", CultureInfo.InvariantCulture); string label = GetMessage( "TimerLabel", player.UserIDString, FormatTimeString(remainingTime) ); CuiElementContainer elements = new CuiElementContainer(); elements.Add(new CuiElement { Name = UiTimerBarName, Parent = "Hud", Components = { new CuiImageComponent { Color = StatusBarBackgroundColor }, new CuiRectTransformComponent { AnchorMin = StatusBarAnchorMin, AnchorMax = StatusBarAnchorMax }, new CuiOutlineComponent { Color = StatusBarBorderColor, Distance = "1 1" } } }); elements.Add(new CuiElement { Name = $"{UiTimerBarName}.fill", Parent = UiTimerBarName, Components = { new CuiImageComponent { Color = StatusBarFillColor }, new CuiRectTransformComponent { AnchorMin = "0 0", AnchorMax = $"{progressAnchor} 1" } } }); elements.Add(new CuiElement { Name = $"{UiTimerBarName}.text", Parent = UiTimerBarName, Components = { new CuiTextComponent { Text = label, Align = TextAnchor.MiddleCenter, FontSize = 12, Color = StatusBarTextColor, Font = "robotocondensed-regular.ttf" }, new CuiRectTransformComponent { AnchorMin = "0 0", AnchorMax = "1 1" } } }); CuiHelper.DestroyUi(player, UiTimerBarName); CuiHelper.AddUi(player, elements); } private void DestroyAllStatusBars() { foreach (BasePlayer player in BasePlayer.activePlayerList) { if (player != null) { CuiHelper.DestroyUi(player, UiTimerBarName); } } } #endregion #region Time and Messages private int GetRemainingBlockTime() { if (configData == null || configData.BlockTimeAfterWipe <= 0) { return 0; } double elapsedSeconds = Math.Max(0d, (DateTime.UtcNow - SaveRestore.SaveCreatedTime).TotalSeconds); return Math.Max(0, (int)Math.Ceiling(configData.BlockTimeAfterWipe - elapsedSeconds)); } private string FormatTimeString(int seconds) { seconds = Math.Max(0, seconds); List components = new List(4); int days = seconds / 86400; seconds %= 86400; if (days > 0) { components.Add($"{days}d"); } int hours = seconds / 3600; seconds %= 3600; if (hours > 0) { components.Add($"{hours}h"); } int minutes = seconds / 60; seconds %= 60; if (minutes > 0) { components.Add($"{minutes}m"); } if (seconds > 0 || components.Count == 0) { components.Add($"{seconds}s"); } return string.Join(" ", components); } protected override void LoadDefaultMessages() { lang.RegisterMessages(new Dictionary { ["DamageBlocked"] = "This structure is protected from raiding for {0}.", ["TimerLabel"] = "Raid Protection: {0}" }, this); } private string GetMessage(string key, string playerId, params object[] args) { return string.Format(lang.GetMessage(key, this, playerId), args); } #endregion #region Configuration private class ConfigData { [JsonProperty("Block Time After Wipe (seconds)")] public int BlockTimeAfterWipe { get; set; } = 86400; [JsonProperty("Protection")] public ProtectionSettings Protection { get; set; } = new ProtectionSettings(); [JsonProperty("Base Qualification")] public BaseQualificationSettings BaseQualification { get; set; } = new BaseQualificationSettings(); [JsonProperty("Blocked Hit Notifications")] public NotificationSettings Notifications { get; set; } = new NotificationSettings(); [JsonProperty("Status Bar")] public StatusBarSettings StatusBar { get; set; } = new StatusBarSettings(); [JsonProperty("Debug Logging")] public bool DebugLogging { get; set; } } private class ProtectionSettings { [JsonProperty("Protect Building Blocks")] public bool ProtectBuildingBlocks { get; set; } = true; [JsonProperty("Protect Twig Building Blocks")] public bool ProtectTwigBuildingBlocks { get; set; } = false; [JsonProperty("Protect Doors")] public bool ProtectDoors { get; set; } = true; [JsonProperty("Protect Player Deployables")] public bool ProtectPlayerDeployables { get; set; } = true; [JsonProperty("Require Deployable Owner Authorization")] public bool RequireDeployableOwnerAuthorization { get; set; } = true; [JsonProperty("Allow Cupboard Authorized Damage")] public bool AllowCupboardAuthorizedDamage { get; set; } = true; [JsonProperty("Allow Cupboard Owner Damage")] public bool AllowCupboardOwnerDamage { get; set; } = true; [JsonProperty("Allow Admin Bypass")] public bool AllowAdminBypass { get; set; } = true; [JsonProperty("Block Unattributed Damage")] public bool BlockUnattributedDamage { get; set; } = true; [JsonProperty("Allow Decay Damage")] public bool AllowDecayDamage { get; set; } = true; [JsonProperty("Privilege Lookup Cache Seconds")] public float PrivilegeLookupCacheSeconds { get; set; } = 2f; } private class BaseQualificationSettings { [JsonProperty("Minimum Non-Twig Foundations")] public int MinimumNonTwigFoundations { get; set; } = 2; [JsonProperty("Minimum Doors")] public int MinimumDoors { get; set; } = 1; [JsonProperty("Cache Seconds")] public float CacheSeconds { get; set; } = 5f; } private class NotificationSettings { [JsonProperty("Notify Blocked Attacker")] public bool NotifyBlockedAttacker { get; set; } = true; [JsonProperty("Cooldown Seconds")] public float CooldownSeconds { get; set; } = 5f; } private class StatusBarSettings { [JsonProperty("Enabled")] public bool Enabled { get; set; } = true; [JsonProperty("Refresh Interval Seconds")] public float RefreshIntervalSeconds { get; set; } = 10f; } protected override void LoadConfig() { base.LoadConfig(); try { JObject rawConfig = Config.ReadObject() ?? new JObject(); bool legacyConfig = rawConfig["Blocked Items (List of items blocked during wipe timer)"] != null || rawConfig["Block Attack Helicopter"] != null || rawConfig["Block MLRS"] != null || rawConfig["Block Drones (Set drone.maxcontrolrange to 1 during raid block)"] != null; configData = rawConfig.ToObject() ?? new ConfigData(); if (legacyConfig) { PrintWarning("Migrated legacy item-blocking configuration. Attack helicopters, raid items, " + "MLRS, and drones are no longer restricted by default."); } } catch (Exception exception) { PrintError($"Configuration is invalid; using defaults. {exception.Message}"); configData = new ConfigData(); } ValidateConfig(); SaveConfig(); } private void ValidateConfig() { configData ??= new ConfigData(); configData.Protection ??= new ProtectionSettings(); configData.BaseQualification ??= new BaseQualificationSettings(); configData.Notifications ??= new NotificationSettings(); configData.StatusBar ??= new StatusBarSettings(); configData.BlockTimeAfterWipe = Math.Max(0, configData.BlockTimeAfterWipe); configData.Protection.PrivilegeLookupCacheSeconds = Mathf.Clamp( configData.Protection.PrivilegeLookupCacheSeconds, 0.1f, 10f ); configData.BaseQualification.MinimumNonTwigFoundations = Math.Max( 1, configData.BaseQualification.MinimumNonTwigFoundations ); configData.BaseQualification.MinimumDoors = Math.Max( 1, configData.BaseQualification.MinimumDoors ); configData.BaseQualification.CacheSeconds = Mathf.Clamp( configData.BaseQualification.CacheSeconds, 1f, 30f ); configData.Notifications.CooldownSeconds = Mathf.Clamp( configData.Notifications.CooldownSeconds, 0.5f, 60f ); configData.StatusBar.RefreshIntervalSeconds = Mathf.Clamp( configData.StatusBar.RefreshIntervalSeconds, 1f, 60f ); } protected override void LoadDefaultConfig() { configData = new ConfigData(); } protected override void SaveConfig() { Config.WriteObject(configData, true); } #endregion #region API private Dictionary API_GetRemainingBlockTime() { return new Dictionary { ["RemainingSeconds"] = GetRemainingBlockTime() }; } #endregion } }