using System; using System.Collections.Generic; using System.Linq; using HarmonyLib; using Newtonsoft.Json; using Oxide.Core; using Oxide.Core.Plugins; using Oxide.Game.Rust.Cui; using UnityEngine; namespace Oxide.Plugins { [Info("DynamicBubbleController", "Zim", "2.4.4")] [Description("PvE bubble zones with live map markers + TruePVE enforcement, an Advanced Status HUD, player self-managed zone rules via /mybase, Everlight everlasting-light control, a solar/wind electrical max-output override, and automatic water refill.")] public class DynamicBubbleController : RustPlugin { [PluginReference] private Plugin AdvancedStatus; [PluginReference] private Plugin TruePVE; private const string PermAdmin = "dynamicbubblecontroller.admin"; private const string RadiusMarkerPrefab = "assets/prefabs/tools/map/genericradiusmarker.prefab"; private const string VendingMarkerPrefab = "assets/prefabs/deployable/vendingmachine/vending_mapmarker.prefab"; private const string UiMain = "PveZoneMap.UI.Main"; private const string UiPlayer = "PveZoneMap.UI.Player"; private const int EverlightItemsPerPage = 9; private Configuration _config; private StoredData _data; private readonly Dictionary _liveMarkers = new Dictionary(StringComparer.OrdinalIgnoreCase); private readonly HashSet _playersInsideZone = new HashSet(); private readonly Dictionary _lastZoneByPlayer = new Dictionary(); private readonly Dictionary _selectedZoneByPlayer = new Dictionary(); private readonly Dictionary _selectedTabByPlayer = new Dictionary(); private readonly Dictionary _selectedPlayerTabByPlayer = new Dictionary(); private readonly HashSet _previewEnabledPlayers = new HashSet(); private readonly Dictionary _pendingInputByPlayer = new Dictionary(); private readonly Dictionary _zoneNameDraftByPlayer = new Dictionary(); private readonly Dictionary _statusTextDraftByPlayer = new Dictionary(); private readonly Dictionary _everlightPageByPlayer = new Dictionary(); private readonly Dictionary _waterPageByPlayer = new Dictionary(); private readonly Dictionary _electricalPageByPlayer = new Dictionary(); private readonly Dictionary _zoneSearchByPlayer = new Dictionary(); private readonly Dictionary _lastStatusSignatureByPlayer = new Dictionary(); private Timer _previewTimer; private Timer _markerMaintenanceTimer; private Timer _waterRefillTimer; private Timer _electricalRefillTimer; // Harmony patches force AlwaysOn entities (Solar / Wind) to peak output // by intercepting their GetCurrentEnergy() — sidesteps the game's per-tick // wind/sun recalc that a reflection write would lose the race against. private Harmony _harmony; private const string HarmonyId = "com.zim.dynamicbubblecontroller.electricaloverride"; private static DynamicBubbleController Instance; private bool _suppressMarkerRespawn; private readonly Dictionary _everlightByShortname = new Dictionary(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _burnableDefinitions = new Dictionary(); private readonly Dictionary _everlightDefaults = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["Barbeque"] = new EverlightEntry("bbq.deployed", new EverlightSettings(false, false, "dynamicbubblecontroller.light.bbq")), ["Camp Fire"] = new EverlightEntry("campfire", new EverlightSettings(false, false, "dynamicbubblecontroller.light.campfire")), ["Cursed Cauldron"] = new EverlightEntry("cursedcauldron.deployed", new EverlightSettings(false, false, "dynamicbubblecontroller.light.cursedcauldron")), ["Chinese Lantern"] = new EverlightEntry("chineselantern.deployed", new EverlightSettings(false, false, "dynamicbubblecontroller.light.chineselantern")), ["Stone Fireplace"] = new EverlightEntry("fireplace.deployed", new EverlightSettings(false, false, "dynamicbubblecontroller.light.fireplace")), ["Furnace"] = new EverlightEntry("furnace", new EverlightSettings(false, false, "dynamicbubblecontroller.light.furnace")), ["Large Furnace"] = new EverlightEntry("furnace.large", new EverlightSettings(false, false, "dynamicbubblecontroller.light.furnace.large")), ["Jack O Lantern Angry"] = new EverlightEntry("jackolantern.angry", new EverlightSettings(false, false, "dynamicbubblecontroller.light.jackolantern.angry")), ["Jack O Lantern Happy"] = new EverlightEntry("jackolantern.happy", new EverlightSettings(false, false, "dynamicbubblecontroller.light.jackolantern.happy")), ["Carvable Pumpkin"] = new EverlightEntry("carvable.pumpkin", new EverlightSettings(false, false, "dynamicbubblecontroller.light.carvable.pumpkin")), ["Lantern"] = new EverlightEntry("lantern.deployed", new EverlightSettings(false, false, "dynamicbubblecontroller.light.lantern")), ["Skull Fire Pit"] = new EverlightEntry("skull_fire_pit", new EverlightSettings(false, false, "dynamicbubblecontroller.light.skull_fire_pit")), ["Small Oil Refinery"] = new EverlightEntry("refinery_small_deployed", new EverlightSettings(false, false, "dynamicbubblecontroller.light.refinery_small")), ["Tuna Can Lamp"] = new EverlightEntry("tunalight.deployed", new EverlightSettings(false, false, "dynamicbubblecontroller.light.tunalight")), ["Hobo Barrel"] = new EverlightEntry("hobobarrel.deployed", new EverlightSettings(false, false, "dynamicbubblecontroller.light.hobobarrel")), ["Sky Lantern Green"] = new EverlightEntry("skylantern.skylantern.green", new EverlightSettings(false, false, "dynamicbubblecontroller.light.skylanterngreen")), ["Sky Lantern Orange"] = new EverlightEntry("skylantern.skylantern.orange", new EverlightSettings(false, false, "dynamicbubblecontroller.light.skylanternorange")), ["Sky Lantern Purple"] = new EverlightEntry("skylantern.skylantern.purple", new EverlightSettings(false, false, "dynamicbubblecontroller.light.skylanternpurple")), ["Sky Lantern Red"] = new EverlightEntry("skylantern.skylantern.red", new EverlightSettings(false, false, "dynamicbubblecontroller.light.skylanternred")), ["Sky Lantern"] = new EverlightEntry("skylantern.deployed", new EverlightSettings(false, false, "dynamicbubblecontroller.light.skylantern")), ["Chinese Lantern White"] = new EverlightEntry("chineselantern_white.deployed", new EverlightSettings(false, false, "dynamicbubblecontroller.light.chineselanternwhite")), ["Legacy Furnace"] = new EverlightEntry("legacy_furnace", new EverlightSettings(false, false, "dynamicbubblecontroller.light.legacyfurnace")), ["Night Vision Goggles"] = new EverlightEntry("nightvisiongoggles", new EverlightSettings(false, false, "dynamicbubblecontroller.light.nightvisiongoggles")), ["Torch"] = new EverlightEntry("torch", new EverlightSettings(false, false, "dynamicbubblecontroller.light.torch")), ["Cultist Deer Torch"] = new EverlightEntry("torch.torch.skull", new EverlightSettings(false, false, "dynamicbubblecontroller.light.torch.torch.skull")), ["Abyss Torch"] = new EverlightEntry("divertorch", new EverlightSettings(false, false, "dynamicbubblecontroller.light.divertorch")), ["Miners Hat"] = new EverlightEntry("hat.miner", new EverlightSettings(false, false, "dynamicbubblecontroller.light.hat.miner")), ["Candle Hat"] = new EverlightEntry("hat.candle", new EverlightSettings(false, false, "dynamicbubblecontroller.light.hat.candle")), }; private readonly Dictionary _waterByShortname = new Dictionary(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _waterDefaults = new Dictionary(StringComparer.OrdinalIgnoreCase) { // Values below are entity ShortPrefabNames (what BaseNetworkable.serverEntities // reports), NOT item shortnames. Confirmed via the /waterdiag command. ["Small Water Catcher"] = new WaterEntry("water_catcher_small", new WaterSettings(false, false, "dynamicbubblecontroller.water.catcher.small")), ["Large Water Catcher"] = new WaterEntry("water_catcher_large", new WaterSettings(false, false, "dynamicbubblecontroller.water.catcher.large")), ["Water Barrel"] = new WaterEntry("waterbarrel", new WaterSettings(false, false, "dynamicbubblecontroller.water.barrel")), }; private readonly Dictionary _electricalByShortname = new Dictionary(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _electricalDefaults = new Dictionary(StringComparer.OrdinalIgnoreCase) { // Best-guess ShortPrefabNames — verify with /electricaldiag and ping me // if any are off; same pattern we used for the water entities. ["Small Generator"] = new ElectricalEntry("generator.small", ElectricalKind.FuelGenerator, new ElectricalSettings(false, false, "dynamicbubblecontroller.electrical.generator.small")), ["Test Generator"] = new ElectricalEntry("generator.static", ElectricalKind.AlwaysOn, new ElectricalSettings(false, false, "dynamicbubblecontroller.electrical.generator.test")), ["Small Battery"] = new ElectricalEntry("small.rechargable.battery.deployed", ElectricalKind.Battery, new ElectricalSettings(false, false, "dynamicbubblecontroller.electrical.battery.small")), ["Medium Battery"] = new ElectricalEntry("medium.rechargable.battery.deployed", ElectricalKind.Battery, new ElectricalSettings(false, false, "dynamicbubblecontroller.electrical.battery.medium")), ["Large Battery"] = new ElectricalEntry("large.rechargable.battery.deployed", ElectricalKind.Battery, new ElectricalSettings(false, false, "dynamicbubblecontroller.electrical.battery.large")), ["Solar Panel"] = new ElectricalEntry("solarpanel.large.deployed", ElectricalKind.AlwaysOn, new ElectricalSettings(false, false, "dynamicbubblecontroller.electrical.solar")), ["Wind Turbine"] = new ElectricalEntry("electric.windmill.small", ElectricalKind.AlwaysOn, new ElectricalSettings(false, false, "dynamicbubblecontroller.electrical.wind")), }; // Obsolete entries from earlier defaults — pruned from saved configs // on plugin load so the Water Controller UI doesn't list dead toggles. private static readonly string[] _waterObsoleteKeys = { "Water Purifier", "Powered Water Purifier", "Sprinkler" }; #region Models private enum PendingInputType { None, RenameZone, StatusText } private class ZoneEntry { public string Name; public SerializableVector3 Position; public float Radius = 100f; public float BubblePercent = 100f; public string ZoneColorHex = "#27AE60"; public string OutlineColorHex = "#0B0B0B"; public bool ShowOnMap = true; public bool CreateLabelMarker = false; public bool NoDecay = true; public bool AllowTeleport = true; public bool BlockPlayerVsPlayer = true; public bool BlockPlayerVsStructures = true; public bool BlockPlayerVsNPC = true; public bool BlockTrapsAndTurrets = true; public bool UseAdvancedStatus = true; public string StatusText = "PVE ZONE"; public string StatusBackgroundColor = "#27AE60"; public string StatusTextColor = "#FFFFFF"; public string StatusIcon = ""; // When set in the future, blocks player-side rule toggles (PvP / // Structure / NPC / Traps) until this time has passed. Bumped by // 6 hours whenever a player flips a rule via /mybase. public DateTime RuleChangeLockedUntil = DateTime.MinValue; } private const int PlayerRuleCooldownHours = 6; private class StoredData { public List Zones = new List(); } private class SerializableVector3 { public float X; public float Y; public float Z; public SerializableVector3() { } public SerializableVector3(Vector3 position) { X = position.x; Y = position.y; Z = position.z; } public Vector3 ToVector3() => new Vector3(X, Y, Z); } private class MarkerPair { public MapMarkerGenericRadius RadiusMarker; public VendingMachineMapMarker LabelMarker; } private class Configuration { public float MarkerAlpha = 0.55f; public bool NotifyPlayers = true; public string ChatPrefix = "[PVE ZONE]: "; public string EnterMessage = "You entered the zone."; public string LeaveMessage = "You left the zone."; public string BlockMessage = "Damage is blocked inside this PvE zone."; public float EnterLeaveCheckInterval = 1.0f; public int StatusOrder = 12; public string DefaultStatusIcon = ""; public string NoDecayStatusSuffix = "Will Not Decay"; public string UiAccentColor = "0.15 0.65 0.25 0.95"; public string UiPanelColor = "0.10 0.10 0.10 0.96"; public string UiPanelSecondaryColor = "0.16 0.16 0.16 0.96"; public string UiTextColor = "1 1 1 1"; public string UiMutedTextColor = "0.75 0.75 0.75 1"; public string UiDangerColor = "0.75 0.20 0.20 0.95"; public float MarkerRefreshInterval = 30f; [JsonProperty(PropertyName = "Everlight entity list")] public Dictionary EverlightList = new Dictionary(StringComparer.OrdinalIgnoreCase); [JsonProperty(PropertyName = "Water entity list")] public Dictionary WaterList = new Dictionary(StringComparer.OrdinalIgnoreCase); [JsonProperty(PropertyName = "Water refill interval (seconds)")] public float WaterRefillInterval = 30f; [JsonProperty(PropertyName = "Electrical entity list")] public Dictionary ElectricalList = new Dictionary(StringComparer.OrdinalIgnoreCase); [JsonProperty(PropertyName = "Electrical refill interval (seconds)")] public float ElectricalRefillInterval = 30f; } private class EverlightEntry { public string ShortName; public EverlightSettings Settings; public EverlightEntry(string shortName, EverlightSettings settings) { ShortName = shortName; Settings = settings; } } private class EverlightSettings { [JsonProperty(PropertyName = "Enabled")] public bool Enabled; [JsonProperty(PropertyName = "Use permission")] public bool UsePermission; [JsonProperty(PropertyName = "Permission")] public string Permission; public EverlightSettings() { } public EverlightSettings(bool enabled, bool usePermission, string permission) { Enabled = enabled; UsePermission = usePermission; Permission = permission; } } private class WaterEntry { public string ShortName; public WaterSettings Settings; public WaterEntry(string shortName, WaterSettings settings) { ShortName = shortName; Settings = settings; } } private class WaterSettings { [JsonProperty(PropertyName = "Enabled")] public bool Enabled; [JsonProperty(PropertyName = "Use permission")] public bool UsePermission; [JsonProperty(PropertyName = "Permission")] public string Permission; public WaterSettings() { } public WaterSettings(bool enabled, bool usePermission, string permission) { Enabled = enabled; UsePermission = usePermission; Permission = permission; } } // Electrical entities cover three different mechanics, so each entry // declares which one the refill tick should apply. private enum ElectricalKind { FuelGenerator, // top up lowgradefuel in the entity's inventory Battery, // set the battery's stored charge to its max AlwaysOn // force the IO output / passthrough to max } private class ElectricalEntry { public string ShortName; public ElectricalKind Kind; public ElectricalSettings Settings; public ElectricalEntry(string shortName, ElectricalKind kind, ElectricalSettings settings) { ShortName = shortName; Kind = kind; Settings = settings; } } private class ElectricalSettings { [JsonProperty(PropertyName = "Enabled")] public bool Enabled; [JsonProperty(PropertyName = "Use permission")] public bool UsePermission; [JsonProperty(PropertyName = "Permission")] public string Permission; public ElectricalSettings() { } public ElectricalSettings(bool enabled, bool usePermission, string permission) { Enabled = enabled; UsePermission = usePermission; Permission = permission; } } #endregion #region Oxide Hooks private void Init() { Instance = this; permission.RegisterPermission(PermAdmin, this); LoadConfigValues(); LoadData(); InitializeEverlight(); InitializeWater(); InitializeElectrical(); } private void OnServerInitialized() { RefreshAllMarkers(); timer.Once(1f, RefreshAllMarkers); ApplyTruePveMappings(); CacheBurnables(); _previewTimer = timer.Every(1f, DrawPreviewSpheres); _markerMaintenanceTimer = timer.Every(Mathf.Max(5f, _config.MarkerRefreshInterval), EnsureMarkersHealthy); _waterRefillTimer = timer.Every(Mathf.Max(5f, _config.WaterRefillInterval), RefillEnabledWaterEntities); _electricalRefillTimer = timer.Every(Mathf.Max(5f, _config.ElectricalRefillInterval), RefillEnabledElectricalEntities); try { _harmony = new Harmony(HarmonyId); _harmony.PatchAll(typeof(DynamicBubbleController).Assembly); } catch (Exception ex) { PrintError("Failed to apply Harmony patches for AlwaysOn output override: " + ex.Message); _harmony = null; } foreach (var player in BasePlayer.activePlayerList) { if (player == null || !player.IsConnected) continue; var zone = GetZoneAtPosition(player.transform.position); if (zone == null) continue; _playersInsideZone.Add(player.userID); _lastZoneByPlayer[player.userID] = zone.Name; CreateOrUpdateStatus(player); } timer.Every(Mathf.Max(0.5f, _config.EnterLeaveCheckInterval), CheckPlayersInZones); } private void Unload() { _previewTimer?.Destroy(); _markerMaintenanceTimer?.Destroy(); _waterRefillTimer?.Destroy(); _electricalRefillTimer?.Destroy(); try { _harmony?.UnpatchAll(HarmonyId); } catch { } _harmony = null; Instance = null; foreach (var player in BasePlayer.activePlayerList) { DestroyUi(player); DestroyPlayerUi(player); DeleteStatus(player); } _previewEnabledPlayers.Clear(); _pendingInputByPlayer.Clear(); _zoneNameDraftByPlayer.Clear(); _statusTextDraftByPlayer.Clear(); _everlightPageByPlayer.Clear(); _waterPageByPlayer.Clear(); _electricalPageByPlayer.Clear(); _zoneSearchByPlayer.Clear(); _lastStatusSignatureByPlayer.Clear(); _suppressMarkerRespawn = true; DestroyAllMarkers(); _suppressMarkerRespawn = false; } private void OnNewSave(string filename) { NextTick(() => { RefreshAllMarkers(); ApplyTruePveMappings(); }); } private void OnPlayerConnected(BasePlayer player) { timer.Once(3f, () => { if (player == null || !player.IsConnected) return; RefreshAllMarkers(); var zone = GetZoneAtPosition(player.transform.position); if (zone == null) return; _playersInsideZone.Add(player.userID); _lastZoneByPlayer[player.userID] = zone.Name; CreateOrUpdateStatus(player); }); } private void OnPlayerDisconnected(BasePlayer player, string reason) { if (player == null) return; _playersInsideZone.Remove(player.userID); _selectedZoneByPlayer.Remove(player.userID); _selectedTabByPlayer.Remove(player.userID); _selectedPlayerTabByPlayer.Remove(player.userID); _previewEnabledPlayers.Remove(player.userID); _pendingInputByPlayer.Remove(player.userID); _zoneNameDraftByPlayer.Remove(player.userID); _statusTextDraftByPlayer.Remove(player.userID); _everlightPageByPlayer.Remove(player.userID); _lastZoneByPlayer.Remove(player.userID); _lastStatusSignatureByPlayer.Remove(player.userID); DestroyUi(player); DestroyPlayerUi(player); DeleteStatus(player); } private void OnEntityKill(BaseNetworkable entity) { if (_suppressMarkerRespawn || entity == null) return; var mapMarker = entity as MapMarkerGenericRadius; var labelMarker = entity as VendingMachineMapMarker; if (mapMarker == null && labelMarker == null) return; var zone = FindZoneByMarker(entity); if (zone == null) return; NextTick(() => { if (_suppressMarkerRespawn) return; var latestZone = FindZone(zone.Name); if (latestZone == null) return; if (!NeedsMarkerRefresh(latestZone)) return; RefreshZone(latestZone); }); } private object OnEntityTakeDamage(BaseCombatEntity entity, HitInfo info) { if (entity == null || info == null) return null; var zone = GetRelevantZoneForDamage(entity, info); if (zone == null) return null; if (zone.NoDecay && info.damageTypes != null && info.damageTypes.Has(Rust.DamageType.Decay)) { info.damageTypes = new Rust.DamageTypeList(); info.DoHitEffects = false; info.HitMaterial = 0; return true; } if (!ShouldBlockDamage(zone, entity)) return null; var attacker = FindResponsiblePlayer(info); if (attacker != null && _config.NotifyPlayers) SendReply(attacker, $"{FormatZonePrefix(zone)} Damage is blocked in this zone."); info.damageTypes = new Rust.DamageTypeList(); info.DoHitEffects = false; info.HitMaterial = 0; return true; } private object OnFindBurnable(BaseOven oven) { if (oven == null || oven.net == null) return null; if (!CanEverlight(oven.ShortPrefabName, oven.OwnerID.ToString())) return null; foreach (var entry in _burnableDefinitions) { if (oven.fuelType == null || entry.Value == oven.fuelType) return ItemManager.CreateByItemID(entry.Value.itemid, 1); } return null; } private void OnLoseCondition(Item item, ref float amount) { if (item?.info == null) return; var shortname = item.info.shortname; if (shortname != "torch" && shortname != "torch.torch.skull" && shortname != "divertorch" && shortname != "nightvisiongoggles") return; var owner = item.GetOwnerPlayer(); var playerId = owner != null ? owner.UserIDString : string.Empty; if (!CanEverlight(shortname, playerId)) return; amount = 0f; } private object OnItemUse(Item item, int amount) { if (item?.info?.shortname != "lowgradefuel") return null; var wornItem = item.parent?.parent; var shortname = wornItem?.info?.shortname; if (shortname != "hat.miner" && shortname != "hat.candle") return null; var playerId = item.GetRootContainer()?.GetOwnerPlayer()?.UserIDString; if (!CanEverlight(shortname, playerId)) return null; return 0; } private void OnEntityDistanceCheck(BaseOven oven, BasePlayer player, uint id, string debugName, float maximumDistance) { if (oven == null || player == null) return; if (id != 4167839872u || debugName != "SVSwitch") return; if (oven.Distance(player.eyes.position) > maximumDistance) return; if (oven.IsOn()) return; if (!CanEverlight(oven.ShortPrefabName, oven.OwnerID.ToString())) return; var hasFuel = oven.inventory?.itemList?.Any(x => x?.info != null && x.info.GetComponent() != null && (oven.fuelType == null || x.info == oven.fuelType)); if (hasFuel.HasValue && hasFuel.Value) return; if (Interface.CallHook("OnOvenToggle", oven, player) == null && (!oven.needsBuildingPrivilegeToUse || player.CanBuild())) { oven.StartCooking(); } } #endregion #region Chat Commands private void CmdAddZone(BasePlayer player, string command, string[] args) { if (!HasAdminPermission(player)) return; if (args.Length < 2) { SendReply(player, "Usage: /pvezone_add "); return; } float radius; if (!float.TryParse(args[1], out radius) || radius <= 0f) { SendReply(player, "Radius must be a number greater than 0."); return; } var zoneName = args[0]; if (FindZone(zoneName) != null) { SendReply(player, $"A zone called '{zoneName}' already exists."); return; } var zone = CreateDefaultZone(zoneName, player.transform.position, radius); _data.Zones.Add(zone); SaveData(); RefreshZone(zone); SyncTruePveMapping(zone); SelectZone(player, zone.Name); OpenMainUi(player); SendReply(player, $"Created PvE zone '{zone.Name}'."); } private void CmdRemoveZone(BasePlayer player, string command, string[] args) { if (!HasAdminPermission(player)) return; if (args.Length < 1) { SendReply(player, "Usage: /pvezone_remove "); return; } var zone = FindZone(args[0]); if (zone == null) { SendReply(player, $"No PvE zone found named '{args[0]}'."); return; } RemoveZone(zone.Name); OpenMainUi(player); SendReply(player, $"Removed PvE zone '{zone.Name}'."); } private void CmdListZones(BasePlayer player, string command, string[] args) { if (!HasAdminPermission(player)) return; if (_data.Zones.Count == 0) { SendReply(player, "No PvE zones have been created yet."); return; } SendReply(player, $"PvE zones ({_data.Zones.Count}):"); foreach (var zone in _data.Zones) { var pos = zone.Position.ToVector3(); SendReply(player, $"- {zone.Name}: radius {zone.Radius:0.#}, bubble fixed at 1% map size, pos ({pos.x:0.#}, {pos.y:0.#}, {pos.z:0.#})"); } } private void CmdZoneHere(BasePlayer player, string command, string[] args) { if (!HasAdminPermission(player)) return; var zone = GetZoneAtPosition(player.transform.position); SendReply(player, zone == null ? "You are not standing inside a PvE zone." : $"You are inside PvE zone '{zone.Name}'."); } // Diagnostic: dumps ShortPrefabName + container type + inventory state // for any nearby water-ish entity so we can pin down exactly where the // refill path is failing. [ChatCommand("waterdiag")] private void CmdWaterDiag(BasePlayer player, string command, string[] args) { if (!HasAdminPermission(player)) return; var pos = player.transform.position; var enabledShortnames = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var pair in _waterByShortname) { if (pair.Value?.Settings != null && pair.Value.Settings.Enabled) enabledShortnames.Add(pair.Key); } var waterDef = ItemManager.FindItemDefinition("water"); var found = new List(); foreach (var entity in BaseNetworkable.serverEntities) { if (entity == null || entity.IsDestroyed) continue; var name = entity.ShortPrefabName ?? string.Empty; if (name.IndexOf("water", StringComparison.OrdinalIgnoreCase) < 0 && name.IndexOf("barrel", StringComparison.OrdinalIgnoreCase) < 0) continue; var dist = Vector3.Distance(entity.transform.position, pos); if (dist > 50f) continue; var marker = enabledShortnames.Contains(name) ? "[ON]" : "[off]"; var typeName = entity.GetType().Name; var inventory = GetEntityInventory(entity); // Reflect the same permission gate the refill uses so it's // obvious when an entity is matched + enabled but skipped // because the owner lacks the registered permission. string permInfo = string.Empty; WaterEntry entry; if (_waterByShortname.TryGetValue(name, out entry) && entry?.Settings != null && entry.Settings.UsePermission) { var ownerId = (entity as BaseEntity)?.OwnerID ?? 0UL; var permName = entry.Settings.Permission ?? string.Empty; var hasPerm = ownerId != 0UL && !string.IsNullOrEmpty(permName) && permission.UserHasPermission(ownerId.ToString(), permName); permInfo = $" perm={(hasPerm ? "yes" : "NO")}({permName})"; } string invInfo; if (inventory == null) { invInfo = "no-inventory"; } else { var existing = waterDef != null ? inventory.FindItemByItemID(waterDef.itemid) : null; invInfo = $"slots={inventory.capacity} maxStack={inventory.maxStackSize} items={inventory.itemList?.Count ?? 0} water={(existing == null ? "none" : existing.amount.ToString())}"; } found.Add($"{marker} {name} <{typeName}> ({dist:0.0}m) {invInfo}{permInfo}"); } if (found.Count == 0) { SendReply(player, "No water-related entities within 50m. Stand next to one and try again."); return; } found.Sort(); SendReply(player, "Nearby water entities:\n" + string.Join("\n", found)); } // Manually trigger a refill tick. Saves waiting 30s between code changes. [ChatCommand("waterforce")] private void CmdWaterForce(BasePlayer player, string command, string[] args) { if (!HasAdminPermission(player)) return; RefillEnabledWaterEntities(); SendReply(player, "Forced water refill tick. Run /waterdiag to verify results."); } // Electrical diagnostic — same shape as /waterdiag. Lists nearby entities // whose ShortPrefabName contains "generator", "battery", "solar", or // "wind" / "mill", and reports the current refill state. [ChatCommand("electricaldiag")] private void CmdElectricalDiag(BasePlayer player, string command, string[] args) { if (!HasAdminPermission(player)) return; var pos = player.transform.position; var enabledShortnames = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var pair in _electricalByShortname) { if (pair.Value?.Settings != null && pair.Value.Settings.Enabled) enabledShortnames.Add(pair.Key); } var found = new List(); foreach (var entity in BaseNetworkable.serverEntities) { if (entity == null || entity.IsDestroyed) continue; var name = entity.ShortPrefabName ?? string.Empty; if (name.IndexOf("generator", StringComparison.OrdinalIgnoreCase) < 0 && name.IndexOf("battery", StringComparison.OrdinalIgnoreCase) < 0 && name.IndexOf("solar", StringComparison.OrdinalIgnoreCase) < 0 && name.IndexOf("wind", StringComparison.OrdinalIgnoreCase) < 0 && name.IndexOf("mill", StringComparison.OrdinalIgnoreCase) < 0) continue; var dist = Vector3.Distance(entity.transform.position, pos); if (dist > 50f) continue; var marker = enabledShortnames.Contains(name) ? "[ON]" : "[off]"; var typeName = entity.GetType().Name; ElectricalEntry entry; var kindLabel = _electricalByShortname.TryGetValue(name, out entry) && entry != null ? ElectricalKindLabel(entry.Kind) : "[unknown]"; string permInfo = string.Empty; if (entry?.Settings != null && entry.Settings.UsePermission) { var ownerId = (entity as BaseEntity)?.OwnerID ?? 0UL; var permName = entry.Settings.Permission ?? string.Empty; var hasPerm = ownerId != 0UL && !string.IsNullOrEmpty(permName) && permission.UserHasPermission(ownerId.ToString(), permName); permInfo = $" perm={(hasPerm ? "yes" : "NO")}"; } found.Add($"{marker} {name} <{typeName}> {kindLabel} ({dist:0.0}m){permInfo}"); } if (found.Count == 0) { SendReply(player, "No electrical entities within 50m. Stand next to one and try again."); return; } found.Sort(); SendReply(player, "Nearby electrical entities:\n" + string.Join("\n", found)); } // Diagnostic: dump every field name + type + current value of the nearest // electrical entity to oxide/logs/DynamicBubbleController/dbc_dump.txt. // Used to discover correct field names when AlwaysOn output overrides // don't work because Rust renamed an internal field. [ChatCommand("DynamicBubbleDebug")] private void CmdDynamicBubbleDebug(BasePlayer player, string command, string[] args) { if (!HasAdminPermission(player)) return; DumpNearestElectrical(player); } // Server-console callable (e.g. via RCON) — no player needed. [ConsoleCommand("pvezonemap.dumpelectrical")] private void CcmdDumpElectricalServer(ConsoleSystem.Arg arg) { var player = arg?.Player(); if (player != null && !HasAdminPermission(player)) return; DumpNearestElectrical(player); arg?.ReplyWith("Dumped electrical types to oxide/logs/DynamicBubbleController/dbc_dump-*.txt"); } // Reports GetPassthroughAmount() for every windmill / solar on the map, // alongside whether the override gate fires. Used to verify the Harmony // patches are doing what we expect without needing to wire it up in-game. [ConsoleCommand("pvezonemap.electricaltest")] private void CcmdElectricalTest(ConsoleSystem.Arg arg) { var player = arg?.Player(); if (player != null && !HasAdminPermission(player)) return; var sb = new System.Text.StringBuilder(); int count = 0; foreach (var ent in BaseNetworkable.serverEntities) { if (ent == null || ent.IsDestroyed) continue; var be = ent as BaseEntity; if (be == null) continue; var wm = be as ElectricWindmill; if (wm != null) { var passthrough = wm.GetPassthroughAmount(0); var shouldOverride = ShouldOverrideAlwaysOn(wm); ElectricalEntry entry; var enabled = _electricalByShortname.TryGetValue(wm.ShortPrefabName, out entry) && entry?.Settings != null && entry.Settings.Enabled; sb.AppendLine($"Windmill at {wm.transform.position} | passthrough={passthrough} | shouldOverride={shouldOverride} | guiEnabled={enabled} | windSpeed={wm.serverWindSpeed:F3}"); if (++count >= 20) break; continue; } var sp = be as SolarPanel; if (sp != null) { var passthrough = sp.GetPassthroughAmount(0); var shouldOverride = ShouldOverrideAlwaysOn(sp); ElectricalEntry entry; var enabled = _electricalByShortname.TryGetValue(sp.ShortPrefabName, out entry) && entry?.Settings != null && entry.Settings.Enabled; sb.AppendLine($"Solar at {sp.transform.position} | passthrough={passthrough} | shouldOverride={shouldOverride} | guiEnabled={enabled} | maxOut={sp.maximalPowerOutput}"); if (++count >= 20) break; } } if (count == 0) sb.AppendLine("No windmills or solar panels found on the map."); var report = sb.ToString(); arg?.ReplyWith(report); PrintWarning(report); } private void DumpNearestElectrical(BasePlayer player) { // Find ONE representative of each electrical-entity shortname known // to the plugin. Dump fields + properties for each, one section per // type, so the same log lets us compare wind / solar / battery / etc. var wantedShortnames = new HashSet( _electricalByShortname.Keys, StringComparer.OrdinalIgnoreCase); var samples = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var entity in BaseNetworkable.serverEntities) { if (entity == null || entity.IsDestroyed) continue; var n = entity.ShortPrefabName ?? string.Empty; if (!wantedShortnames.Contains(n)) continue; if (samples.ContainsKey(n)) continue; // one sample per type is enough var be = entity as BaseEntity; if (be == null) continue; samples[n] = be; if (samples.Count >= wantedShortnames.Count) break; } if (samples.Count == 0) { if (player != null) SendReply(player, "No electrical entities found on the map to dump."); else PrintWarning("No electrical entities found on the map to dump."); return; } const System.Reflection.BindingFlags flags = System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance; var sb = new System.Text.StringBuilder(); sb.AppendLine($"=== DBC dump generated {DateTime.Now:yyyy-MM-dd HH:mm:ss} ==="); sb.AppendLine($"Found {samples.Count} of {wantedShortnames.Count} known electrical types on the map."); sb.AppendLine(); foreach (var kvp in samples.OrderBy(x => x.Key, StringComparer.Ordinal)) { var ent = kvp.Value; sb.AppendLine("--------------------------------------------------------"); sb.AppendLine($"Shortname : {ent.ShortPrefabName}"); sb.AppendLine($"Concrete type : {ent.GetType().FullName}"); sb.AppendLine($"Inheritance :"); for (var t = ent.GetType(); t != null && t != typeof(object); t = t.BaseType) sb.AppendLine($" - {t.FullName}"); sb.AppendLine(); sb.AppendLine("Fields:"); var seen = new HashSet(); for (var t = ent.GetType(); t != null && t != typeof(object); t = t.BaseType) { foreach (var f in t.GetFields(flags)) { if (!seen.Add(f.Name)) continue; string val; try { val = (f.GetValue(ent) ?? "null").ToString(); } catch { val = ""; } if (val.Length > 120) val = val.Substring(0, 120) + "..."; sb.AppendLine($" [{t.Name}] {f.FieldType.Name} {f.Name} = {val}"); } } sb.AppendLine(); sb.AppendLine("Properties:"); var seenP = new HashSet(); for (var t = ent.GetType(); t != null && t != typeof(object); t = t.BaseType) { foreach (var p in t.GetProperties(flags)) { if (!seenP.Add(p.Name)) continue; if (p.GetIndexParameters().Length > 0) continue; string val; try { val = (p.GetValue(ent, null) ?? "null").ToString(); } catch { val = ""; } if (val.Length > 120) val = val.Substring(0, 120) + "..."; sb.AppendLine($" [{t.Name}] {p.PropertyType.Name} {p.Name} = {val}"); } } sb.AppendLine(); sb.AppendLine("Methods (declared, walking inheritance):"); var seenM = new HashSet(); for (var t = ent.GetType(); t != null && t != typeof(object); t = t.BaseType) { foreach (var m in t.GetMethods(flags | System.Reflection.BindingFlags.DeclaredOnly)) { if (m.IsSpecialName) continue; // skip property getters/setters var pars = string.Join(", ", m.GetParameters().Select(pp => pp.ParameterType.Name + " " + pp.Name)); var sig = $"{m.ReturnType.Name} {m.Name}({pars})"; if (!seenM.Add(sig)) continue; sb.AppendLine($" [{t.Name}] {sig}"); } } sb.AppendLine(); } // Note which types weren't found on the map var missing = wantedShortnames.Except(samples.Keys, StringComparer.OrdinalIgnoreCase).ToList(); if (missing.Count > 0) { sb.AppendLine("--------------------------------------------------------"); sb.AppendLine("Types not found on this map (no sample to dump):"); foreach (var m in missing) sb.AppendLine($" - {m}"); } LogToFile("dbc_dump", sb.ToString(), this); if (player != null) SendReply(player, $"Dumped {samples.Count} electrical type(s) to oxide/logs/DynamicBubbleController/dbc_dump.txt"); else PrintWarning($"Dumped {samples.Count} electrical type(s) to oxide/logs/DynamicBubbleController/dbc_dump.txt"); } [ChatCommand("electricalforce")] private void CmdElectricalForce(BasePlayer player, string command, string[] args) { if (!HasAdminPermission(player)) return; RefillEnabledElectricalEntities(); SendReply(player, "Forced electrical refill tick. Run /electricaldiag to verify results."); } [ChatCommand("bubble")] private void CmdZoneUi(BasePlayer player, string command, string[] args) { if (!HasAdminPermission(player)) return; // Always land on the Hub so the user picks which module to enter. // Power users can still use /light to skip straight to Light Controller. _selectedTabByPlayer[player.userID] = "hub"; if (_data.Zones.Count > 0 && GetSelectedZone(player) == null) SelectZone(player, _data.Zones[0].Name); OpenMainUi(player); } [ChatCommand("light")] private void CmdLightUi(BasePlayer player, string command, string[] args) { if (!HasAdminPermission(player)) return; _selectedTabByPlayer[player.userID] = "everlight"; OpenMainUi(player); } [ChatCommand("mybase")] private void CmdPlayerZoneRules(BasePlayer player, string command, string[] args) { if (player == null || !player.IsConnected) return; ZoneEntry zone; string reason; if (!TryGetPlayerControlledZone(player, out zone, out reason)) { SendReply(player, reason); return; } _zoneNameDraftByPlayer[player.userID] = zone.Name; OpenPlayerRulesUi(player, zone); } private void CmdCancelInput(BasePlayer player, string command, string[] args) { if (player == null) return; _pendingInputByPlayer.Remove(player.userID); SendReply(player, "Pending input cancelled."); OpenMainUi(player); } #endregion #region Console Commands [ConsoleCommand("pvezonemap.ui.close")] private void CcmdClose(ConsoleSystem.Arg arg) { var player = arg.Player(); if (!HasAdminPermission(player)) return; DestroyUi(player); } [ConsoleCommand("pvezonemap.ui.search")] private void CcmdSearch(ConsoleSystem.Arg arg) { var player = arg.Player(); if (!HasAdminPermission(player)) return; // No args = clear filter (the "x" clear button binds to this with no args). var value = arg.Args == null || arg.Args.Length == 0 ? string.Empty : string.Join(" ", arg.Args).Trim(); if (string.IsNullOrEmpty(value)) _zoneSearchByPlayer.Remove(player.userID); else _zoneSearchByPlayer[player.userID] = value; // Filter only affects the sidebar; body content depends on the // selected zone, which is unchanged by typing in the search box. RedrawSidebar(player); } [ConsoleCommand("pvezonemap.ui.select")] private void CcmdSelect(ConsoleSystem.Arg arg) { var player = arg.Player(); if (!HasAdminPermission(player)) return; if (arg.Args == null || arg.Args.Length < 1) return; var zoneName = string.Join(" ", arg.Args).Trim(); if (string.IsNullOrEmpty(zoneName)) return; SelectZone(player, zoneName); RedrawSidebarAndBody(player); } [ConsoleCommand("pvezonemap.ui.tab")] private void CcmdTab(ConsoleSystem.Arg arg) { var player = arg.Player(); if (!HasAdminPermission(player)) return; if (arg.Args == null || arg.Args.Length < 1) return; var oldTab = GetSelectedTab(player); var oldFullWidth = IsFullWidthTab(oldTab); var oldHub = oldTab == "hub"; SelectTab(player, arg.GetString(0)); var newTab = GetSelectedTab(player); var newFullWidth = IsFullWidthTab(newTab); var newHub = newTab == "hub"; // Auto-select the first zone when entering the Zone Manager so the // body has something to render instead of "No zones". if (!newFullWidth && _data.Zones.Count > 0 && GetSelectedZone(player) == null) SelectZone(player, _data.Zones[0].Name); // Layout differs between fullwidth modes (hub/light/water) and the // Zone Manager (sidebar + sub-tab bar). Crossing that boundary // needs a full rebuild; staying on the same side can do partial. if (oldFullWidth != newFullWidth) { OpenMainUi(player); } else if (newFullWidth) { // Crossing the hub boundary while staying fullwidth changes the // header (the "< Hub" button appears/disappears), so the header // needs a partial redraw too. if (oldHub != newHub) RedrawHeader(player); RedrawBody(player); } else { RedrawTabBarAndBody(player); } } [ConsoleCommand("pvezonemap.ui.createhere")] private void CcmdCreateHere(ConsoleSystem.Arg arg) { var player = arg.Player(); if (!HasAdminPermission(player)) return; var zoneName = $"Zone{_data.Zones.Count + 1}"; while (FindZone(zoneName) != null) zoneName = $"Zone{UnityEngine.Random.Range(100, 999)}"; var zone = CreateDefaultZone(zoneName, player.transform.position, 100f); _data.Zones.Add(zone); SaveData(); RefreshZone(zone); SyncTruePveMapping(zone); SelectZone(player, zone.Name); // Clear any active search filter so the new zone is visible in the sidebar. _zoneSearchByPlayer.Remove(player.userID); RedrawSidebarAndBody(player); } [ConsoleCommand("pvezonemap.ui.remove")] private void CcmdRemove(ConsoleSystem.Arg arg) { var player = arg.Player(); if (!HasAdminPermission(player)) return; var zone = GetSelectedZone(player); if (zone == null) return; RemoveZone(zone.Name); RedrawSidebarAndBody(player); } [ConsoleCommand("pvezonemap.ui.duplicate")] private void CcmdDuplicate(ConsoleSystem.Arg arg) { var player = arg.Player(); if (!HasAdminPermission(player)) return; var zone = GetSelectedZone(player); if (zone == null) return; var copy = CloneZone(zone); copy.Name = GetUniqueZoneName(zone.Name + "_copy"); copy.Position = new SerializableVector3(zone.Position.ToVector3() + new Vector3(8f, 0f, 8f)); _data.Zones.Add(copy); SaveData(); RefreshZone(copy); SyncTruePveMapping(copy); SelectZone(player, copy.Name); // Clear search so the duplicated zone is visible. _zoneSearchByPlayer.Remove(player.userID); RedrawSidebarAndBody(player); } [ConsoleCommand("pvezonemap.ui.preview")] private void CcmdPreview(ConsoleSystem.Arg arg) { var player = arg.Player(); if (!HasAdminPermission(player)) return; if (_previewEnabledPlayers.Contains(player.userID)) _previewEnabledPlayers.Remove(player.userID); else _previewEnabledPlayers.Add(player.userID); // Only the General-tab Preview button shows this state. RedrawBody(player); } [ConsoleCommand("pvezonemap.ui.movehere")] private void CcmdMoveHere(ConsoleSystem.Arg arg) { var player = arg.Player(); if (!HasAdminPermission(player)) return; var zone = GetSelectedZone(player); if (zone == null) return; zone.Position = new SerializableVector3(player.transform.position); SaveData(); RefreshZone(zone); RefreshStatusesForZone(zone); // Position is not displayed in the panel — no UI redraw needed. } [ConsoleCommand("pvezonemap.ui.adjust")] private void CcmdAdjust(ConsoleSystem.Arg arg) { var player = arg.Player(); if (!HasAdminPermission(player)) return; if (arg.Args == null || arg.Args.Length < 2) return; var zone = GetSelectedZone(player); if (zone == null) return; float amount; if (!float.TryParse(arg.GetString(1), out amount)) return; var redrawBody = false; switch (arg.GetString(0)) { case "radius": zone.Radius = Mathf.Clamp(zone.Radius + amount, 5f, 3000f); SaveData(); RefreshZone(zone); RefreshStatusesForZone(zone); // No UI element shows the radius value — markers/spheres update via RefreshZone. break; case "alpha": var alphaPercent = Mathf.Clamp((_config.MarkerAlpha * 100f) + amount, 1f, 100f); _config.MarkerAlpha = alphaPercent / 100f; SaveConfig(); RefreshAllMarkers(); redrawBody = true; // Bubble tab shows "Alpha: X%" break; } if (redrawBody) RedrawBody(player); } [ConsoleCommand("pvezonemap.ui.toggle")] private void CcmdToggle(ConsoleSystem.Arg arg) { var player = arg.Player(); if (!HasAdminPermission(player)) return; if (arg.Args == null || arg.Args.Length < 1) return; var zone = GetSelectedZone(player); if (zone == null) return; var refreshMarkers = false; var refreshStatuses = false; switch (arg.GetString(0)) { case "pvp": zone.BlockPlayerVsPlayer = !zone.BlockPlayerVsPlayer; break; case "structures": zone.BlockPlayerVsStructures = !zone.BlockPlayerVsStructures; break; case "npc": zone.BlockPlayerVsNPC = !zone.BlockPlayerVsNPC; break; case "traps": zone.BlockTrapsAndTurrets = !zone.BlockTrapsAndTurrets; break; case "status": zone.UseAdvancedStatus = !zone.UseAdvancedStatus; refreshStatuses = true; break; case "showonmap": zone.ShowOnMap = !zone.ShowOnMap; refreshMarkers = true; break; case "label": zone.CreateLabelMarker = !zone.CreateLabelMarker; refreshMarkers = true; break; case "nodecay": zone.NoDecay = !zone.NoDecay; refreshStatuses = true; break; case "teleport": zone.AllowTeleport = !zone.AllowTeleport; break; } SaveData(); if (refreshMarkers) RefreshZone(zone); if (refreshStatuses) RefreshStatusesForZone(zone); // Toggle button labels/colors live in the body card. RedrawBody(player); } [ConsoleCommand("pvezonemap.ui.color")] private void CcmdColor(ConsoleSystem.Arg arg) { var player = arg.Player(); if (!HasAdminPermission(player)) return; if (arg.Args == null || arg.Args.Length < 2) return; var zone = GetSelectedZone(player); if (zone == null) return; var target = arg.GetString(0); var color = arg.GetString(1); var refreshMarkers = false; var refreshStatuses = false; switch (target) { case "zone": zone.ZoneColorHex = color; refreshMarkers = true; break; case "outline": zone.OutlineColorHex = color; refreshMarkers = true; break; case "statusbg": zone.StatusBackgroundColor = color; refreshStatuses = true; break; case "statustext": zone.StatusTextColor = color; refreshStatuses = true; break; } SaveData(); if (refreshMarkers) RefreshZone(zone); if (refreshStatuses) RefreshStatusesForZone(zone); // Color swatches don't display a selected state in the panel itself. // Map markers / status bars update via the Refresh* calls above. } [ConsoleCommand("pvezonemap.ui.draft")] private void CcmdDraft(ConsoleSystem.Arg arg) { var player = arg.Player(); if (!HasAdminPermission(player)) return; if (arg.Args == null || arg.Args.Length < 2) return; var target = arg.GetString(0); var value = string.Join(" ", arg.Args, 1, arg.Args.Length - 1).Trim(); switch (target) { case "name": _zoneNameDraftByPlayer[player.userID] = value; break; case "status": _statusTextDraftByPlayer[player.userID] = value; break; } } [ConsoleCommand("pvezonemap.ui.save")] private void CcmdSave(ConsoleSystem.Arg arg) { var player = arg.Player(); if (!HasAdminPermission(player)) return; if (arg.Args == null || arg.Args.Length < 1) return; var zone = GetSelectedZone(player); if (zone == null) return; var redrawSidebar = false; switch (arg.GetString(0)) { case "name": string newName; if (!_zoneNameDraftByPlayer.TryGetValue(player.userID, out newName)) return; newName = (newName ?? string.Empty).Trim(); if (string.IsNullOrEmpty(newName)) { SendReply(player, "Zone name cannot be empty."); return; } var existing = FindZone(newName); if (existing != null && !existing.Name.Equals(zone.Name, StringComparison.OrdinalIgnoreCase)) { SendReply(player, "A zone with that name already exists."); return; } var oldName = zone.Name; DestroyMarkers(oldName); RemoveTruePveMapping(oldName); zone.Name = newName; _zoneNameDraftByPlayer[player.userID] = newName; SaveData(); UpdateSelectedZoneReferences(oldName, newName); UpdateLastZoneReferences(oldName, newName); SelectZone(player, newName); RefreshZone(zone); SyncTruePveMapping(zone); RefreshStatusesForZone(zone); SendReply(player, $"Zone renamed to '{newName}'."); redrawSidebar = true; // Sidebar shows the new label break; case "status": string newStatus; if (!_statusTextDraftByPlayer.TryGetValue(player.userID, out newStatus)) return; newStatus = (newStatus ?? string.Empty).Trim(); if (string.IsNullOrEmpty(newStatus)) { SendReply(player, "Status text cannot be empty."); return; } zone.StatusText = newStatus; _statusTextDraftByPlayer[player.userID] = newStatus; SaveData(); RefreshStatusesForZone(zone); SendReply(player, $"Status text changed to '{zone.StatusText}'."); break; } if (redrawSidebar) RedrawSidebarAndBody(player); else RedrawBody(player); } [ConsoleCommand("pvezonemap.player.close")] private void CcmdPlayerClose(ConsoleSystem.Arg arg) { var player = arg.Player(); if (player == null) return; DestroyPlayerUi(player); } [ConsoleCommand("pvezonemap.player.toggle")] private void CcmdPlayerToggle(ConsoleSystem.Arg arg) { var player = arg.Player(); if (player == null) return; if (arg.Args == null || arg.Args.Length < 1) return; ZoneEntry zone; string reason; if (!TryGetPlayerControlledZone(player, out zone, out reason)) { SendReply(player, reason); DestroyPlayerUi(player); return; } var which = arg.GetString(0); bool isRuleChange = which == "pvp" || which == "structures" || which == "npc" || which == "traps"; if (isRuleChange) { TimeSpan remaining; if (IsZoneRuleChangeLocked(zone, out remaining)) { SendReply(player, $"{FormatZonePrefix(zone)} Rules are locked for another {FormatRemaining(remaining)}. Changes cool down for {PlayerRuleCooldownHours}h each."); OpenPlayerRulesUi(player, zone); return; } } bool refreshMarkers = false; switch (which) { case "pvp": zone.BlockPlayerVsPlayer = !zone.BlockPlayerVsPlayer; break; case "structures": zone.BlockPlayerVsStructures = !zone.BlockPlayerVsStructures; break; case "npc": zone.BlockPlayerVsNPC = !zone.BlockPlayerVsNPC; break; case "traps": zone.BlockTrapsAndTurrets = !zone.BlockTrapsAndTurrets; break; case "showonmap": zone.ShowOnMap = !zone.ShowOnMap; refreshMarkers = true; break; default: return; } if (isRuleChange) zone.RuleChangeLockedUntil = DateTime.UtcNow.AddHours(PlayerRuleCooldownHours); SaveData(); if (refreshMarkers) RefreshZone(zone); OpenPlayerRulesUi(player, zone); if (isRuleChange) SendReply(player, $"{FormatZonePrefix(zone)} Rule updated. Rules now locked for {PlayerRuleCooldownHours}h."); else SendReply(player, $"{FormatZonePrefix(zone)} Zone updated."); } [ConsoleCommand("pvezonemap.player.draft")] private void CcmdPlayerDraft(ConsoleSystem.Arg arg) { var player = arg.Player(); if (player == null) return; if (arg.Args == null || arg.Args.Length < 2) return; var kind = arg.GetString(0); var value = string.Join(" ", arg.Args.Skip(1).ToArray()).Trim(); switch (kind) { case "name": _zoneNameDraftByPlayer[player.userID] = value; break; case "status": _statusTextDraftByPlayer[player.userID] = value; break; } } [ConsoleCommand("pvezonemap.player.color")] private void CcmdPlayerColor(ConsoleSystem.Arg arg) { var player = arg.Player(); if (player == null) return; if (arg.Args == null || arg.Args.Length < 2) return; var target = arg.GetString(0); var hex = arg.GetString(1); ZoneEntry zone; string reason; if (!TryGetPlayerControlledZone(player, out zone, out reason)) { SendReply(player, reason); DestroyPlayerUi(player); return; } bool refreshMarkers = false, refreshStatuses = false; switch (target) { case "zone": zone.ZoneColorHex = hex; refreshMarkers = true; break; case "outline": zone.OutlineColorHex = hex; refreshMarkers = true; break; case "statusbg": zone.StatusBackgroundColor = hex; refreshStatuses = true; break; case "statustext": zone.StatusTextColor = hex; refreshStatuses = true; break; default: return; } SaveData(); if (refreshMarkers) RefreshZone(zone); if (refreshStatuses) RefreshStatusesForZone(zone); OpenPlayerRulesUi(player, zone); } [ConsoleCommand("pvezonemap.player.tab")] private void CcmdPlayerTab(ConsoleSystem.Arg arg) { var player = arg.Player(); if (player == null) return; if (arg.Args == null || arg.Args.Length < 1) return; ZoneEntry zone; string reason; if (!TryGetPlayerControlledZone(player, out zone, out reason)) { SendReply(player, reason); DestroyPlayerUi(player); return; } _selectedPlayerTabByPlayer[player.userID] = arg.GetString(0).ToLower(); OpenPlayerRulesUi(player, zone); } [ConsoleCommand("pvezonemap.player.save")] private void CcmdPlayerSave(ConsoleSystem.Arg arg) { var player = arg.Player(); if (player == null) return; ZoneEntry zone; string reason; if (!TryGetPlayerControlledZone(player, out zone, out reason)) { SendReply(player, reason); DestroyPlayerUi(player); return; } var kind = (arg.Args != null && arg.Args.Length > 0) ? arg.GetString(0) : "name"; if (kind == "status") { string newStatus; if (!_statusTextDraftByPlayer.TryGetValue(player.userID, out newStatus)) return; newStatus = (newStatus ?? string.Empty).Trim(); if (string.IsNullOrEmpty(newStatus)) { SendReply(player, "Status text cannot be empty."); return; } zone.StatusText = newStatus; _statusTextDraftByPlayer[player.userID] = newStatus; SaveData(); RefreshStatusesForZone(zone); SendReply(player, $"{FormatZonePrefix(zone)} Status text changed to '{zone.StatusText}'."); OpenPlayerRulesUi(player, zone); return; } string newName; if (!_zoneNameDraftByPlayer.TryGetValue(player.userID, out newName)) return; newName = (newName ?? string.Empty).Trim(); if (string.IsNullOrEmpty(newName)) { SendReply(player, "Zone name cannot be empty."); return; } var existing = FindZone(newName); if (existing != null && !existing.Name.Equals(zone.Name, StringComparison.OrdinalIgnoreCase)) { SendReply(player, "A zone with that name already exists."); return; } var oldName = zone.Name; DestroyMarkers(oldName); RemoveTruePveMapping(oldName); zone.Name = newName; _zoneNameDraftByPlayer[player.userID] = newName; SaveData(); UpdateSelectedZoneReferences(oldName, newName); UpdateLastZoneReferences(oldName, newName); RefreshZone(zone); SyncTruePveMapping(zone); RefreshStatusesForZone(zone); SendReply(player, $"{FormatZonePrefix(zone)} Zone renamed successfully."); OpenPlayerRulesUi(player, zone); } [ConsoleCommand("pvezonemap.ui.everlighttoggle")] private void CcmdEverlightToggle(ConsoleSystem.Arg arg) { var player = arg.Player(); if (!HasAdminPermission(player)) return; if (arg.Args == null || arg.Args.Length < 1) return; int index; if (!int.TryParse(arg.GetString(0), out index)) return; var keys = GetOrderedEverlightKeys(); if (index < 0 || index >= keys.Count) return; EverlightSettings settings; if (!_config.EverlightList.TryGetValue(keys[index], out settings)) return; settings.Enabled = !settings.Enabled; _config.EverlightList[keys[index]] = settings; SaveConfig(); BuildEverlightCache(); // Light list lives in the body for the everlight tab. RedrawBody(player); } [ConsoleCommand("pvezonemap.ui.everlightpage")] private void CcmdEverlightPage(ConsoleSystem.Arg arg) { var player = arg.Player(); if (!HasAdminPermission(player)) return; if (arg.Args == null || arg.Args.Length < 1) return; int page; if (!int.TryParse(arg.GetString(0), out page)) return; var wasEverlight = GetSelectedTab(player) == "everlight"; _everlightPageByPlayer[player.userID] = Mathf.Max(0, page); SelectTab(player, "everlight"); // Only the layout changes if the player wasn't already on the everlight tab. if (wasEverlight) RedrawBody(player); else OpenMainUi(player); } [ConsoleCommand("pvezonemap.ui.watertoggle")] private void CcmdWaterToggle(ConsoleSystem.Arg arg) { var player = arg.Player(); if (!HasAdminPermission(player)) return; if (arg.Args == null || arg.Args.Length < 1) return; int index; if (!int.TryParse(arg.GetString(0), out index)) return; var keys = GetOrderedWaterKeys(); if (index < 0 || index >= keys.Count) return; WaterSettings settings; if (!_config.WaterList.TryGetValue(keys[index], out settings)) return; settings.Enabled = !settings.Enabled; _config.WaterList[keys[index]] = settings; SaveConfig(); BuildWaterCache(); // Water list lives in the body for the water tab. RedrawBody(player); } [ConsoleCommand("pvezonemap.ui.waterpage")] private void CcmdWaterPage(ConsoleSystem.Arg arg) { var player = arg.Player(); if (!HasAdminPermission(player)) return; if (arg.Args == null || arg.Args.Length < 1) return; int page; if (!int.TryParse(arg.GetString(0), out page)) return; var wasWater = GetSelectedTab(player) == "water"; _waterPageByPlayer[player.userID] = Mathf.Max(0, page); SelectTab(player, "water"); if (wasWater) RedrawBody(player); else OpenMainUi(player); } [ConsoleCommand("pvezonemap.ui.electricaltoggle")] private void CcmdElectricalToggle(ConsoleSystem.Arg arg) { var player = arg.Player(); if (!HasAdminPermission(player)) return; if (arg.Args == null || arg.Args.Length < 1) return; int index; if (!int.TryParse(arg.GetString(0), out index)) return; var keys = GetOrderedElectricalKeys(); if (index < 0 || index >= keys.Count) return; ElectricalSettings settings; if (!_config.ElectricalList.TryGetValue(keys[index], out settings)) return; settings.Enabled = !settings.Enabled; _config.ElectricalList[keys[index]] = settings; SaveConfig(); BuildElectricalCache(); RedrawBody(player); } [ConsoleCommand("pvezonemap.ui.electricalpage")] private void CcmdElectricalPage(ConsoleSystem.Arg arg) { var player = arg.Player(); if (!HasAdminPermission(player)) return; if (arg.Args == null || arg.Args.Length < 1) return; int page; if (!int.TryParse(arg.GetString(0), out page)) return; var wasElectrical = GetSelectedTab(player) == "electrical"; _electricalPageByPlayer[player.userID] = Mathf.Max(0, page); SelectTab(player, "electrical"); if (wasElectrical) RedrawBody(player); else OpenMainUi(player); } #endregion private void ApplyTruePveMappings() { if (TruePVE == null) return; foreach (var zone in _data.Zones) SyncTruePveMapping(zone); } private void SyncTruePveMapping(ZoneEntry zone) { if (TruePVE == null || zone == null || string.IsNullOrEmpty(zone.Name)) return; TruePVE.Call("AddOrUpdateMapping", zone.Name, "exclude"); } private void RemoveTruePveMapping(string zoneName) { if (TruePVE == null || string.IsNullOrEmpty(zoneName)) return; TruePVE.Call("RemoveMapping", zoneName); } #region Zone Logic private ZoneEntry CreateDefaultZone(string name, Vector3 position, float radius) { return new ZoneEntry { Name = name, Position = new SerializableVector3(position), Radius = radius, BubblePercent = 100f, ZoneColorHex = "#27AE60", OutlineColorHex = "#0B0B0B", ShowOnMap = true, CreateLabelMarker = false, NoDecay = true, AllowTeleport = true, BlockPlayerVsPlayer = true, BlockPlayerVsStructures = true, BlockPlayerVsNPC = true, BlockTrapsAndTurrets = true, UseAdvancedStatus = true, StatusText = "PVE ZONE", StatusBackgroundColor = "#27AE60", StatusTextColor = "#FFFFFF", StatusIcon = _config.DefaultStatusIcon }; } private ZoneEntry CloneZone(ZoneEntry source) { return new ZoneEntry { Name = source.Name, Position = new SerializableVector3(source.Position.ToVector3()), Radius = source.Radius, BubblePercent = source.BubblePercent, ZoneColorHex = source.ZoneColorHex, OutlineColorHex = source.OutlineColorHex, ShowOnMap = source.ShowOnMap, CreateLabelMarker = source.CreateLabelMarker, NoDecay = source.NoDecay, AllowTeleport = source.AllowTeleport, BlockPlayerVsPlayer = source.BlockPlayerVsPlayer, BlockPlayerVsStructures = source.BlockPlayerVsStructures, BlockPlayerVsNPC = source.BlockPlayerVsNPC, BlockTrapsAndTurrets = source.BlockTrapsAndTurrets, UseAdvancedStatus = source.UseAdvancedStatus, StatusText = source.StatusText, StatusBackgroundColor = source.StatusBackgroundColor, StatusTextColor = source.StatusTextColor, StatusIcon = source.StatusIcon, RuleChangeLockedUntil = source.RuleChangeLockedUntil }; } // Player-side rule cooldown helpers. Locked when RuleChangeLockedUntil // is in the future; remaining is the time until unlock. private static bool IsZoneRuleChangeLocked(ZoneEntry zone, out TimeSpan remaining) { remaining = zone.RuleChangeLockedUntil - DateTime.UtcNow; return remaining.TotalSeconds > 0; } private static string FormatRemaining(TimeSpan ts) { if (ts.TotalHours >= 1) return $"{(int)ts.TotalHours}h {ts.Minutes}m"; if (ts.TotalMinutes >= 1) return $"{(int)ts.TotalMinutes}m"; return "less than a minute"; } private string GetUniqueZoneName(string baseName) { var name = baseName; var counter = 1; while (FindZone(name) != null) { name = baseName + counter; counter++; } return name; } private void SelectZone(BasePlayer player, string zoneName) { if (player == null || string.IsNullOrEmpty(zoneName)) return; var zone = FindZone(zoneName); if (zone == null) return; _selectedZoneByPlayer[player.userID] = zone.Name; } private ZoneEntry GetSelectedZone(BasePlayer player) { if (player == null) return null; string zoneName; if (!_selectedZoneByPlayer.TryGetValue(player.userID, out zoneName)) { if (_data.Zones.Count > 0) { _selectedZoneByPlayer[player.userID] = _data.Zones[0].Name; return _data.Zones[0]; } return null; } var zone = FindZone(zoneName); if (zone != null) return zone; if (_data.Zones.Count > 0) { _selectedZoneByPlayer[player.userID] = _data.Zones[0].Name; return _data.Zones[0]; } return null; } private void SelectTab(BasePlayer player, string tab) { if (player == null || string.IsNullOrEmpty(tab)) return; _selectedTabByPlayer[player.userID] = tab.ToLower(); } private string GetSelectedTab(BasePlayer player) { if (player == null) return "hub"; string tab; if (_selectedTabByPlayer.TryGetValue(player.userID, out tab) && !string.IsNullOrEmpty(tab)) return tab; _selectedTabByPlayer[player.userID] = "hub"; return "hub"; } private void CheckPlayersInZones() { foreach (var player in BasePlayer.activePlayerList) { if (player == null || !player.IsConnected) continue; var zone = GetZoneAtPosition(player.transform.position); var isInside = zone != null; var tracked = _playersInsideZone.Contains(player.userID); string previousZoneName; _lastZoneByPlayer.TryGetValue(player.userID, out previousZoneName); if (isInside && !tracked) { _playersInsideZone.Add(player.userID); _lastZoneByPlayer[player.userID] = zone.Name; CreateOrUpdateStatus(player); if (_config.NotifyPlayers) SendReply(player, $"{FormatZonePrefix(zone)} {_config.EnterMessage}"); } else if (!isInside && tracked) { _playersInsideZone.Remove(player.userID); ZoneEntry lastZone = null; if (!string.IsNullOrEmpty(previousZoneName)) lastZone = FindZone(previousZoneName); _lastZoneByPlayer.Remove(player.userID); DeleteStatus(player); if (_config.NotifyPlayers) SendReply(player, $"{FormatZonePrefix(lastZone)} {_config.LeaveMessage}"); } else if (isInside && tracked && !string.Equals(previousZoneName, zone.Name, StringComparison.OrdinalIgnoreCase)) { _lastZoneByPlayer[player.userID] = zone.Name; CreateOrUpdateStatus(player); } } } private ZoneEntry GetRelevantZoneForDamage(BaseCombatEntity victim, HitInfo info) { var victimZone = GetZoneAtPosition(victim.transform.position); if (victimZone != null) return victimZone; var attacker = FindResponsiblePlayer(info); if (attacker != null) return GetZoneAtPosition(attacker.transform.position); if (info.Initiator != null) return GetZoneAtPosition(info.Initiator.transform.position); return null; } private bool ShouldBlockDamage(ZoneEntry zone, BaseCombatEntity victim) { if (zone == null || victim == null) return false; if (victim is BasePlayer) return zone.BlockPlayerVsPlayer; if (victim is BaseNpc || victim.ShortPrefabName.IndexOf("npc", StringComparison.OrdinalIgnoreCase) >= 0) return zone.BlockPlayerVsNPC; if (victim.ShortPrefabName.IndexOf("autoturret", StringComparison.OrdinalIgnoreCase) >= 0 || victim.ShortPrefabName.IndexOf("guntrap", StringComparison.OrdinalIgnoreCase) >= 0 || victim.ShortPrefabName.IndexOf("flameturret", StringComparison.OrdinalIgnoreCase) >= 0 || victim.ShortPrefabName.IndexOf("beartrap", StringComparison.OrdinalIgnoreCase) >= 0 || victim.ShortPrefabName.IndexOf("landmine", StringComparison.OrdinalIgnoreCase) >= 0) return zone.BlockTrapsAndTurrets; if (victim is DecayEntity || victim is BuildingBlock || victim is Door || victim is StorageContainer) return zone.BlockPlayerVsStructures; return zone.BlockPlayerVsStructures; } private BasePlayer FindResponsiblePlayer(HitInfo info) { if (info?.InitiatorPlayer != null) return info.InitiatorPlayer; var initiator = info?.Initiator; if (initiator == null) return null; if (initiator is BasePlayer player) return player; if (initiator.OwnerID.IsSteamId()) return BasePlayer.FindByID(initiator.OwnerID) ?? BasePlayer.FindSleeping(initiator.OwnerID); return null; } private ZoneEntry GetZoneAtPosition(Vector3 position) { for (var i = 0; i < _data.Zones.Count; i++) { var zone = _data.Zones[i]; var center = zone.Position.ToVector3(); var radius = zone.Radius; var dx = position.x - center.x; var dz = position.z - center.z; if ((dx * dx) + (dz * dz) <= (radius * radius)) return zone; } return null; } private ZoneEntry FindZone(string name) { return _data.Zones.Find(z => z.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); } private void RemoveZone(string name) { var zone = FindZone(name); if (zone == null) return; DestroyMarkers(zone.Name); RemoveTruePveMapping(zone.Name); _data.Zones.Remove(zone); SaveData(); RemoveZoneTracking(name); foreach (var key in new List(_selectedZoneByPlayer.Keys)) { if (string.Equals(_selectedZoneByPlayer[key], name, StringComparison.OrdinalIgnoreCase)) _selectedZoneByPlayer.Remove(key); } } private void DrawPreviewSpheres() { if (_previewEnabledPlayers.Count == 0) return; foreach (var userId in new List(_previewEnabledPlayers)) { var player = BasePlayer.FindByID(userId); if (player == null || !player.IsConnected) { _previewEnabledPlayers.Remove(userId); continue; } var zone = GetSelectedZone(player); if (zone == null) continue; var center = zone.Position.ToVector3() + new Vector3(0f, 0.5f, 0f); player.SendConsoleCommand("ddraw.sphere", 1.1f, Color.green, center, zone.Radius); player.SendConsoleCommand("ddraw.text", 1.1f, Color.white, center + new Vector3(0f, 2f, 0f), zone.Name + $" ({zone.Radius:0}m)"); } } private object CanTeleport(BasePlayer player, Vector3 to) { var fromZone = player == null ? null : GetZoneAtPosition(player.transform.position); if (fromZone != null && !fromZone.AllowTeleport) return "Teleporting is disabled in this zone."; var toZone = GetZoneAtPosition(to); if (toZone != null && !toZone.AllowTeleport) return "You cannot teleport into that zone."; return null; } #endregion #region Everlight private void InitializeEverlight() { foreach (var pair in _everlightDefaults) { if (!_config.EverlightList.ContainsKey(pair.Key)) { _config.EverlightList[pair.Key] = new EverlightSettings( pair.Value.Settings.Enabled, pair.Value.Settings.UsePermission, pair.Value.Settings.Permission ); } else { var existing = _config.EverlightList[pair.Key]; if (existing != null && (string.IsNullOrEmpty(existing.Permission) || existing.Permission.StartsWith("pvezonemap.", StringComparison.OrdinalIgnoreCase) || !existing.Permission.StartsWith("dynamicbubblecontroller.", StringComparison.OrdinalIgnoreCase))) { existing.Permission = pair.Value.Settings.Permission; _config.EverlightList[pair.Key] = existing; } } if (!permission.PermissionExists(pair.Value.Settings.Permission)) permission.RegisterPermission(pair.Value.Settings.Permission, this); } SaveConfig(); BuildEverlightCache(); } private void CacheBurnables() { _burnableDefinitions.Clear(); foreach (var itemDef in ItemManager.GetItemDefinitions()) { if (itemDef == null) continue; var burnable = itemDef.GetComponent(); if (burnable != null && !_burnableDefinitions.ContainsKey(burnable)) _burnableDefinitions.Add(burnable, itemDef); } } private void BuildEverlightCache() { _everlightByShortname.Clear(); foreach (var pair in _everlightDefaults) { EverlightSettings settings; if (_config.EverlightList.TryGetValue(pair.Key, out settings)) _everlightByShortname[pair.Value.ShortName] = new EverlightEntry(pair.Value.ShortName, settings); else _everlightByShortname[pair.Value.ShortName] = new EverlightEntry(pair.Value.ShortName, pair.Value.Settings); } } private bool CanEverlight(string shortname, string playerId) { if (string.IsNullOrEmpty(shortname)) return false; EverlightEntry entry; if (!_everlightByShortname.TryGetValue(shortname, out entry)) return false; if (!entry.Settings.Enabled) return false; if (!entry.Settings.UsePermission) return true; return !string.IsNullOrEmpty(playerId) && permission.UserHasPermission(playerId, entry.Settings.Permission); } private List GetOrderedEverlightKeys() { var priority = new List { "Torch", "Cultist Deer Torch", "Abyss Torch", "Chinese Lantern", "Chinese Lantern White", "Lantern", "Tuna Can Lamp", "Hobo Barrel", "Camp Fire" }; var ordered = new List(); foreach (var key in priority) { if (_config.EverlightList.ContainsKey(key) && !ordered.Contains(key)) ordered.Add(key); } foreach (var key in _config.EverlightList.Keys.OrderBy(x => x)) { if (!ordered.Contains(key)) ordered.Add(key); } return ordered; } private int GetEverlightPage(BasePlayer player) { if (player == null) return 0; int page; if (_everlightPageByPlayer.TryGetValue(player.userID, out page)) return page; return 0; } #endregion #region Water private void InitializeWater() { // Remove obsolete entries from earlier plugin versions. foreach (var key in _waterObsoleteKeys) _config.WaterList.Remove(key); foreach (var pair in _waterDefaults) { if (!_config.WaterList.ContainsKey(pair.Key)) { _config.WaterList[pair.Key] = new WaterSettings( pair.Value.Settings.Enabled, pair.Value.Settings.UsePermission, pair.Value.Settings.Permission ); } else { var existing = _config.WaterList[pair.Key]; if (existing != null && string.IsNullOrEmpty(existing.Permission)) { existing.Permission = pair.Value.Settings.Permission; _config.WaterList[pair.Key] = existing; } } if (!permission.PermissionExists(pair.Value.Settings.Permission)) permission.RegisterPermission(pair.Value.Settings.Permission, this); } SaveConfig(); BuildWaterCache(); } private void BuildWaterCache() { _waterByShortname.Clear(); foreach (var pair in _waterDefaults) { WaterSettings settings; if (_config.WaterList.TryGetValue(pair.Key, out settings)) _waterByShortname[pair.Value.ShortName] = new WaterEntry(pair.Value.ShortName, settings); else _waterByShortname[pair.Value.ShortName] = new WaterEntry(pair.Value.ShortName, pair.Value.Settings); } } private List GetOrderedWaterKeys() { var priority = new List { "Small Water Catcher", "Large Water Catcher", "Water Barrel" }; var ordered = new List(); foreach (var key in priority) { if (_config.WaterList.ContainsKey(key) && !ordered.Contains(key)) ordered.Add(key); } foreach (var key in _config.WaterList.Keys.OrderBy(x => x)) { if (!ordered.Contains(key)) ordered.Add(key); } return ordered; } private int GetWaterPage(BasePlayer player) { if (player == null) return 0; int page; if (_waterPageByPlayer.TryGetValue(player.userID, out page)) return page; return 0; } // Periodic top-up: every WaterRefillInterval seconds, scan world entities // whose ShortPrefabName matches an enabled water entry, and fill each // container's water inventory to its stack cap. Sourceless refilling // is exactly what the Water Controller is for. private void RefillEnabledWaterEntities() { if (_config?.WaterList == null || _waterByShortname.Count == 0) return; var enabled = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var pair in _waterByShortname) { if (pair.Value?.Settings != null && pair.Value.Settings.Enabled) enabled.Add(pair.Key); } if (enabled.Count == 0) return; var waterDef = ItemManager.FindItemDefinition("water"); if (waterDef == null) return; foreach (var entity in BaseNetworkable.serverEntities) { if (entity == null || entity.IsDestroyed) continue; var shortname = entity.ShortPrefabName; if (!enabled.Contains(shortname)) continue; // Permission gate: when UsePermission is true on the entry, // only refill containers whose owner has the registered // permission. Mirrors the Light Controller's CanEverlight check. WaterEntry entry; if (_waterByShortname.TryGetValue(shortname, out entry) && entry?.Settings != null && entry.Settings.UsePermission) { var ownerId = (entity as BaseEntity)?.OwnerID ?? 0UL; if (ownerId == 0UL || string.IsNullOrEmpty(entry.Settings.Permission) || !permission.UserHasPermission(ownerId.ToString(), entry.Settings.Permission)) continue; } var inventory = GetEntityInventory(entity); if (inventory == null) continue; TopUpWaterInventory(inventory, waterDef); } } // Resolve the ItemContainer for a water entity. WaterCatcher and the // water barrel are LiquidContainer / ContainerIOEntity types in current // Rust — those are NOT StorageContainer subclasses, so a direct cast // misses them. We walk the inheritance chain looking for an `inventory` // field or property of type ItemContainer, which works for any current // or future container variant without hard-coding class names. private ItemContainer GetEntityInventory(BaseNetworkable entity) { var storage = entity as StorageContainer; if (storage?.inventory != null) return storage.inventory; var baseEntity = entity as BaseEntity; if (baseEntity == null) return null; var component = baseEntity.GetComponent(); if (component?.inventory != null) return component.inventory; const System.Reflection.BindingFlags flags = System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance; for (var t = baseEntity.GetType(); t != null && t != typeof(object); t = t.BaseType) { var fld = t.GetField("inventory", flags); if (fld != null && typeof(ItemContainer).IsAssignableFrom(fld.FieldType)) { var val = fld.GetValue(baseEntity) as ItemContainer; if (val != null) return val; } var prop = t.GetProperty("inventory", flags); if (prop != null && typeof(ItemContainer).IsAssignableFrom(prop.PropertyType)) { var val = prop.GetValue(baseEntity) as ItemContainer; if (val != null) return val; } } return null; } private void TopUpWaterInventory(ItemContainer inventory, ItemDefinition waterDef) { if (inventory == null || waterDef == null) return; // Slot cap precedence: container.maxStackSize → item.stackable → 5000 fallback. var maxStack = inventory.maxStackSize > 0 ? inventory.maxStackSize : waterDef.stackable; if (maxStack <= 0) maxStack = 5000; var existing = inventory.FindItemByItemID(waterDef.itemid); if (existing != null) { if (existing.amount >= maxStack) return; existing.amount = maxStack; existing.MarkDirty(); return; } // Don't disturb non-water items already in the slot (e.g. purifier output). if (inventory.itemList != null && inventory.itemList.Count > 0) return; // Use ItemContainer.AddItem — same code path the vanilla rain // collector uses, which respects the container's filter rules. inventory.AddItem(waterDef, maxStack, 0UL); } #endregion #region Electrical private void InitializeElectrical() { foreach (var pair in _electricalDefaults) { if (!_config.ElectricalList.ContainsKey(pair.Key)) { _config.ElectricalList[pair.Key] = new ElectricalSettings( pair.Value.Settings.Enabled, pair.Value.Settings.UsePermission, pair.Value.Settings.Permission ); } else { var existing = _config.ElectricalList[pair.Key]; if (existing != null && string.IsNullOrEmpty(existing.Permission)) { existing.Permission = pair.Value.Settings.Permission; _config.ElectricalList[pair.Key] = existing; } } if (!permission.PermissionExists(pair.Value.Settings.Permission)) permission.RegisterPermission(pair.Value.Settings.Permission, this); } SaveConfig(); BuildElectricalCache(); } private void BuildElectricalCache() { _electricalByShortname.Clear(); foreach (var pair in _electricalDefaults) { ElectricalSettings settings; if (_config.ElectricalList.TryGetValue(pair.Key, out settings)) _electricalByShortname[pair.Value.ShortName] = new ElectricalEntry(pair.Value.ShortName, pair.Value.Kind, settings); else _electricalByShortname[pair.Value.ShortName] = new ElectricalEntry(pair.Value.ShortName, pair.Value.Kind, pair.Value.Settings); } } private List GetOrderedElectricalKeys() { var priority = new List { "Small Generator", "Test Generator", "Small Battery", "Medium Battery", "Large Battery", "Solar Panel", "Wind Turbine" }; var ordered = new List(); foreach (var key in priority) { if (_config.ElectricalList.ContainsKey(key) && !ordered.Contains(key)) ordered.Add(key); } foreach (var key in _config.ElectricalList.Keys.OrderBy(x => x)) { if (!ordered.Contains(key)) ordered.Add(key); } return ordered; } private int GetElectricalPage(BasePlayer player) { if (player == null) return 0; int page; if (_electricalPageByPlayer.TryGetValue(player.userID, out page)) return page; return 0; } // Periodic refill — runs every ElectricalRefillInterval seconds. For // each enabled entity, applies the right mechanic for its kind: // FuelGenerator → top up lowgradefuel in the inventory // Battery → set current charge to max via reflection // AlwaysOn → force output / passthrough field(s) to max private void RefillEnabledElectricalEntities() { if (_config?.ElectricalList == null || _electricalByShortname.Count == 0) return; var fuelDef = ItemManager.FindItemDefinition("lowgradefuel"); foreach (var entity in BaseNetworkable.serverEntities) { if (entity == null || entity.IsDestroyed) continue; ElectricalEntry entry; if (!_electricalByShortname.TryGetValue(entity.ShortPrefabName, out entry)) continue; if (entry?.Settings == null || !entry.Settings.Enabled) continue; // AlwaysOn entities (solar / wind / test gen) are handled by the // separate 1-second fast tick — skip them here to avoid double work. if (entry.Kind == ElectricalKind.AlwaysOn) continue; if (entry.Settings.UsePermission) { var ownerId = (entity as BaseEntity)?.OwnerID ?? 0UL; if (ownerId == 0UL || string.IsNullOrEmpty(entry.Settings.Permission) || !permission.UserHasPermission(ownerId.ToString(), entry.Settings.Permission)) continue; } ApplyElectricalRefill(entity, entry, fuelDef); } } // Called from Harmony postfixes — answers "should this AlwaysOn entity be // forced to peak output right now?". Server-wide: any windmill/solar // whose per-entity toggle is ON gets pegged to max, regardless of zones. internal bool ShouldOverrideAlwaysOn(BaseEntity entity) { if (entity == null || entity.IsDestroyed) return false; if (_electricalByShortname.Count == 0) return false; ElectricalEntry entry; if (!_electricalByShortname.TryGetValue(entity.ShortPrefabName, out entry)) return false; if (entry?.Settings == null || !entry.Settings.Enabled) return false; if (entry.Kind != ElectricalKind.AlwaysOn) return false; if (entry.Settings.UsePermission) { var ownerId = entity.OwnerID; if (ownerId == 0UL || string.IsNullOrEmpty(entry.Settings.Permission) || !permission.UserHasPermission(ownerId.ToString(), entry.Settings.Permission)) return false; } return true; } // GetPassthroughAmount(outputSlot) is what the power network reads to // push amps down each output wire. Both ElectricWindmill and SolarPanel // declare it directly (verified via /DynamicBubbleDebug method list), so the // Harmony lookup is unambiguous. [HarmonyPatch(typeof(ElectricWindmill), "GetPassthroughAmount", new[] { typeof(int) })] public static class Patch_ElectricWindmill_GetPassthroughAmount { static void Postfix(ElectricWindmill __instance, ref int __result) { var p = Instance; if (p == null || !p.ShouldOverrideAlwaysOn(__instance)) return; __result = __instance.maxPowerGeneration; } } [HarmonyPatch(typeof(SolarPanel), "GetPassthroughAmount", new[] { typeof(int) })] public static class Patch_SolarPanel_GetPassthroughAmount { static void Postfix(SolarPanel __instance, ref int __result) { var p = Instance; if (p == null || !p.ShouldOverrideAlwaysOn(__instance)) return; __result = __instance.maximalPowerOutput; } } private void ApplyElectricalRefill(BaseNetworkable entity, ElectricalEntry entry, ItemDefinition fuelDef) { switch (entry.Kind) { case ElectricalKind.FuelGenerator: if (fuelDef != null) { var inventory = GetEntityInventory(entity); if (inventory != null) TopUpFuelInventory(inventory, fuelDef); } break; case ElectricalKind.Battery: // ElectricBattery in current Rust stores charge as `rustWattSeconds` // capped by `maxCapactiySeconds` (yes, Rust source has the typo). SetPowerFieldToMax(entity, new[] { "rustWattSeconds" }, new[] { "maxCapactiySeconds" }); break; case ElectricalKind.AlwaysOn: // Handled authoritatively by Harmony postfixes on // ElectricWindmill.GetCurrentEnergy / SolarPanel.GetCurrentEnergy. // No per-tick work needed here. break; } } private void TopUpFuelInventory(ItemContainer inventory, ItemDefinition fuelDef) { if (inventory == null || fuelDef == null) return; var maxStack = inventory.maxStackSize > 0 ? inventory.maxStackSize : fuelDef.stackable; if (maxStack <= 0) maxStack = fuelDef.stackable > 0 ? fuelDef.stackable : 500; var existing = inventory.FindItemByItemID(fuelDef.itemid); if (existing != null) { if (existing.amount >= maxStack) return; existing.amount = maxStack; existing.MarkDirty(); return; } if (inventory.itemList != null && inventory.itemList.Count > 0) return; inventory.AddItem(fuelDef, maxStack, 0UL); } // Set the first field whose name matches one of `currentNames` to the // value of the first matching field in `maxNames`. Operates on whatever // type the field actually is (int, float). Walks the inheritance chain // so inherited fields work too. private bool SetPowerFieldToMax(BaseNetworkable entity, string[] currentNames, string[] maxNames) { var be = entity as BaseEntity; if (be == null) return false; const System.Reflection.BindingFlags flags = System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance; System.Reflection.FieldInfo currentField = null; System.Reflection.FieldInfo maxField = null; for (var t = be.GetType(); t != null && t != typeof(object); t = t.BaseType) { if (currentField == null) { foreach (var name in currentNames) { var f = t.GetField(name, flags); if (f != null) { currentField = f; break; } } } if (maxField == null) { foreach (var name in maxNames) { var f = t.GetField(name, flags); if (f != null) { maxField = f; break; } } } if (currentField != null && maxField != null) break; } if (currentField == null) return false; object newValue; if (maxField != null) { newValue = maxField.GetValue(be); } else { // No max field located — fall back to a generous constant that // covers vanilla power outputs (solar / windmill cap at ~100). if (currentField.FieldType == typeof(float)) newValue = 1000f; else if (currentField.FieldType == typeof(int)) newValue = 1000; else return false; } try { // Coerce int/float as needed so we don't blow up on mismatched field types. if (currentField.FieldType == typeof(float) && newValue is int) newValue = (float)(int)newValue; else if (currentField.FieldType == typeof(int) && newValue is float) newValue = (int)(float)newValue; currentField.SetValue(be, newValue); // Nudge any IOEntity to re-evaluate its outputs so connected // consumers see the change immediately. var ioEntity = be as IOEntity; if (ioEntity != null) ioEntity.SendNetworkUpdate(); return true; } catch { return false; } } #endregion #region Advanced Status private int GetPlayersInsideZoneCount(ZoneEntry zone) { if (zone == null) return 0; return _lastZoneByPlayer.Values.Count(zoneName => string.Equals(zoneName, zone.Name, StringComparison.OrdinalIgnoreCase)); } private void CreateOrUpdateStatus(BasePlayer player) { if (player == null || AdvancedStatus == null) return; var zone = GetZoneAtPosition(player.transform.position); if (zone == null || !zone.UseAdvancedStatus) { DeleteStatus(player); return; } var ready = AdvancedStatus.Call("IsReady"); if (!(ready is bool) || !(bool)ready) return; var signature = GetStatusSignature(zone); string existingSignature; if (_lastStatusSignatureByPlayer.TryGetValue(player.userID, out existingSignature) && string.Equals(existingSignature, signature, StringComparison.Ordinal)) { return; } DeleteStatus(player); var parameters = new Dictionary { ["Id"] = "PveZoneMap_Status", ["Plugin"] = Name, ["BarType"] = "Default", ["Order"] = _config.StatusOrder, ["Main_Color"] = zone.StatusBackgroundColor, ["Text"] = BuildAdvancedStatusText(zone), ["Text_Color"] = zone.StatusTextColor }; if (!string.IsNullOrEmpty(zone.StatusIcon)) parameters["Image"] = zone.StatusIcon; AdvancedStatus.Call("CreateBar", player.userID, parameters); _lastStatusSignatureByPlayer[player.userID] = signature; } private string BuildAdvancedStatusText(ZoneEntry zone) { var baseText = string.IsNullOrEmpty(zone.StatusText) ? zone.Name : zone.StatusText; if (zone.NoDecay) { var suffix = string.IsNullOrEmpty(_config.NoDecayStatusSuffix) ? "Will Not Decay" : _config.NoDecayStatusSuffix; return $"{baseText} • {suffix}"; } return baseText; } private void DeleteStatus(BasePlayer player) { if (player == null || AdvancedStatus == null) return; AdvancedStatus.Call("DeleteBar", player.userID, "PveZoneMap_Status", Name); _lastStatusSignatureByPlayer.Remove(player.userID); } #endregion #region Marker Handling private void EnsureMarkersHealthy() { foreach (var zone in _data.Zones) { if (zone == null) continue; if (NeedsMarkerRefresh(zone)) RefreshZone(zone); } } private bool NeedsMarkerRefresh(ZoneEntry zone) { if (zone == null) return false; MarkerPair pair; if (!_liveMarkers.TryGetValue(zone.Name, out pair) || pair == null) return zone.ShowOnMap; if (!zone.ShowOnMap) return pair.RadiusMarker != null || pair.LabelMarker != null; if (pair.RadiusMarker == null || pair.RadiusMarker.IsDestroyed) return true; if (zone.CreateLabelMarker && (pair.LabelMarker == null || pair.LabelMarker.IsDestroyed)) return true; if (!zone.CreateLabelMarker && pair.LabelMarker != null && !pair.LabelMarker.IsDestroyed) return true; return false; } private ZoneEntry FindZoneByMarker(BaseNetworkable entity) { if (entity == null) return null; foreach (var pair in _liveMarkers) { if (pair.Value == null) continue; if (pair.Value.RadiusMarker == entity || pair.Value.LabelMarker == entity) return FindZone(pair.Key); } return null; } private void SpawnMarkers(ZoneEntry zone) { if (zone == null || string.IsNullOrEmpty(zone.Name)) return; DestroyMarkers(zone.Name); var position = zone.Position.ToVector3(); var pair = new MarkerPair(); if (!zone.ShowOnMap) { _liveMarkers[zone.Name] = pair; return; } var radiusMarker = GameManager.server.CreateEntity(RadiusMarkerPrefab, position) as MapMarkerGenericRadius; if (radiusMarker != null) { var baseColor = ParseColor(zone.ZoneColorHex, new Color(0.15f, 0.68f, 0.37f, 1f)); var outlineColor = ParseColor(zone.OutlineColorHex, Color.black); radiusMarker.alpha = _config.MarkerAlpha; radiusMarker.color1 = new Color(baseColor.r, baseColor.g, baseColor.b, 1f); radiusMarker.color2 = new Color(outlineColor.r, outlineColor.g, outlineColor.b, 1f); radiusMarker.radius = zone.Radius * 0.01f; radiusMarker.Spawn(); radiusMarker.SendUpdate(); pair.RadiusMarker = radiusMarker; } if (zone.CreateLabelMarker) { var labelMarker = GameManager.server.CreateEntity(VendingMarkerPrefab, position) as VendingMachineMapMarker; if (labelMarker != null) { labelMarker.markerShopName = zone.Name; labelMarker.Spawn(); labelMarker.SendNetworkUpdate(); pair.LabelMarker = labelMarker; } } _liveMarkers[zone.Name] = pair; } private void DestroyMarkers(string zoneName) { MarkerPair pair; if (!_liveMarkers.TryGetValue(zoneName, out pair)) return; var wasSuppressed = _suppressMarkerRespawn; _suppressMarkerRespawn = true; if (pair.RadiusMarker != null && !pair.RadiusMarker.IsDestroyed) pair.RadiusMarker.Kill(); if (pair.LabelMarker != null && !pair.LabelMarker.IsDestroyed) pair.LabelMarker.Kill(); _suppressMarkerRespawn = wasSuppressed; _liveMarkers.Remove(zoneName); } private void DestroyAllMarkers() { foreach (var zoneName in new List(_liveMarkers.Keys)) DestroyMarkers(zoneName); } private void RefreshAllMarkers() { DestroyAllMarkers(); foreach (var zone in _data.Zones) SpawnMarkers(zone); } private void RefreshZone(ZoneEntry zone) { if (zone == null) return; SpawnMarkers(zone); } #endregion #region UI private const string UiHeader = UiMain + ".Header"; private const string UiSidebar = UiMain + ".Sidebar"; private const string UiContent = UiMain + ".Content"; private const string UiTabBar = UiMain + ".TabBar"; private const string UiBody = UiMain + ".BodyCard"; // Tabs that render full-width (no sidebar / no sub-tab bar): the Hub // landing page, Light Controller, Water Controller, and Electrical // Controller. The Zone Manager sub-tabs keep the sidebar. private static bool IsFullWidthTab(string tab) { return tab == "hub" || tab == "everlight" || tab == "water" || tab == "electrical"; } private void OpenMainUi(BasePlayer player) { if (player == null || !player.IsConnected) return; DestroyUi(player); var container = new CuiElementContainer(); var selectedTab = GetSelectedTab(player); var fullWidth = IsFullWidthTab(selectedTab); BuildRoot(container); BuildHeader(container, player); if (!fullWidth) BuildSidebar(container, player); BuildContent(container, player); if (!fullWidth) BuildTabBar(container, player); BuildBody(container, player); CuiHelper.AddUi(player, container); } // ===== Section build helpers ===== private void BuildRoot(CuiElementContainer container) { container.Add(new CuiPanel { Image = { Color = "0.04 0.06 0.08 0.96" }, RectTransform = { AnchorMin = "0.12 0.12", AnchorMax = "0.88 0.88" }, CursorEnabled = true }, "Hud", UiMain); } private void BuildHeader(CuiElementContainer container, BasePlayer player) { var selectedTab = GetSelectedTab(player); var onHub = selectedTab == "hub"; var inZoneManager = !onHub && !IsFullWidthTab(selectedTab); container.Add(new CuiPanel { Image = { Color = "0.10 0.15 0.20 0.98" }, RectTransform = { AnchorMin = "0 0.92", AnchorMax = "1 1" } }, UiMain, UiHeader); container.Add(new CuiLabel { Text = { Text = "DYNAMIC BUBBLE CONTROLLER", FontSize = 22, Align = TextAnchor.MiddleLeft, Color = "1 1 1 1" }, RectTransform = { AnchorMin = "0.02 0.15", AnchorMax = "0.24 0.90" } }, UiHeader); // Back-to-Hub button is hidden on the Hub itself. Plain ASCII so the // Rust default font always renders the label (some glyphs do not). if (!onHub) { AddButton(container, UiHeader, "< Hub", "0.24 0.24 0.24 0.98", "0.25 0.18", "0.42 0.82", "pvezonemap.ui.tab hub", 16); } // Zone-scoped actions only make sense inside the Zone Manager. if (inZoneManager) { AddButton(container, UiHeader, "Create", "0.17 0.62 0.24 0.95", "0.52 0.18", "0.64 0.82", "pvezonemap.ui.createhere", 14); AddButton(container, UiHeader, "Move", "0.17 0.62 0.24 0.95", "0.65 0.18", "0.75 0.82", "pvezonemap.ui.movehere", 14); AddButton(container, UiHeader, "Duplicate", "0.95 0.77 0.05 0.95", "0.76 0.18", "0.85 0.82", "pvezonemap.ui.duplicate", 14); AddButton(container, UiHeader, "Delete", "0.75 0.20 0.20 0.95", "0.86 0.18", "0.94 0.82", "pvezonemap.ui.remove", 14); } AddButton(container, UiHeader, "X", "0.75 0.20 0.20 0.95", "0.97 0.18", "0.995 0.82", "pvezonemap.ui.close", 16); } private void BuildSidebar(CuiElementContainer container, BasePlayer player) { var zone = GetSelectedZone(player); var query = GetZoneSearchQuery(player); var hasQuery = !string.IsNullOrEmpty(query); // Filter the zone list by search query (case-insensitive substring). var filtered = hasQuery ? _data.Zones.Where(z => z.Name.IndexOf(query, StringComparison.OrdinalIgnoreCase) >= 0).ToList() : _data.Zones; container.Add(new CuiPanel { Image = { Color = "0.08 0.08 0.08 0.90" }, RectTransform = { AnchorMin = "0.02 0.04", AnchorMax = "0.31 0.89" } }, UiMain, UiSidebar); var countLabel = hasQuery ? $"Zones ({filtered.Count}/{_data.Zones.Count})" : $"Zones ({_data.Zones.Count})"; AddText(container, UiSidebar, countLabel, 18, "0.06 0.90", "0.65 0.97"); container.Add(new CuiPanel { Image = { Color = "0.18 0.18 0.18 0.95" }, RectTransform = { AnchorMin = "0.05 0.81", AnchorMax = "0.95 0.88" } }, UiSidebar, UiSidebar + ".Search"); AddText(container, UiSidebar + ".Search", "⌕", 16, "0.03 0.10", "0.12 0.90", "0.80 0.80 0.80 1"); // Real input field. Pressing Enter sends the typed text to // pvezonemap.ui.search which stores the query and refilters. AddInputField(container, UiSidebar + ".Search", query, "pvezonemap.ui.search", "0.13 0.05", hasQuery ? "0.85 0.95" : "0.97 0.95"); // Tiny clear button only renders when there's an active filter. if (hasQuery) { AddButton(container, UiSidebar + ".Search", "x", "0.55 0.20 0.20 0.95", "0.86 0.10", "0.97 0.90", "pvezonemap.ui.search", 14); } var startY = 0.74f; var step = 0.095f; var rendered = 0; for (var i = 0; i < filtered.Count && rendered < 6; i++) { var listZone = filtered[i]; var selected = zone != null && zone.Name.Equals(listZone.Name, StringComparison.OrdinalIgnoreCase); var yMax = startY - (rendered * step); var yMin = yMax - 0.075f; var color = selected ? "0.16 0.65 0.22 0.95" : "0.22 0.22 0.22 0.95"; var dotColor = selected ? "0.18 0.70 0.22 1" : "0.75 0.45 0.35 1"; var playersInside = GetPlayersInsideZoneCount(listZone); container.Add(new CuiPanel { Image = { Color = dotColor }, RectTransform = { AnchorMin = $"0.08 {yMin + 0.018:0.000}", AnchorMax = $"0.14 {yMax - 0.018:0.000}" } }, UiSidebar); AddButton(container, UiSidebar, listZone.Name, color, $"0.16 {yMin:0.000}", $"0.78 {yMax:0.000}", $"pvezonemap.ui.select {listZone.Name}", 14); AddButton(container, UiSidebar, playersInside.ToString(), selected ? "0.11 0.45 0.16 0.98" : "0.18 0.18 0.18 0.98", $"0.80 {yMin:0.000}", $"0.92 {yMax:0.000}", $"pvezonemap.ui.select {listZone.Name}", 13); rendered++; } if (hasQuery && filtered.Count == 0) { AddText(container, UiSidebar, $"No zones match '{query}'.", 13, "0.08 0.66", "0.92 0.74", "0.70 0.70 0.70 1"); } AddButton(container, UiSidebar, "+ Add Zone", "0.24 0.24 0.24 0.95", "0.08 0.16", "0.92 0.24", "pvezonemap.ui.createhere", 14); AddButton(container, UiSidebar, "Manage Colors", "0.95 0.77 0.05 0.95", "0.08 0.07", "0.92 0.14", "pvezonemap.ui.tab bubble", 14); } private string GetZoneSearchQuery(BasePlayer player) { if (player == null) return string.Empty; string query; return _zoneSearchByPlayer.TryGetValue(player.userID, out query) ? (query ?? string.Empty) : string.Empty; } private void BuildContent(CuiElementContainer container, BasePlayer player) { var selectedTab = GetSelectedTab(player); var fullWidth = IsFullWidthTab(selectedTab); container.Add(new CuiPanel { Image = { Color = "0.11 0.11 0.11 0.90" }, RectTransform = { AnchorMin = fullWidth ? "0.02 0.04" : "0.33 0.04", AnchorMax = "0.98 0.89" } }, UiMain, UiContent); // The Zone Manager view has a "Zone Settings" header above its sub-tab bar. if (selectedTab != "hub" && !fullWidth) AddText(container, UiContent, "Zone Settings", 16, "0.42 0.90", "0.60 0.98"); } private void BuildTabBar(CuiElementContainer container, BasePlayer player) { var selectedTab = GetSelectedTab(player); // Transparent wrapper so the tab bar can be redrawn independently of Content. container.Add(new CuiPanel { Image = { Color = "0 0 0 0" }, RectTransform = { AnchorMin = "0 0.81", AnchorMax = "1 0.89" } }, UiContent, UiTabBar); AddTabButton(container, UiTabBar, "General", selectedTab == "general", "pvezonemap.ui.tab general", "0.08 0.125", "0.28 0.875"); AddTabButton(container, UiTabBar, "Rules", selectedTab == "rules", "pvezonemap.ui.tab rules", "0.30 0.125", "0.50 0.875"); AddTabButton(container, UiTabBar, "Bubble", selectedTab == "bubble", "pvezonemap.ui.tab bubble", "0.52 0.125", "0.72 0.875"); AddTabButton(container, UiTabBar, "Status", selectedTab == "status", "pvezonemap.ui.tab status", "0.74 0.125", "0.94 0.875"); } private void BuildBody(CuiElementContainer container, BasePlayer player) { var selectedTab = GetSelectedTab(player); if (selectedTab == "hub") { container.Add(new CuiPanel { Image = { Color = "0 0 0 0" }, RectTransform = { AnchorMin = "0.02 0.04", AnchorMax = "0.98 0.95" } }, UiContent, UiBody); BuildHubBody(container, player); return; } if (selectedTab == "everlight") { container.Add(new CuiPanel { Image = { Color = "0.15 0.15 0.15 0.95" }, RectTransform = { AnchorMin = "0.035 0.12", AnchorMax = "0.965 0.86" } }, UiContent, UiBody); BuildEverlightUi(container, player); return; } if (selectedTab == "water") { container.Add(new CuiPanel { Image = { Color = "0.15 0.15 0.15 0.95" }, RectTransform = { AnchorMin = "0.035 0.12", AnchorMax = "0.965 0.86" } }, UiContent, UiBody); BuildWaterBody(container, player); return; } if (selectedTab == "electrical") { container.Add(new CuiPanel { Image = { Color = "0.15 0.15 0.15 0.95" }, RectTransform = { AnchorMin = "0.035 0.12", AnchorMax = "0.965 0.86" } }, UiContent, UiBody); BuildElectricalBody(container, player); return; } var zone = GetSelectedZone(player); if (zone == null) { // Transparent body so message floats on Content panel — same look as before. container.Add(new CuiPanel { Image = { Color = "0 0 0 0" }, RectTransform = { AnchorMin = "0.03 0.23", AnchorMax = "0.97 0.80" } }, UiContent, UiBody); AddText(container, UiBody, "No zones yet. Press Create.", 20, "0.29 0.37", "0.71 0.58", "0.8 0.8 0.8 1"); return; } container.Add(new CuiPanel { Image = { Color = "0.15 0.15 0.15 0.95" }, RectTransform = { AnchorMin = "0.03 0.23", AnchorMax = "0.97 0.80" } }, UiContent, UiBody); var zoneNameDraft = _zoneNameDraftByPlayer.ContainsKey(player.userID) ? _zoneNameDraftByPlayer[player.userID] : zone.Name; var statusTextDraft = _statusTextDraftByPlayer.ContainsKey(player.userID) ? _statusTextDraftByPlayer[player.userID] : zone.StatusText; var previewOn = _previewEnabledPlayers.Contains(player.userID); switch (selectedTab) { case "general": BuildGeneralBody(container, zone, zoneNameDraft, previewOn); break; case "rules": BuildRulesBody(container, zone); break; case "bubble": BuildBubbleBody(container, zone); break; case "status": BuildStatusBody(container, zone, statusTextDraft); break; default: BuildEverlightUi(container, player); break; } } private void BuildHubBody(CuiElementContainer container, BasePlayer player) { // Four tiles in a row, evenly spaced. Each tile is a colored panel // with a centered title + subtitle inside, plus a transparent // button covering the whole tile to capture clicks. const string bubbleColor = "0.16 0.65 0.22 0.98"; // green const string lightColor = "0.92 0.76 0.06 0.98"; // gold const string waterColor = "0.15 0.55 0.85 0.98"; // blue const string electricalColor = "0.78 0.30 0.85 0.98"; // purple AddText(container, UiBody, "Choose a module", 16, "0.05 0.86", "0.95 0.92", "0.78 0.78 0.78 1"); BuildHubTile(container, UiBody + ".TileBubble", "Bubble", "PvE zones, no-decay, map markers", bubbleColor, "0.04 0.20", "0.245 0.78", "pvezonemap.ui.tab general"); BuildHubTile(container, UiBody + ".TileLight", "Light Controller", "Server-wide ever-burning lights", lightColor, "0.265 0.20", "0.47 0.78", "pvezonemap.ui.tab everlight"); BuildHubTile(container, UiBody + ".TileWater", "Water Controller", "Sourceless catchers / barrels", waterColor, "0.49 0.20", "0.695 0.78", "pvezonemap.ui.tab water"); BuildHubTile(container, UiBody + ".TileElectrical", "Electrical", "Free power for gens / batteries / solar", electricalColor, "0.715 0.20", "0.92 0.78", "pvezonemap.ui.tab electrical"); } private void BuildHubTile(CuiElementContainer container, string tileName, string title, string subtitle, string color, string anchorMin, string anchorMax, string command) { // Colored tile background. container.Add(new CuiPanel { Image = { Color = color }, RectTransform = { AnchorMin = anchorMin, AnchorMax = anchorMax } }, UiBody, tileName); // Subtle inner highlight strip near the top so the tile reads as a card. container.Add(new CuiPanel { Image = { Color = "1 1 1 0.10" }, RectTransform = { AnchorMin = "0 0.92", AnchorMax = "1 1" } }, tileName); // Title — large, centered in the upper half. Slightly smaller // than the 3-tile version so longer labels still fit. AddCenteredText(container, tileName, title, 20, "0.04 0.52", "0.96 0.84", "1 1 1 1"); // Subtitle — smaller, centered in the lower half. AddCenteredText(container, tileName, subtitle, 12, "0.04 0.18", "0.96 0.44", "1 1 1 0.85"); // Transparent click target covering the whole tile. CuiLabel // children don't intercept clicks, so the labels above stay // visible and the button still fires the command. container.Add(new CuiButton { Button = { Color = "0 0 0 0", Command = command }, RectTransform = { AnchorMin = "0 0", AnchorMax = "1 1" }, Text = { Text = string.Empty, FontSize = 1 } }, tileName); } private void AddCenteredText(CuiElementContainer container, string parent, string text, int fontSize, string anchorMin, string anchorMax, string color = null) { container.Add(new CuiLabel { Text = { Text = text, FontSize = fontSize, Align = TextAnchor.MiddleCenter, Color = color ?? _config.UiTextColor }, RectTransform = { AnchorMin = anchorMin, AnchorMax = anchorMax } }, parent); } private void BuildWaterBody(CuiElementContainer container, BasePlayer player) { AddText(container, UiBody, "Water Controller", 24, "0.04 0.92", "0.45 0.985", "0.55 0.85 1 1"); var keys = GetOrderedWaterKeys(); var totalPages = Mathf.Max(1, Mathf.CeilToInt(keys.Count / (float)EverlightItemsPerPage)); var page = Mathf.Clamp(GetWaterPage(player), 0, totalPages - 1); _waterPageByPlayer[player.userID] = page; AddText(container, UiBody, $"| Page {page + 1}/{totalPages}", 24, "0.46 0.92", "0.68 0.985", "0.55 0.85 1 1"); int start = page * EverlightItemsPerPage; int end = Mathf.Min(start + EverlightItemsPerPage, keys.Count); float listTop = 0.83f; float listBottomLimit = 0.14f; float rowHeight = 0.075f; float rowGap = 0.012f; for (int i = start; i < end; i++) { var key = keys[i]; var settings = _config.WaterList[key]; float currentTop = listTop - ((i - start) * (rowHeight + rowGap)); float currentBottom = currentTop - rowHeight; if (currentBottom < listBottomLimit) break; container.Add(new CuiPanel { Image = { Color = ((i - start) % 2 == 0) ? "0.11 0.11 0.11 0.96" : "0.13 0.13 0.13 0.96" }, RectTransform = { AnchorMin = $"0.03 {currentBottom:0.000}", AnchorMax = $"0.97 {currentTop:0.000}" } }, UiBody); AddText(container, UiBody, key, 16, $"0.05 {currentBottom + 0.012f:0.000}", $"0.54 {currentTop - 0.012f:0.000}"); AddText(container, UiBody, settings.Enabled ? "ON" : "OFF", 16, $"0.61 {currentBottom + 0.012f:0.000}", $"0.70 {currentTop - 0.012f:0.000}", settings.Enabled ? "0.2 0.95 0.2 1" : "0.95 0.25 0.25 1"); AddButton(container, UiBody, settings.Enabled ? "Disable" : "Enable", settings.Enabled ? "0.78 0.32 0.32 0.95" : "0.22 0.68 0.22 0.95", $"0.78 {currentBottom + 0.014f:0.000}", $"0.95 {currentTop - 0.014f:0.000}", $"pvezonemap.ui.watertoggle {i}", 14); } container.Add(new CuiPanel { Image = { Color = "0.10 0.10 0.10 0.98" }, RectTransform = { AnchorMin = "0.03 0.02", AnchorMax = "0.97 0.11" } }, UiBody); if (page > 0) AddButton(container, UiBody, "< Prev", "0.24 0.24 0.24 0.95", "0.04 0.035", "0.16 0.095", $"pvezonemap.ui.waterpage {page - 1}", 15); if (page < totalPages - 1) AddButton(container, UiBody, "Next >", "0.24 0.24 0.24 0.95", "0.80 0.035", "0.96 0.095", $"pvezonemap.ui.waterpage {page + 1}", 15); AddText(container, UiBody, $"Enabled entities top up to full every {Mathf.Max(5f, _config.WaterRefillInterval):0}s. Use /waterdiag to verify shortnames.", 14, "0.20 0.04", "0.78 0.10", "0.82 0.82 0.82 1"); } private void BuildElectricalBody(CuiElementContainer container, BasePlayer player) { AddText(container, UiBody, "Electrical Controller", 24, "0.04 0.92", "0.50 0.985", "0.86 0.55 1 1"); var keys = GetOrderedElectricalKeys(); var totalPages = Mathf.Max(1, Mathf.CeilToInt(keys.Count / (float)EverlightItemsPerPage)); var page = Mathf.Clamp(GetElectricalPage(player), 0, totalPages - 1); _electricalPageByPlayer[player.userID] = page; AddText(container, UiBody, $"| Page {page + 1}/{totalPages}", 24, "0.51 0.92", "0.72 0.985", "0.86 0.55 1 1"); int start = page * EverlightItemsPerPage; int end = Mathf.Min(start + EverlightItemsPerPage, keys.Count); float listTop = 0.83f; float listBottomLimit = 0.14f; float rowHeight = 0.075f; float rowGap = 0.012f; for (int i = start; i < end; i++) { var key = keys[i]; var settings = _config.ElectricalList[key]; ElectricalEntry entry; _electricalDefaults.TryGetValue(key, out entry); var kindLabel = entry != null ? ElectricalKindLabel(entry.Kind) : string.Empty; float currentTop = listTop - ((i - start) * (rowHeight + rowGap)); float currentBottom = currentTop - rowHeight; if (currentBottom < listBottomLimit) break; container.Add(new CuiPanel { Image = { Color = ((i - start) % 2 == 0) ? "0.11 0.11 0.11 0.96" : "0.13 0.13 0.13 0.96" }, RectTransform = { AnchorMin = $"0.03 {currentBottom:0.000}", AnchorMax = $"0.97 {currentTop:0.000}" } }, UiBody); AddText(container, UiBody, key, 16, $"0.05 {currentBottom + 0.012f:0.000}", $"0.45 {currentTop - 0.012f:0.000}"); AddText(container, UiBody, kindLabel, 13, $"0.46 {currentBottom + 0.012f:0.000}", $"0.60 {currentTop - 0.012f:0.000}", "0.70 0.70 0.70 1"); AddText(container, UiBody, settings.Enabled ? "ON" : "OFF", 16, $"0.62 {currentBottom + 0.012f:0.000}", $"0.70 {currentTop - 0.012f:0.000}", settings.Enabled ? "0.2 0.95 0.2 1" : "0.95 0.25 0.25 1"); AddButton(container, UiBody, settings.Enabled ? "Disable" : "Enable", settings.Enabled ? "0.78 0.32 0.32 0.95" : "0.22 0.68 0.22 0.95", $"0.78 {currentBottom + 0.014f:0.000}", $"0.95 {currentTop - 0.014f:0.000}", $"pvezonemap.ui.electricaltoggle {i}", 14); } container.Add(new CuiPanel { Image = { Color = "0.10 0.10 0.10 0.98" }, RectTransform = { AnchorMin = "0.03 0.02", AnchorMax = "0.97 0.11" } }, UiBody); if (page > 0) AddButton(container, UiBody, "< Prev", "0.24 0.24 0.24 0.95", "0.04 0.035", "0.16 0.095", $"pvezonemap.ui.electricalpage {page - 1}", 15); if (page < totalPages - 1) AddButton(container, UiBody, "Next >", "0.24 0.24 0.24 0.95", "0.80 0.035", "0.96 0.095", $"pvezonemap.ui.electricalpage {page + 1}", 15); AddText(container, UiBody, $"Refresh every {Mathf.Max(5f, _config.ElectricalRefillInterval):0}s. Use /electricaldiag to verify shortnames.", 14, "0.20 0.04", "0.78 0.10", "0.82 0.82 0.82 1"); } private static string ElectricalKindLabel(ElectricalKind kind) { switch (kind) { case ElectricalKind.FuelGenerator: return "[fuel]"; case ElectricalKind.Battery: return "[battery]"; case ElectricalKind.AlwaysOn: return "[output]"; default: return string.Empty; } } private void BuildGeneralBody(CuiElementContainer container, ZoneEntry zone, string zoneNameDraft, bool previewOn) { AddText(container, UiBody, "General Settings", 20, "0.04 0.86", "0.35 0.96"); AddText(container, UiBody, "Zone Radius:", 18, "0.05 0.70", "0.25 0.80"); AddButton(container, UiBody, "-50", "0.24 0.24 0.24 0.95", "0.30 0.76", "0.36 0.84", "pvezonemap.ui.adjust radius -50"); AddButton(container, UiBody, "-25", "0.24 0.24 0.24 0.95", "0.38 0.76", "0.44 0.84", "pvezonemap.ui.adjust radius -25"); AddButton(container, UiBody, "-10", "0.24 0.24 0.24 0.95", "0.46 0.76", "0.52 0.84", "pvezonemap.ui.adjust radius -10"); AddButton(container, UiBody, "-5", "0.24 0.24 0.24 0.95", "0.54 0.76", "0.60 0.84", "pvezonemap.ui.adjust radius -5"); AddButton(container, UiBody, "-1", "0.24 0.24 0.24 0.95", "0.62 0.76", "0.68 0.84", "pvezonemap.ui.adjust radius -1"); AddButton(container, UiBody, "+1", "0.17 0.62 0.24 0.95", "0.30 0.64", "0.36 0.72", "pvezonemap.ui.adjust radius 1"); AddButton(container, UiBody, "+5", "0.17 0.62 0.24 0.95", "0.38 0.64", "0.44 0.72", "pvezonemap.ui.adjust radius 5"); AddButton(container, UiBody, "+10", "0.17 0.62 0.24 0.95", "0.46 0.64", "0.52 0.72", "pvezonemap.ui.adjust radius 10"); AddButton(container, UiBody, "+25", "0.17 0.62 0.24 0.95", "0.54 0.64", "0.60 0.72", "pvezonemap.ui.adjust radius 25"); AddButton(container, UiBody, "+50", "0.17 0.62 0.24 0.95", "0.62 0.64", "0.68 0.72", "pvezonemap.ui.adjust radius 50"); AddText(container, UiBody, "Quick Actions", 18, "0.05 0.50", "0.25 0.60"); AddButton(container, UiBody, "Move Zone To Me", "0.17 0.62 0.24 0.95", "0.28 0.48", "0.46 0.60", "pvezonemap.ui.movehere"); AddButton(container, UiBody, previewOn ? "Preview Zone: ON" : "Preview Zone: OFF", previewOn ? "0.17 0.62 0.24 0.95" : "0.35 0.22 0.22 0.95", "0.48 0.48", "0.86 0.60", "pvezonemap.ui.preview"); AddText(container, UiBody, "Zone Options", 18, "0.05 0.34", "0.25 0.44"); AddToggleButton(container, UiBody, "No Decay", zone.NoDecay, "pvezonemap.ui.toggle nodecay", "0.28 0.34", "0.47 0.44"); AddToggleButton(container, UiBody, "Allow Teleport", zone.AllowTeleport, "pvezonemap.ui.toggle teleport", "0.49 0.34", "0.70 0.44"); AddText(container, UiBody, "Zone Name", 18, "0.05 0.16", "0.22 0.26"); AddInputField(container, UiBody, zoneNameDraft, "pvezonemap.ui.draft name", "0.28 0.15", "0.70 0.27"); AddButton(container, UiBody, "Save", "0.17 0.62 0.24 0.95", "0.72 0.15", "0.82 0.27", "pvezonemap.ui.save name"); AddButton(container, UiBody, zone.CreateLabelMarker ? "Label: ON" : "Label: OFF", zone.CreateLabelMarker ? "0.90 0.68 0.10 0.95" : "0.35 0.22 0.22 0.95", "0.84 0.15", "0.95 0.27", "pvezonemap.ui.toggle label"); AddText(container, UiBody, "Buildings inside this zone will not decay when No Decay is enabled.", 14, "0.05 0.03", "0.90 0.11", "0.78 0.78 0.78 1"); } private void BuildRulesBody(CuiElementContainer container, ZoneEntry zone) { AddText(container, UiBody, "Combat Rules", 20, "0.04 0.86", "0.30 0.96"); AddToggleButton(container, UiBody, "Block PvP", zone.BlockPlayerVsPlayer, "pvezonemap.ui.toggle pvp", "0.05 0.62", "0.42 0.76"); AddToggleButton(container, UiBody, "Block Structure Damage", zone.BlockPlayerVsStructures, "pvezonemap.ui.toggle structures", "0.50 0.62", "0.87 0.76"); AddToggleButton(container, UiBody, "Block NPC Damage", zone.BlockPlayerVsNPC, "pvezonemap.ui.toggle npc", "0.05 0.40", "0.42 0.54"); AddToggleButton(container, UiBody, "Hostile Traps / Turrets", zone.BlockTrapsAndTurrets, "pvezonemap.ui.toggle traps", "0.50 0.40", "0.87 0.54"); AddText(container, UiBody, "Turn each rule on or off for this selected zone.", 14, "0.05 0.16", "0.70 0.24", "0.78 0.78 0.78 1"); } private void BuildBubbleBody(CuiElementContainer container, ZoneEntry zone) { AddText(container, UiBody, "Map Bubble", 20, "0.04 0.86", "0.25 0.96"); AddText(container, UiBody, "Bubble size follows the zone radius automatically.", 18, "0.05 0.68", "0.72 0.78"); AddText(container, UiBody, $"Alpha: {(int)(_config.MarkerAlpha * 100f)}%", 18, "0.05 0.56", "0.28 0.66"); AddButton(container, UiBody, "-5", "0.24 0.24 0.24 0.95", "0.30 0.54", "0.42 0.66", "pvezonemap.ui.adjust alpha -5"); AddButton(container, UiBody, "+5", "0.17 0.62 0.24 0.95", "0.44 0.54", "0.56 0.66", "pvezonemap.ui.adjust alpha 5"); AddText(container, UiBody, "Map Visibility", 16, "0.05 0.40", "0.25 0.50"); AddToggleButton(container, UiBody, "Show Map Bubble", zone.ShowOnMap, "pvezonemap.ui.toggle showonmap", "0.30 0.38", "0.62 0.50"); AddText(container, UiBody, "Bubble Colour", 16, "0.05 0.22", "0.25 0.32"); AddColorButton(container, UiBody, "#27AE60", "0.28 0.20", "0.37 0.32", "pvezonemap.ui.color zone #27AE60"); AddColorButton(container, UiBody, "#F1C40F", "0.39 0.20", "0.48 0.32", "pvezonemap.ui.color zone #F1C40F"); AddColorButton(container, UiBody, "#3498DB", "0.50 0.20", "0.59 0.32", "pvezonemap.ui.color zone #3498DB"); AddColorButton(container, UiBody, "#E74C3C", "0.61 0.20", "0.70 0.32", "pvezonemap.ui.color zone #E74C3C"); AddText(container, UiBody, "Outline", 16, "0.05 0.04", "0.18 0.14"); AddColorButton(container, UiBody, "#0B0B0B", "0.28 0.02", "0.37 0.14", "pvezonemap.ui.color outline #0B0B0B"); AddColorButton(container, UiBody, "#1F2937", "0.39 0.02", "0.48 0.14", "pvezonemap.ui.color outline #1F2937"); AddColorButton(container, UiBody, "#FFFFFF", "0.50 0.02", "0.59 0.14", "pvezonemap.ui.color outline #FFFFFF"); } private void BuildStatusBody(CuiElementContainer container, ZoneEntry zone, string statusTextDraft) { AddText(container, UiBody, "Advanced Status", 20, "0.04 0.86", "0.35 0.96"); AddText(container, UiBody, "Status Text", 16, "0.05 0.68", "0.22 0.78"); AddInputField(container, UiBody, statusTextDraft, "pvezonemap.ui.draft status", "0.28 0.66", "0.72 0.78"); AddButton(container, UiBody, "Save", "0.17 0.62 0.24 0.95", "0.74 0.66", "0.84 0.78", "pvezonemap.ui.save status"); AddText(container, UiBody, "Bar Colour", 16, "0.05 0.44", "0.22 0.54"); AddColorButton(container, UiBody, "#27AE60", "0.24 0.36", "0.33 0.48", "pvezonemap.ui.color statusbg #27AE60"); AddColorButton(container, UiBody, "#F1C40F", "0.35 0.36", "0.44 0.48", "pvezonemap.ui.color statusbg #F1C40F"); AddColorButton(container, UiBody, "#E67E22", "0.46 0.36", "0.55 0.48", "pvezonemap.ui.color statusbg #E67E22"); AddColorButton(container, UiBody, "#E74C3C", "0.57 0.36", "0.66 0.48", "pvezonemap.ui.color statusbg #E74C3C"); AddText(container, UiBody, "Text Colour", 16, "0.05 0.18", "0.22 0.28"); AddColorButton(container, UiBody, "#FFFFFF", "0.24 0.16", "0.33 0.28", "pvezonemap.ui.color statustext #FFFFFF"); AddColorButton(container, UiBody, "#0B0B0B", "0.35 0.16", "0.44 0.28", "pvezonemap.ui.color statustext #0B0B0B"); AddText(container, UiBody, "Edit the text in the box and press Save.", 14, "0.05 0.06", "0.78 0.14", "0.78 0.78 0.78 1"); } // ===== Partial redraw helpers ===== // These swap a single named subtree without touching the rest of the UI, // eliminating the full-rebuild flicker every click previously caused. private void RedrawHeader(BasePlayer player) { if (player == null || !player.IsConnected) return; CuiHelper.DestroyUi(player, UiHeader); var c = new CuiElementContainer(); BuildHeader(c, player); if (c.Count > 0) CuiHelper.AddUi(player, c); } private void RedrawSidebar(BasePlayer player) { if (player == null || !player.IsConnected) return; CuiHelper.DestroyUi(player, UiSidebar); var c = new CuiElementContainer(); BuildSidebar(c, player); if (c.Count > 0) CuiHelper.AddUi(player, c); } private void RedrawTabBar(BasePlayer player) { if (player == null || !player.IsConnected) return; CuiHelper.DestroyUi(player, UiTabBar); var c = new CuiElementContainer(); BuildTabBar(c, player); if (c.Count > 0) CuiHelper.AddUi(player, c); } private void RedrawBody(BasePlayer player) { if (player == null || !player.IsConnected) return; CuiHelper.DestroyUi(player, UiBody); var c = new CuiElementContainer(); BuildBody(c, player); if (c.Count > 0) CuiHelper.AddUi(player, c); } private void RedrawSidebarAndBody(BasePlayer player) { if (player == null || !player.IsConnected) return; CuiHelper.DestroyUi(player, UiSidebar); CuiHelper.DestroyUi(player, UiBody); var c = new CuiElementContainer(); BuildSidebar(c, player); BuildBody(c, player); if (c.Count > 0) CuiHelper.AddUi(player, c); } private void RedrawTabBarAndBody(BasePlayer player) { if (player == null || !player.IsConnected) return; CuiHelper.DestroyUi(player, UiTabBar); CuiHelper.DestroyUi(player, UiBody); var c = new CuiElementContainer(); BuildTabBar(c, player); BuildBody(c, player); if (c.Count > 0) CuiHelper.AddUi(player, c); } private void BuildEverlightUi(CuiElementContainer container, BasePlayer player) { AddText(container, UiMain + ".BodyCard", "Light Controller", 24, "0.22 0.92", "0.45 0.985", "1 0.82 0.15 1"); var keys = GetOrderedEverlightKeys(); var totalPages = Mathf.Max(1, Mathf.CeilToInt(keys.Count / (float)EverlightItemsPerPage)); var page = Mathf.Clamp(GetEverlightPage(player), 0, totalPages - 1); _everlightPageByPlayer[player.userID] = page; AddText(container, UiMain + ".BodyCard", $"| Page {page + 1}/{totalPages}", 24, "0.46 0.92", "0.68 0.985", "1 0.82 0.15 1"); int start = page * EverlightItemsPerPage; int end = Mathf.Min(start + EverlightItemsPerPage, keys.Count); float listTop = 0.84f; float listBottomLimit = 0.14f; float rowHeight = 0.075f; float rowGap = 0.012f; for (int i = start; i < end; i++) { var key = keys[i]; var settings = _config.EverlightList[key]; float currentTop = listTop - ((i - start) * (rowHeight + rowGap)); float currentBottom = currentTop - rowHeight; if (currentBottom < listBottomLimit) break; container.Add(new CuiPanel { Image = { Color = ((i - start) % 2 == 0) ? "0.11 0.11 0.11 0.96" : "0.13 0.13 0.13 0.96" }, RectTransform = { AnchorMin = $"0.03 {currentBottom:0.000}", AnchorMax = $"0.97 {currentTop:0.000}" } }, UiMain + ".BodyCard"); AddText(container, UiMain + ".BodyCard", key, 16, $"0.05 {currentBottom + 0.012f:0.000}", $"0.54 {currentTop - 0.012f:0.000}"); AddText(container, UiMain + ".BodyCard", settings.Enabled ? "ON" : "OFF", 16, $"0.61 {currentBottom + 0.012f:0.000}", $"0.70 {currentTop - 0.012f:0.000}", settings.Enabled ? "0.2 0.95 0.2 1" : "0.95 0.25 0.25 1"); AddButton(container, UiMain + ".BodyCard", settings.Enabled ? "Disable" : "Enable", settings.Enabled ? "0.78 0.32 0.32 0.95" : "0.22 0.68 0.22 0.95", $"0.78 {currentBottom + 0.014f:0.000}", $"0.95 {currentTop - 0.014f:0.000}", $"pvezonemap.ui.everlighttoggle {i}", 14); } container.Add(new CuiPanel { Image = { Color = "0.10 0.10 0.10 0.98" }, RectTransform = { AnchorMin = "0.03 0.02", AnchorMax = "0.97 0.11" } }, UiMain + ".BodyCard"); if (page > 0) AddButton(container, UiMain + ".BodyCard", "< Prev", "0.24 0.24 0.24 0.95", "0.04 0.035", "0.16 0.095", $"pvezonemap.ui.everlightpage {page - 1}", 15); if (page < totalPages - 1) AddButton(container, UiMain + ".BodyCard", "Next >", "0.24 0.24 0.24 0.95", "0.80 0.035", "0.96 0.095", $"pvezonemap.ui.everlightpage {page + 1}", 15); AddText(container, UiMain + ".BodyCard", "Torch toggles are pinned to the top. Changes save straight into the plugin config.", 14, "0.20 0.04", "0.78 0.10", "0.82 0.82 0.82 1"); } private void OpenPlayerRulesUi(BasePlayer player, ZoneEntry zone) { if (player == null || !player.IsConnected || zone == null) return; DestroyPlayerUi(player); var container = new CuiElementContainer(); // Outer modal panel — slightly larger than before to host the tab bar. container.Add(new CuiPanel { Image = { Color = "0.04 0.06 0.08 0.96" }, RectTransform = { AnchorMin = "0.27 0.17", AnchorMax = "0.73 0.83" }, CursorEnabled = true }, "Hud", UiPlayer); // Header strip. container.Add(new CuiPanel { Image = { Color = "0.10 0.15 0.20 0.98" }, RectTransform = { AnchorMin = "0 0.92", AnchorMax = "1 1" } }, UiPlayer, UiPlayer + ".Header"); AddText(container, UiPlayer + ".Header", $"Zone Settings — {zone.Name}", 18, "0.04 0.15", "0.88 0.85"); AddButton(container, UiPlayer + ".Header", "X", "0.75 0.20 0.20 0.95", "0.94 0.20", "0.985 0.80", "pvezonemap.player.close", 16); // Tab bar. container.Add(new CuiPanel { Image = { Color = "0.07 0.08 0.10 0.95" }, RectTransform = { AnchorMin = "0 0.83", AnchorMax = "1 0.915" } }, UiPlayer, UiPlayer + ".TabBar"); var tab = GetSelectedPlayerTab(player); AddTabButton(container, UiPlayer + ".TabBar", "General", tab == "general", "pvezonemap.player.tab general", "0.03 0.12", "0.245 0.88"); AddTabButton(container, UiPlayer + ".TabBar", "Rules", tab == "rules", "pvezonemap.player.tab rules", "0.26 0.12", "0.475 0.88"); AddTabButton(container, UiPlayer + ".TabBar", "Bubble", tab == "bubble", "pvezonemap.player.tab bubble", "0.49 0.12", "0.705 0.88"); AddTabButton(container, UiPlayer + ".TabBar", "Status", tab == "status", "pvezonemap.player.tab status", "0.72 0.12", "0.97 0.88"); // Body card. container.Add(new CuiPanel { Image = { Color = "0.11 0.11 0.11 0.95" }, RectTransform = { AnchorMin = "0.02 0.03", AnchorMax = "0.98 0.82" } }, UiPlayer, UiPlayer + ".Body"); switch (tab) { case "rules": BuildPlayerRulesBody(container, zone); break; case "bubble": BuildPlayerBubbleBody(container, zone); break; case "status": BuildPlayerStatusBody(container, player, zone); break; default: BuildPlayerGeneralBody(container, player, zone); break; } CuiHelper.AddUi(player, container); } private string GetSelectedPlayerTab(BasePlayer player) { string t; return _selectedPlayerTabByPlayer.TryGetValue(player.userID, out t) && !string.IsNullOrEmpty(t) ? t : "general"; } private void BuildPlayerGeneralBody(CuiElementContainer container, BasePlayer player, ZoneEntry zone) { var draft = _zoneNameDraftByPlayer.ContainsKey(player.userID) ? _zoneNameDraftByPlayer[player.userID] : zone.Name; AddText(container, UiPlayer + ".Body", "General", 18, "0.04 0.86", "0.40 0.96"); AddText(container, UiPlayer + ".Body", "Zone Name", 14, "0.04 0.70", "0.24 0.80"); AddInputField(container, UiPlayer + ".Body", draft, "pvezonemap.player.draft name", "0.26 0.70", "0.74 0.82"); AddButton(container, UiPlayer + ".Body", "Save", "0.17 0.62 0.24 0.95", "0.76 0.70", "0.95 0.82", "pvezonemap.player.save name", 14); AddText(container, UiPlayer + ".Body", "You can use this panel while standing inside the zone and authenticated on a Tool Cupboard in this bubble.", 13, "0.04 0.04", "0.95 0.16", "0.80 0.80 0.80 1"); } private void BuildPlayerRulesBody(CuiElementContainer container, ZoneEntry zone) { AddText(container, UiPlayer + ".Body", "Zone Rules", 18, "0.04 0.86", "0.40 0.96"); AddToggleButton(container, UiPlayer + ".Body", "Block PvP", zone.BlockPlayerVsPlayer, "pvezonemap.player.toggle pvp", "0.04 0.66", "0.48 0.78"); AddToggleButton(container, UiPlayer + ".Body", "Block Structure Damage", zone.BlockPlayerVsStructures, "pvezonemap.player.toggle structures", "0.52 0.66", "0.96 0.78"); AddToggleButton(container, UiPlayer + ".Body", "Block NPC Damage", zone.BlockPlayerVsNPC, "pvezonemap.player.toggle npc", "0.04 0.48", "0.48 0.60"); AddToggleButton(container, UiPlayer + ".Body", "Hostile Traps / Turrets",zone.BlockTrapsAndTurrets, "pvezonemap.player.toggle traps", "0.52 0.48", "0.96 0.60"); TimeSpan remaining; if (IsZoneRuleChangeLocked(zone, out remaining)) { AddText(container, UiPlayer + ".Body", $"Rules locked — {FormatRemaining(remaining)} remaining.", 14, "0.04 0.32", "0.95 0.42", "0.95 0.60 0.20 1"); AddText(container, UiPlayer + ".Body", $"Each rule change locks the zone for {PlayerRuleCooldownHours} hours. Plan your switch.", 13, "0.04 0.18", "0.95 0.28", "0.80 0.80 0.80 1"); } else { AddText(container, UiPlayer + ".Body", $"Turn each rule on or off for this zone. Each change locks rules for {PlayerRuleCooldownHours}h.", 13, "0.04 0.30", "0.95 0.40", "0.80 0.80 0.80 1"); } } private void BuildPlayerBubbleBody(CuiElementContainer container, ZoneEntry zone) { AddText(container, UiPlayer + ".Body", "Map Bubble", 18, "0.04 0.86", "0.40 0.96"); AddText(container, UiPlayer + ".Body", "Bubble size follows the zone radius automatically.", 13, "0.04 0.76", "0.96 0.84", "0.80 0.80 0.80 1"); AddText(container, UiPlayer + ".Body", "Map Visibility", 14, "0.04 0.60", "0.26 0.70"); AddToggleButton(container, UiPlayer + ".Body", "Show Map Bubble", zone.ShowOnMap, "pvezonemap.player.toggle showonmap", "0.28 0.58", "0.68 0.70"); AddText(container, UiPlayer + ".Body", "Bubble Colour", 14, "0.04 0.42", "0.26 0.52"); AddColorButton(container, UiPlayer + ".Body", "#27AE60", "0.28 0.40", "0.39 0.52", "pvezonemap.player.color zone #27AE60"); AddColorButton(container, UiPlayer + ".Body", "#F1C40F", "0.41 0.40", "0.52 0.52", "pvezonemap.player.color zone #F1C40F"); AddColorButton(container, UiPlayer + ".Body", "#3498DB", "0.54 0.40", "0.65 0.52", "pvezonemap.player.color zone #3498DB"); AddColorButton(container, UiPlayer + ".Body", "#E74C3C", "0.67 0.40", "0.78 0.52", "pvezonemap.player.color zone #E74C3C"); AddText(container, UiPlayer + ".Body", "Outline", 14, "0.04 0.24", "0.26 0.34"); AddColorButton(container, UiPlayer + ".Body", "#0B0B0B", "0.28 0.22", "0.39 0.34", "pvezonemap.player.color outline #0B0B0B"); AddColorButton(container, UiPlayer + ".Body", "#1F2937", "0.41 0.22", "0.52 0.34", "pvezonemap.player.color outline #1F2937"); AddColorButton(container, UiPlayer + ".Body", "#FFFFFF", "0.54 0.22", "0.65 0.34", "pvezonemap.player.color outline #FFFFFF"); } private void BuildPlayerStatusBody(CuiElementContainer container, BasePlayer player, ZoneEntry zone) { var draft = _statusTextDraftByPlayer.ContainsKey(player.userID) ? _statusTextDraftByPlayer[player.userID] : (zone.StatusText ?? string.Empty); AddText(container, UiPlayer + ".Body", "Advanced Status", 18, "0.04 0.86", "0.45 0.96"); AddText(container, UiPlayer + ".Body", "Status Text", 14, "0.04 0.70", "0.22 0.80"); AddInputField(container, UiPlayer + ".Body", draft, "pvezonemap.player.draft status", "0.24 0.70", "0.72 0.82"); AddButton(container, UiPlayer + ".Body", "Save", "0.17 0.62 0.24 0.95", "0.74 0.70", "0.92 0.82", "pvezonemap.player.save status", 14); AddText(container, UiPlayer + ".Body", "Bar Colour", 14, "0.04 0.52", "0.22 0.62"); AddColorButton(container, UiPlayer + ".Body", "#27AE60", "0.24 0.50", "0.35 0.62", "pvezonemap.player.color statusbg #27AE60"); AddColorButton(container, UiPlayer + ".Body", "#F1C40F", "0.37 0.50", "0.48 0.62", "pvezonemap.player.color statusbg #F1C40F"); AddColorButton(container, UiPlayer + ".Body", "#E67E22", "0.50 0.50", "0.61 0.62", "pvezonemap.player.color statusbg #E67E22"); AddColorButton(container, UiPlayer + ".Body", "#E74C3C", "0.63 0.50", "0.74 0.62", "pvezonemap.player.color statusbg #E74C3C"); AddText(container, UiPlayer + ".Body", "Text Colour", 14, "0.04 0.34", "0.22 0.44"); AddColorButton(container, UiPlayer + ".Body", "#FFFFFF", "0.24 0.32", "0.35 0.44", "pvezonemap.player.color statustext #FFFFFF"); AddColorButton(container, UiPlayer + ".Body", "#0B0B0B", "0.37 0.32", "0.48 0.44", "pvezonemap.player.color statustext #0B0B0B"); AddText(container, UiPlayer + ".Body", "Edit the text in the box and press Save.", 13, "0.04 0.16", "0.95 0.24", "0.80 0.80 0.80 1"); } private void DestroyPlayerUi(BasePlayer player) { if (player == null) return; CuiHelper.DestroyUi(player, UiPlayer); } private void DestroyUi(BasePlayer player) { if (player == null) return; CuiHelper.DestroyUi(player, UiMain); } private void AddButton(CuiElementContainer container, string parent, string text, string color, string anchorMin, string anchorMax, string command, int fontSize = 14) { container.Add(new CuiButton { Button = { Color = color, Command = command }, RectTransform = { AnchorMin = anchorMin, AnchorMax = anchorMax }, Text = { Text = text, FontSize = fontSize, Align = TextAnchor.MiddleCenter, Color = "1 1 1 1" } }, parent); } private void AddToggleButton(CuiElementContainer container, string parent, string label, bool state, string command, string anchorMin, string anchorMax) { AddButton(container, parent, state ? $"{label}: ON" : $"{label}: OFF", state ? _config.UiAccentColor : "0.30 0.20 0.20 0.95", anchorMin, anchorMax, command); } private void AddTabButton(CuiElementContainer container, string parent, string label, bool selected, string command, string anchorMin, string anchorMax) { AddButton(container, parent, label, selected ? "0.92 0.76 0.06 0.98" : "0.20 0.20 0.20 0.98", anchorMin, anchorMax, command, 14); } private void AddColorButton(CuiElementContainer container, string parent, string hex, string anchorMin, string anchorMax, string command) { AddButton(container, parent, string.Empty, HexToUi(hex, "0.2 0.2 0.2 1"), anchorMin, anchorMax, command); } private void AddInputField(CuiElementContainer container, string parent, string text, string command, string anchorMin, string anchorMax) { var panelName = container.Add(new CuiPanel { Image = { Color = "0.11 0.11 0.11 0.98" }, RectTransform = { AnchorMin = anchorMin, AnchorMax = anchorMax } }, parent); container.Add(new CuiElement { Parent = panelName, Components = { new CuiInputFieldComponent { Text = text ?? string.Empty, FontSize = 14, Align = TextAnchor.MiddleLeft, CharsLimit = 64, Color = "1 1 1 1", Command = command, HudMenuInput = true, NeedsKeyboard = true }, new CuiRectTransformComponent { AnchorMin = "0.03 0.08", AnchorMax = "0.97 0.92" } } }); } private void AddText(CuiElementContainer container, string parent, string text, int fontSize, string anchorMin, string anchorMax, string color = null) { container.Add(new CuiLabel { Text = { Text = text, FontSize = fontSize, Align = TextAnchor.MiddleLeft, Color = color ?? _config.UiTextColor }, RectTransform = { AnchorMin = anchorMin, AnchorMax = anchorMax } }, parent); } #endregion #region Helpers private bool HasAdminPermission(BasePlayer player) { if (player == null) return false; if (player.IsAdmin || permission.UserHasPermission(player.UserIDString, PermAdmin)) return true; SendReply(player, "You do not have permission to use this command."); return false; } private bool TryGetPlayerControlledZone(BasePlayer player, out ZoneEntry zone, out string reason) { zone = null; reason = "You cannot manage zone rules right now."; if (player == null || !player.IsConnected) return false; zone = GetZoneAtPosition(player.transform.position); if (zone == null) { reason = "You must be standing inside a bubble to use /zonerules."; return false; } if (!IsPlayerAuthorizedInZone(player, zone)) { reason = $"{FormatZonePrefix(zone)} You need Tool Cupboard auth inside this zone to use /zonerules."; zone = null; return false; } return true; } private bool IsPlayerAuthorizedInZone(BasePlayer player, ZoneEntry zone) { if (player == null || zone == null) return false; foreach (var privilege in UnityEngine.Object.FindObjectsOfType()) { if (privilege == null || privilege.IsDestroyed) continue; if (Vector3.Distance(privilege.transform.position, zone.Position.ToVector3()) > zone.Radius) continue; var authorized = privilege.authorizedPlayers; if (authorized == null) continue; if (authorized.Contains(player.userID)) return true; } return false; } private string FormatZonePrefix(ZoneEntry zone) { if (zone == null || string.IsNullOrEmpty(zone.Name)) return _config.ChatPrefix; var color = string.IsNullOrEmpty(zone.ZoneColorHex) ? "#27AE60" : zone.ZoneColorHex; return $"[{zone.Name}]:"; } private void UpdateSelectedZoneReferences(string oldName, string newName) { if (string.IsNullOrEmpty(oldName) || string.IsNullOrEmpty(newName)) return; foreach (var key in new List(_selectedZoneByPlayer.Keys)) { if (string.Equals(_selectedZoneByPlayer[key], oldName, StringComparison.OrdinalIgnoreCase)) _selectedZoneByPlayer[key] = newName; } } private void UpdateLastZoneReferences(string oldName, string newName) { if (string.IsNullOrEmpty(oldName) || string.IsNullOrEmpty(newName)) return; foreach (var key in new List(_lastZoneByPlayer.Keys)) { if (string.Equals(_lastZoneByPlayer[key], oldName, StringComparison.OrdinalIgnoreCase)) _lastZoneByPlayer[key] = newName; } } private void RemoveZoneTracking(string zoneName) { if (string.IsNullOrEmpty(zoneName)) return; foreach (var key in new List(_lastZoneByPlayer.Keys)) { if (!string.Equals(_lastZoneByPlayer[key], zoneName, StringComparison.OrdinalIgnoreCase)) continue; _lastZoneByPlayer.Remove(key); _playersInsideZone.Remove(key); _lastStatusSignatureByPlayer.Remove(key); var player = BasePlayer.FindByID(key); if (player != null && player.IsConnected) DeleteStatus(player); } } private void RefreshStatusesForZone(ZoneEntry zone) { if (zone == null) return; foreach (var player in BasePlayer.activePlayerList) { if (player == null || !player.IsConnected) continue; var playerZone = GetZoneAtPosition(player.transform.position); if (playerZone == null || !string.Equals(playerZone.Name, zone.Name, StringComparison.OrdinalIgnoreCase)) continue; CreateOrUpdateStatus(player); } } private string GetStatusSignature(ZoneEntry zone) { if (zone == null) return string.Empty; return string.Join("|", zone.Name ?? string.Empty, zone.UseAdvancedStatus ? "1" : "0", zone.StatusText ?? string.Empty, zone.StatusBackgroundColor ?? string.Empty, zone.StatusTextColor ?? string.Empty, zone.StatusIcon ?? string.Empty, zone.NoDecay ? "1" : "0", _config.NoDecayStatusSuffix ?? string.Empty, _config.StatusOrder.ToString()); } private Color ParseColor(string hex, Color fallback) { if (string.IsNullOrEmpty(hex)) return fallback; Color color; return ColorUtility.TryParseHtmlString(hex, out color) ? color : fallback; } private string HexToUi(string hex, string fallback) { Color color; if (!ColorUtility.TryParseHtmlString(hex, out color)) return fallback; return $"{color.r:0.###} {color.g:0.###} {color.b:0.###} 1"; } #endregion #region Config / Data protected override void LoadDefaultConfig() { _config = new Configuration(); SaveConfig(); } private void LoadConfigValues() { try { _config = Config.ReadObject(); if (_config == null) throw new Exception(); } catch { PrintWarning("Config was invalid, generating a new one."); LoadDefaultConfig(); } if (_config.EverlightList == null) _config.EverlightList = new Dictionary(StringComparer.OrdinalIgnoreCase); if (_config.WaterList == null) _config.WaterList = new Dictionary(StringComparer.OrdinalIgnoreCase); if (_config.ElectricalList == null) _config.ElectricalList = new Dictionary(StringComparer.OrdinalIgnoreCase); } protected override void SaveConfig() { Config.WriteObject(_config, true); } private void LoadData() { try { _data = Interface.Oxide.DataFileSystem.ReadObject(Name); } catch { _data = new StoredData(); } } private void SaveData() { Interface.Oxide.DataFileSystem.WriteObject(Name, _data); } #endregion } }