C#:
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<string, MarkerPair> _liveMarkers = new Dictionary<string, MarkerPair>(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<ulong> _playersInsideZone = new HashSet<ulong>();
private readonly Dictionary<ulong, string> _lastZoneByPlayer = new Dictionary<ulong, string>();
private readonly Dictionary<ulong, string> _selectedZoneByPlayer = new Dictionary<ulong, string>();
private readonly Dictionary<ulong, string> _selectedTabByPlayer = new Dictionary<ulong, string>();
private readonly Dictionary<ulong, string> _selectedPlayerTabByPlayer = new Dictionary<ulong, string>();
private readonly HashSet<ulong> _previewEnabledPlayers = new HashSet<ulong>();
private readonly Dictionary<ulong, PendingInputType> _pendingInputByPlayer = new Dictionary<ulong, PendingInputType>();
private readonly Dictionary<ulong, string> _zoneNameDraftByPlayer = new Dictionary<ulong, string>();
private readonly Dictionary<ulong, string> _statusTextDraftByPlayer = new Dictionary<ulong, string>();
private readonly Dictionary<ulong, int> _everlightPageByPlayer = new Dictionary<ulong, int>();
private readonly Dictionary<ulong, int> _waterPageByPlayer = new Dictionary<ulong, int>();
private readonly Dictionary<ulong, int> _electricalPageByPlayer = new Dictionary<ulong, int>();
private readonly Dictionary<ulong, string> _zoneSearchByPlayer = new Dictionary<ulong, string>();
private readonly Dictionary<ulong, string> _lastStatusSignatureByPlayer = new Dictionary<ulong, string>();
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<string, EverlightEntry> _everlightByShortname = new Dictionary<string, EverlightEntry>(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<ItemModBurnable, ItemDefinition> _burnableDefinitions = new Dictionary<ItemModBurnable, ItemDefinition>();
private readonly Dictionary<string, EverlightEntry> _everlightDefaults = new Dictionary<string, EverlightEntry>(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<string, WaterEntry> _waterByShortname = new Dictionary<string, WaterEntry>(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, WaterEntry> _waterDefaults = new Dictionary<string, WaterEntry>(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<string, ElectricalEntry> _electricalByShortname = new Dictionary<string, ElectricalEntry>(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, ElectricalEntry> _electricalDefaults = new Dictionary<string, ElectricalEntry>(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<ZoneEntry> Zones = new List<ZoneEntry>();
}
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 = "<color=#27AE60>[PVE ZONE]</color>: ";
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<string, EverlightSettings> EverlightList = new Dictionary<string, EverlightSettings>(StringComparer.OrdinalIgnoreCase);
[JsonProperty(PropertyName = "Water entity list")]
public Dictionary<string, WaterSettings> WaterList = new Dictionary<string, WaterSettings>(StringComparer.OrdinalIgnoreCase);
[JsonProperty(PropertyName = "Water refill interval (seconds)")]
public float WaterRefillInterval = 30f;
[JsonProperty(PropertyName = "Electrical entity list")]
public Dictionary<string, ElectricalSettings> ElectricalList = new Dictionary<string, ElectricalSettings>(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<ItemModBurnable>() != 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 <name> <radius>");
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 <name>");
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<string>(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<string>();
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<string>(StringComparer.OrdinalIgnoreCase);
foreach (var pair in _electricalByShortname) {
if (pair.Value?.Settings != null && pair.Value.Settings.Enabled) enabledShortnames.Add(pair.Key);
}
var found = new List<string>();
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<string>(_electricalByShortname.Keys, StringComparer.OrdinalIgnoreCase);
var samples = new Dictionary<string, BaseEntity>(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<string>();
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 = "<read-error>";
}
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<string>();
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 = "<read-error>";
}
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<string>();
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;
// ... [Code preview truncated due to length. Full source in attached file] ...