From 746523387e18d1348501040de59f959733a24a4c Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 29 Sep 2026 05:25:43 -0500 Subject: [PATCH 1/2] =?UTF-8?q?feat(bridge):=20a=20zone's=20ZoneManager=20?= =?UTF-8?q?options,=20its=20messages,=20and=20a=20ZoneDomes=20dome=20(PLAN?= =?UTF-8?q?=5FREDESIGNS=20=C2=A73)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit world.zone gains flags, settings, enterMessage, leaveMessage, delivery, format and dome (PROTOCOL §19.11). Flags are checked against the live ZoneManager's own list, read through its API call ZoneFieldListRaw() (D211), and refused bad-option rather than dropped; settings are an allowlist of five with bounds. Both are kept on the registry entry and re-applied when a zone is re-created. The bridge says a zone's enter and leave messages itself (D193), as chat or a popup (chat when PopupNotifications is missing), and stays quiet for 5 s after re-creating a zone. RunicGatewayDomes.cs, a new helper, patches ZoneDomes so its API can be called without a player, and raises OnRgDomesReady after ZoneDomes' own start; the bridge puts its domes back only after it, so none is drawn twice. Domes are removed before their zone at expiry and revert. hello carries zoneManager { version, flags }, integrations.zoneDomes the ZoneDomes and helper state, and hello is sent again when ZoneManager, ZoneDomes or either helper loads or unloads. The release ships the new helper beside the zone helper. Not compiled or walked on a rig: the panel's file API was down on both. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- .gitea/workflows/release.yml | 9 +- README.md | 8 + overlay/oxide/plugins/RunicGateway.cs | 619 ++++++++++++++++++++- overlay/oxide/plugins/RunicGatewayDomes.cs | 261 +++++++++ 4 files changed, 888 insertions(+), 9 deletions(-) create mode 100644 overlay/oxide/plugins/RunicGatewayDomes.cs diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 5f66dba..4b1b5ab 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -29,8 +29,8 @@ # exact commit being released. # # 2. A FRAMEWORK-NEUTRAL LAYOUT. The tarball holds one directory, -# `runicgateway-rust-plugin/`, with `RunicGateway.cs`, `RunicGatewayZones.cs` -# and `manifest.json` at its top. Every `.cs` in it is listed in the +# `runicgateway-rust-plugin/`, with `RunicGateway.cs`, its helpers +# (`RunicGatewayZones.cs`, `RunicGatewayDomes.cs`) and `manifest.json` at its top. Every `.cs` in it is listed in the # manifest's `files`, and the installer and the egg place every one of them. It does NOT mirror `oxide/plugins/`: the same file goes to # `oxide/plugins/` or `carbon/plugins/`, and the installer and the egg each # decide which (§34.2.1). The fixed, unversioned prefix is so a reader never @@ -40,7 +40,8 @@ # `oxide.plugins` / `c.plugins` on a server names the release it runs. The # committed file keeps its placeholder; the manifest records the commit. # Only the bridge is stamped. A helper (docs/modules/rust/PLAN_FIXES.md D168, -# D182 — today `RunicGatewayZones.cs`, the ZoneManager helper) keeps its own +# D182 — today `RunicGatewayZones.cs` for ZoneManager and `RunicGatewayDomes.cs` +# for ZoneDomes, PLAN_REDESIGNS D194) keeps its own # version: it does one job, changes rarely, and the bridge reads its version at # hello to know which job it does. # @@ -80,7 +81,7 @@ env: PLUGIN: overlay/oxide/plugins/RunicGateway.cs # The helpers shipped beside the bridge (D168, D182). Optional at runtime — # the bridge loads without them — and installed by default. - HELPERS: overlay/oxide/plugins/RunicGatewayZones.cs + HELPERS: overlay/oxide/plugins/RunicGatewayZones.cs overlay/oxide/plugins/RunicGatewayDomes.cs SEED_VERSION: "0.1.0" INSTALLER_REPO: RunicGateway/installer diff --git a/README.md b/README.md index 71e82a6..5ba7364 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ unauthenticated command channel on the network. ``` overlay/oxide/plugins/RunicGateway.cs → /oxide/plugins/RunicGateway.cs overlay/oxide/plugins/RunicGatewayZones.cs → /oxide/plugins/RunicGatewayZones.cs +overlay/oxide/plugins/RunicGatewayDomes.cs → /oxide/plugins/RunicGatewayDomes.cs ``` `RunicGatewayZones.cs` is the **ZoneManager helper** (docs/modules/rust/PLAN_FIXES.md D181, D182). ZoneManager @@ -44,6 +45,13 @@ somebody already standing there holds nobody. The helper patches that with Harmo bridge still loads, scores its own zones by position, and reports the helper missing at hello. `rgz.status` in the server console says whether it patched. +`RunicGatewayDomes.cs` is the **ZoneDomes helper** (docs/modules/rust/PLAN_REDESIGNS.md §3.2, D194). ZoneDomes' +API ends by telling the player who asked how it went, and throws when a plugin calls it with no player; and at its +own start it drops the dome of every zone that does not exist yet, which is every zone the bridge re-creates. The +helper patches the first with Harmony and tells the bridge when ZoneDomes has started, so the bridge can put its +domes back without drawing any twice. It is optional: without it the site offers no dome, and says which piece is +missing. `rgd.status` in the server console says whether it patched. + Oxide compiles and loads it on the write, and writes `oxide/config/RunicGateway.json` on first load: ```json diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index 23202db..19a5634 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -244,7 +244,8 @@ namespace Oxide.Plugins "OnClanCreated", "OnClanDisbanded", "OnClanMemberAdded", "OnClanMemberLeft", "OnClanMemberKicked", "OnClanColorChanged", "OnKitRedeemed", - "OnEntitySpawned", "OnEntityKill" + "OnEntitySpawned", "OnEntityKill", + "OnEnterZone", "OnExitZone", "OnRgDomesReady" }; private void Init() @@ -1173,6 +1174,17 @@ namespace Oxide.Plugins } hello["zoneHelper"] = helper; + + // D211: ZoneManager's own flag list, for the site's zone presets (§3.1). In hello + // rather than every status frame, because it changes only when ZoneManager does, + // and a ZoneManager load sends hello again. + var zoneManager = new Dictionary { { "version", ZoneManagerPlugin().Version.ToString() } }; + List flags = ZoneFlagNames(ZoneManagerPlugin()); + + if (flags != null) + zoneManager["flags"] = flags; + + hello["zoneManager"] = zoneManager; } EmitFrame(hello); @@ -4527,6 +4539,22 @@ namespace Oxide.Plugins EmitFrame(frame); } + private bool IsZonePlugin(Plugin plugin) + { + if (plugin == null || plugin == this) + return false; + + string name = plugin.Name; + return name == "ZoneManager" || name == "ZoneDomes" || name == "RunicGatewayZones" || name == "RunicGatewayDomes"; + } + + /// Main thread. Hello again, once the world is up and the link is. + private void ResendHello() + { + if (_worldReady && _connected) + SendHello(); + } + /// Main thread. Queues for somebody else's plugin, once the world is up. private void NotePluginChange(Plugin plugin, bool loaded) { @@ -4548,6 +4576,11 @@ namespace Oxide.Plugins if (plugin != null && plugin != this && plugin.Name == "ZoneManager") NextTick(() => RecreateZones("ZoneManager reloaded")); + // Hello carries ZoneManager's flag list and both helpers' states, so a change to any + // of the four is said again rather than waiting for the next connect (§3.1, §3.2). + if (IsZonePlugin(plugin)) + NextTick(ResendHello); + // BetterChat keeps third-party titles on its INSTANCE, so a reloaded BetterChat has // forgotten our getter. Registering again is the only way back (§33.1). if (plugin != null && plugin != this && plugin.Name == "BetterChat") @@ -4570,6 +4603,9 @@ namespace Oxide.Plugins if (plugin != null && plugin.Name == "BetterChat") _titlesRegistered = false; + if (IsZonePlugin(plugin)) + NextTick(ResendHello); + ConfigWriteJob job = _configJob; if (job == null || plugin == null) @@ -7446,6 +7482,24 @@ namespace Oxide.Plugins public float Radius; public string Name; + /// + /// A zone's ZoneManager flags and settings (PLAN_REDESIGNS §3.1), kept so a re-created + /// zone gets them back. Null on a zone from before protocol 13's options. + /// + public List Flags; + public Dictionary Settings; + + /// What a player is told on the way in and out, and how (D193). Said by this plugin, not ZoneManager. + public string EnterMessage; + public string LeaveMessage; + public string Delivery; + public string Format; + + /// A ZoneDomes dome over the zone (D194): its type (0–4) and how many spheres are stacked. + public bool Dome; + public int DomeType; + public int DomeStack; + /// When a zone is erased without being asked, on this host's clock (D96). Zero for an entity. public long DeadlineMs; @@ -7542,6 +7596,401 @@ namespace Oxide.Plugins return inside is bool && (bool)inside; } + // ---- a zone's options (PLAN_REDESIGNS §3.1, D166, D211) ---- + // + // ZoneManager's flags are whatever the installed version declares, so the list is read from + // it, never kept here. `ZoneFieldListRaw` is in ZoneManager's own API region and answers + // the zone fields followed by every flag name; the fields this plugin controls or does not + // offer are dropped from the front. Plan §0.6 believed only a plugin that references + // ZoneManager's types could read the list, so it put the read in the zone helper. That was + // wrong for 3.1.14 (D211), and a server without the helper still gets every flag. + + /// The fields `ZoneFieldListRaw` answers that are not flags, lower-cased. + private static readonly HashSet ZoneNonFlagFields = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "name", "id", "radius", "rotation", "size", "location", "enter_message", "leave_message", + "radiation", "comfort", "temperature", "safezone", "permission", "ejectspawns", "parentid", "enabled", "enable" + }; + + /// + /// The settings a step may give a zone, and their bounds. Every other ZoneManager field + /// (rotation, parent, eject spawns, size) changes what this plugin believes the zone is, + /// so it stays out (§3.1). The messages are this plugin's own (D193). + /// + private static readonly string[] ZoneSettingKeys = { "radiation", "comfort", "temperature", "safezone", "permission" }; + + private const float ZoneRadiationMax = 500f; + private const float ZoneComfortMax = 1f; + private const float ZoneTemperatureLimit = 100f; + private const int ZoneMessageMax = 256; + private const int ZoneFlagsMax = 80; + + /// ZoneDomes' own sphere types: Standard, Red, Blue, Green, Purple. + private const int DomeTypeMax = 4; + + /// The most spheres one dome stacks. Each is an entity; ZoneDomes itself has no limit. + private const int DomeStackMax = 10; + + /// A zone this plugin just put back says nothing to whoever is inside it for this long. + private const long ZoneQuietMs = 5000L; + + /// Zone id → the moment its enter and leave messages start again. Main thread. + private readonly Dictionary _zoneQuietUntil = new Dictionary(); + + /// + /// Main thread. ZoneManager's flag names, as it spells them, or null when it is not loaded + /// or does not answer. Asked when needed: an operator can update ZoneManager at any time. + /// + private static List ZoneFlagNames(Plugin zm) + { + var fields = zm == null ? null : zm.Call("ZoneFieldListRaw") as List; + + if (fields == null) + return null; + + var flags = new List(); + + foreach (string field in fields) + { + if (!string.IsNullOrEmpty(field) && !ZoneNonFlagFields.Contains(field)) + flags.Add(field); + } + + return flags; + } + + /// + /// Main thread. Reads a step's flags and settings against the live ZoneManager. Each flag + /// is returned as ZoneManager spells it; each setting is checked and written as + /// ZoneManager parses it. False with a refusal on anything unknown or out of range. + /// + private static bool ReadZoneOptions(Plugin zm, Dictionary cmd, + out List flags, out Dictionary settings, out string refusal) + { + flags = new List(); + settings = new Dictionary(StringComparer.Ordinal); + refusal = null; + + object raw; + + if (cmd.TryGetValue("flags", out raw) && raw != null) + { + var list = raw as Newtonsoft.Json.Linq.JArray; + + if (list == null) + { + refusal = "a zone's flags are a list of names"; + return false; + } + + if (list.Count > ZoneFlagsMax) + { + refusal = $"a zone takes at most {ZoneFlagsMax} flags, not {list.Count}"; + return false; + } + + List known = ZoneFlagNames(zm); + + if (known == null) + { + refusal = "this server's ZoneManager did not say which flags it has"; + return false; + } + + var byName = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (string k in known) + byName[k] = k; + + foreach (Newtonsoft.Json.Linq.JToken token in list) + { + string name = token.Type == Newtonsoft.Json.Linq.JTokenType.String ? (string)token : null; + string spelled; + + if (string.IsNullOrEmpty(name) || !byName.TryGetValue(name.Trim(), out spelled)) + { + refusal = $"'{name}' is not a flag this server's ZoneManager has"; + return false; + } + + if (!flags.Contains(spelled)) + flags.Add(spelled); + } + } + + if (cmd.TryGetValue("settings", out raw) && raw != null) + { + var obj = raw as Newtonsoft.Json.Linq.JObject; + + if (obj == null) + { + refusal = "a zone's settings are an object of name and value"; + return false; + } + + foreach (Newtonsoft.Json.Linq.JProperty p in obj.Properties()) + { + string key = p.Name.ToLowerInvariant(); + + if (Array.IndexOf(ZoneSettingKeys, key) < 0) + { + refusal = $"'{p.Name}' is not a zone setting this server offers (radiation, comfort, temperature, safezone, permission)"; + return false; + } + + // Invariant, because a JSON 0.5 read under the host's culture can come back "0,5". + var jv = p.Value as Newtonsoft.Json.Linq.JValue; + string value = jv == null ? null : Convert.ToString(jv.Value, System.Globalization.CultureInfo.InvariantCulture); + string written; + + if (!ZoneSettingValue(key, value, out written, out refusal)) + return false; + + settings[key] = written; + } + } + + return true; + } + + private static bool ZoneSettingValue(string key, string value, out string written, out string refusal) + { + written = null; + refusal = null; + var inv = System.Globalization.CultureInfo.InvariantCulture; + + if (key == "safezone") + { + bool on; + + if (!bool.TryParse(value, out on)) + { + refusal = $"safezone is true or false, not '{value}'"; + return false; + } + + written = on ? "true" : "false"; + return true; + } + + if (key == "permission") + { + string perm = (value ?? string.Empty).Trim().ToLowerInvariant(); + + if (perm.StartsWith("zonemanager.", StringComparison.Ordinal)) + perm = perm.Substring("zonemanager.".Length); + + if (perm.Length == 0 || perm.Length > 32) + { + refusal = "a zone's permission is 1 to 32 characters"; + return false; + } + + foreach (char c in perm) + { + if (!(c >= 'a' && c <= 'z') && !(c >= '0' && c <= '9') && c != '_') + { + refusal = $"a zone's permission is letters, digits and _, not '{value}'"; + return false; + } + } + + written = perm; + return true; + } + + float number; + + if (!float.TryParse(value, System.Globalization.NumberStyles.Float, inv, out number) || float.IsNaN(number) || float.IsInfinity(number)) + { + refusal = $"{key} is a number, not '{value}'"; + return false; + } + + float low = key == "temperature" ? -ZoneTemperatureLimit : 0f; + float high = key == "radiation" ? ZoneRadiationMax : key == "comfort" ? ZoneComfortMax : ZoneTemperatureLimit; + + if (number < low || number > high) + { + refusal = $"{key} is {low} to {high}, not {number.ToString(inv)}"; + return false; + } + + written = number.ToString(inv); + return true; + } + + // ---- the dome (PLAN_REDESIGNS §3.2, D167, D194) ---- + // + // ZoneDomes draws the dome. Two things it lacks are made up here and in the domes helper, + // `RunicGatewayDomes.cs`: its API ends in a chat message to the player who asked, which + // throws for a call with none (the helper patches that), and at its own start it drops + // every dome whose zone does not exist yet, which is every zone this plugin re-creates. + // So this plugin puts its domes back after it puts its zones back, and only once ZoneDomes + // has finished its own start: a dome added before that is drawn twice, and the first set + // of spheres is left in the world where nothing can remove it. The helper says when. + + private Plugin ZoneDomesPlugin() + { + return plugins.Find("ZoneDomes"); + } + + private Plugin DomesHelperPlugin() + { + return plugins.Find("RunicGatewayDomes"); + } + + /// Main thread. patched, unsupported, no-zonedomes or missing. + private string DomesHelperState() + { + Plugin helper = DomesHelperPlugin(); + return helper == null ? "missing" : helper.Call("State") as string ?? "unsupported"; + } + + /// Main thread. Whether a dome may be added now: ZoneDomes loaded, patched, and past its own start. + private bool DomesReady() + { + Plugin helper = DomesHelperPlugin(); + return ZoneDomesPlugin() != null && helper != null + && helper.Call("State") as string == "patched" && helper.Call("Ready") is bool ready && ready; + } + + /// Main thread. Why a dome cannot be added now, or null when it can. + private string DomeRefusal() + { + if (ZoneDomesPlugin() == null) + return "ZoneDomes is not loaded on this server, so a zone cannot have a dome"; + + string state = DomesHelperState(); + + if (state == "missing") + return "RunicGatewayDomes.cs is not installed beside the bridge, and ZoneDomes cannot be called without it"; + + if (state != "patched") + return $"RunicGatewayDomes.cs could not patch this ZoneDomes ({state})"; + + return DomesReady() ? null : "ZoneDomes has not finished starting"; + } + + /// Main thread. Adds a zone's dome. True when ZoneDomes made it, or already had it. + private bool AddDome(WorldEntry e) + { + Plugin zd = ZoneDomesPlugin(); + + if (zd == null || !e.Dome) + return false; + + object made = zd.Call("AddNewDome", null, e.Id, e.DomeType, e.DomeStack); + return made is bool && (bool)made; + } + + /// + /// Main thread. Takes a zone's dome out of the world and out of ZoneDomes' data file. + /// Called BEFORE the zone is erased, so a dome never outlives its zone. + /// + private void RemoveDome(WorldEntry e) + { + Plugin zd = ZoneDomesPlugin(); + + if (zd == null || !e.Dome) + return; + + // Without the helper the call still removes the dome and then throws on its message; + // the framework logs that, and the dome is gone either way. + zd.Call("RemoveExistingDome", null, e.Id); + } + + /// + /// Main thread. Puts back the dome of every standing zone that has one. ZoneDomes answers + /// false for a dome it already has, so this is safe to repeat. + /// + private void RestoreDomes(string why) + { + if (!_worldReady || !DomesReady()) + return; + + Plugin zm = ZoneManagerPlugin(); + + if (zm == null) + return; + + int added = 0; + + foreach (WorldEntry e in _world.Values) + { + if (e.Kind != WorldKindZone || !e.Dome || ZoneExpired(e) || zm.Call("CheckZoneID", e.Id) == null) + continue; + + if (AddDome(e)) + added++; + } + + if (added > 0) + { + _lastWorldSummary = $"domes after {why}: {added} put back"; + Puts($"world: {_lastWorldSummary}"); + } + } + + /// Raised by the domes helper when ZoneDomes has finished its own start. + private void OnRgDomesReady() + { + MarkHook("OnRgDomesReady"); + NextTick(() => RestoreDomes("ZoneDomes started")); + } + + // ---- the zone's messages (D193) ---- + // + // ZoneManager can say a zone's enter and leave messages itself, but only to chat unless its + // own config turns popups on for every zone on the server. So this plugin never gives it + // the messages for its own zones, and says them here, as each zone's step chose. + + private void OnEnterZone(string zoneId, BasePlayer player) + { + MarkHook("OnEnterZone"); + SayZoneMessage(zoneId, player, true); + } + + private void OnExitZone(string zoneId, BasePlayer player) + { + MarkHook("OnExitZone"); + SayZoneMessage(zoneId, player, false); + } + + private void SayZoneMessage(string zoneId, BasePlayer player, bool entering) + { + WorldEntry e; + + if (zoneId == null || player == null || !player.IsConnected || !_world.TryGetValue(zoneId, out e) || e.Kind != WorldKindZone) + return; + + string message = entering ? e.EnterMessage : e.LeaveMessage; + + if (string.IsNullOrEmpty(message)) + return; + + // A zone this plugin just put back (a restart, a ZoneManager reload) re-enters whoever + // is standing in it. They were told when they walked in. + long quiet; + + if (_zoneQuietUntil.TryGetValue(zoneId, out quiet)) + { + if (NowMs() < quiet) + return; + + _zoneQuietUntil.Remove(zoneId); + } + + // A popup where PopupNotifications is loaded, and chat where it is not (§2.4): the + // step was accepted long before this moment, and a player is told either way. + Plugin popup = e.Delivery == DeliveryPopup ? PopupPlugin() : null; + + if (popup != null) + popup.Call("CreatePopupNotification", message, player, 0f); + else + player.ChatMessage(e.Format == null ? message : Voice(e.Format, message)); + } + /// /// The map's monuments in the one order every caller agrees on: grouped by prefab short /// name, then by position. #n in a value counts within its group, so it names the @@ -7904,6 +8353,84 @@ namespace Oxide.Plugins if (name.Length > 64) name = name.Substring(0, 64); + // PLAN_REDESIGNS §3.1: the flags and settings, each checked against this server's own + // ZoneManager. An unknown one is refused, never dropped: a zone that quietly lacks the + // "no building" its author chose is worse than a step that fails and says why. + List flags; + Dictionary settings; + string optionRefusal; + + if (!ReadZoneOptions(zm, cmd, out flags, out settings, out optionRefusal)) + { + EmitWorldError(reqId, "bad-option", optionRefusal); + return; + } + + // D193: what a player is told on the way in and out, and whether as chat or a popup. + string enterMessage = StringField(cmd, "enterMessage"); + string leaveMessage = StringField(cmd, "leaveMessage"); + + if ((enterMessage != null && enterMessage.Length > ZoneMessageMax) || (leaveMessage != null && leaveMessage.Length > ZoneMessageMax)) + { + EmitWorldError(reqId, "out-of-range", $"a zone's message is at most {ZoneMessageMax} characters"); + return; + } + + string delivery = StringField(cmd, "delivery"); + + if (string.IsNullOrEmpty(delivery)) + delivery = DeliveryChat; + + if (delivery != DeliveryChat && delivery != DeliveryPopup) + { + EmitWorldError(reqId, "malformed", $"a zone's messages go to chat or to a popup, not to '{delivery}'"); + return; + } + + string format = StringField(cmd, "format"); + + if (format != null && (format.Length > ChatFormatMax || Occurrences(format, "{message}") != 1)) + { + EmitWorldError(reqId, "malformed", $"a chat format holds exactly one {{message}} and at most {ChatFormatMax} characters"); + return; + } + + // D194: a dome, where ZoneDomes and the domes helper are both here. + bool dome = false; + int domeType = 0, domeStack = 1; + object domeRaw; + + if (cmd.TryGetValue("dome", out domeRaw) && domeRaw != null) + { + var d = domeRaw as Newtonsoft.Json.Linq.JObject; + + if (d == null) + { + EmitWorldError(reqId, "malformed", "a zone's dome is an object of type and stack"); + return; + } + + Newtonsoft.Json.Linq.JToken t = d["type"], s = d["stack"]; + domeType = t != null && t.Type == Newtonsoft.Json.Linq.JTokenType.Integer ? (int)t : -1; + domeStack = s == null ? 1 : s.Type == Newtonsoft.Json.Linq.JTokenType.Integer ? (int)s : -1; + + if (domeType < 0 || domeType > DomeTypeMax || domeStack < 1 || domeStack > DomeStackMax) + { + EmitWorldError(reqId, "out-of-range", $"a dome's type is 0 to {DomeTypeMax} and its stack 1 to {DomeStackMax}"); + return; + } + + string noDome = DomeRefusal(); + + if (noDome != null) + { + EmitWorldError(reqId, "dome-unavailable", noDome); + return; + } + + dome = true; + } + var entry = new WorldEntry { Id = $"rg-{ShortRun(runId)}-{NowMs() % 100000000L}-{++_zoneSerial}", @@ -7915,6 +8442,15 @@ namespace Oxide.Plugins Z = point.z, Radius = (float)radius, Name = name, + Flags = flags.Count > 0 ? flags : null, + Settings = settings.Count > 0 ? settings : null, + EnterMessage = string.IsNullOrEmpty(enterMessage) ? null : enterMessage, + LeaveMessage = string.IsNullOrEmpty(leaveMessage) ? null : leaveMessage, + Delivery = delivery, + Format = delivery == DeliveryChat ? format : null, + Dome = dome, + DomeType = domeType, + DomeStack = domeStack, DeadlineMs = NowMs() + holdMs, BootId = _bootId, WipeId = _wipeId, @@ -7927,11 +8463,20 @@ namespace Oxide.Plugins return; } + // The dome after the zone, because ZoneDomes reads the zone's place and size from + // ZoneManager. A dome that is not made takes its zone with it: the step asked for both. + if (dome && !AddDome(entry)) + { + zm.Call("EraseTemporaryZone", this, entry.Id); + EmitWorldError(reqId, "dome-unavailable", "ZoneDomes did not make the dome, so the zone was not opened"); + return; + } + _world[entry.Id] = entry; SaveWorld(); EnsureWorldTimer(); - _lastWorldSummary = $"zone {entry.Id} r={radius:0} for {holdMs / 60000L} min, run {runId}"; + _lastWorldSummary = $"zone {entry.Id} r={radius:0} for {holdMs / 60000L} min, {flags.Count} flag(s){(dome ? ", domed" : "")}, run {runId}"; Puts($"world: {_lastWorldSummary}"); EmitWorldPlaced(reqId, new List { entry }, false); } @@ -7959,13 +8504,35 @@ namespace Oxide.Plugins /// private bool CreateZone(Plugin zm, WorldEntry entry) { - string[] args = + var args = new List { "name", entry.Name ?? "Event zone", "radius", entry.Radius.ToString(System.Globalization.CultureInfo.InvariantCulture) }; - object created = zm.Call("CreateOrUpdateTemporaryZone", this, entry.Id, args, new UnityEngine.Vector3(entry.X, entry.Y, entry.Z)); + // ZoneManager's own key/value arguments (§3.1): each setting by its key, and each flag + // as "", "true". Kept on the record, so a zone put back after a restart or a + // ZoneManager reload gets them back. A flag a newer ZoneManager no longer has is + // ignored by ZoneManager rather than failing the zone. + if (entry.Settings != null) + { + foreach (KeyValuePair kv in entry.Settings) + { + args.Add(kv.Key); + args.Add(kv.Value); + } + } + + if (entry.Flags != null) + { + foreach (string flag in entry.Flags) + { + args.Add(flag); + args.Add("true"); + } + } + + object created = zm.Call("CreateOrUpdateTemporaryZone", this, entry.Id, args.ToArray(), new UnityEngine.Vector3(entry.X, entry.Y, entry.Z)); return created is bool && (bool)created && zm.Call("CheckZoneID", entry.Id) != null; } @@ -7995,9 +8562,14 @@ namespace Oxide.Plugins continue; if (CreateZone(zm, e)) + { restored++; + _zoneQuietUntil[e.Id] = NowMs() + ZoneQuietMs; + } else + { failed++; + } } if (restored > 0 || failed > 0) @@ -8005,6 +8577,10 @@ namespace Oxide.Plugins _lastWorldSummary = $"zones after {why}: {restored} re-created, {failed} failed"; Puts($"world: {_lastWorldSummary}"); } + + // The domes after the zones (D194). At a real start ZoneDomes has not finished its own + // start yet, and the helper's ready hook brings this back when it has. + RestoreDomes(why); } /// Main thread. Place crates or NPCs an event owns, one registry entry per net id. @@ -8227,8 +8803,14 @@ namespace Oxide.Plugins // it lives in memory only — and its record is all there is left to remove. wasThere = zm != null && zm.Call("CheckZoneID", e.Id) != null; + // The dome first: ZoneDomes keeps it in its own data file, and a dome + // left there outlives the zone it was drawn over (D194). + RemoveDome(e); + if (wasThere) zm.Call("EraseTemporaryZone", this, e.Id); + + _zoneQuietUntil.Remove(e.Id); } else { @@ -8508,10 +9090,13 @@ namespace Oxide.Plugins foreach (WorldEntry e in due) { + RemoveDome(e); + if (zm != null && zm.Call("CheckZoneID", e.Id) != null) zm.Call("EraseTemporaryZone", this, e.Id); _world.Remove(e.Id); + _zoneQuietUntil.Remove(e.Id); Dictionary frame = Frame("world.expired", "event"); frame["id"] = e.Id; @@ -10047,10 +10632,34 @@ namespace Oxide.Plugins return new Dictionary { { "betterChat", IntegrationBody(BetterChatPlugin()) }, - { "popupNotifications", IntegrationBody(PopupPlugin()) } + { "popupNotifications", IntegrationBody(PopupPlugin()) }, + { "zoneDomes", ZoneDomesBody() } }; } + /// + /// ZoneDomes and the domes helper (D194), live on every status frame like the other + /// optional mods: the site offers a dome only where both are here and patched. + /// + private Dictionary ZoneDomesBody() + { + Dictionary body = IntegrationBody(ZoneDomesPlugin()); + var helper = new Dictionary { { "state", ZoneDomesPlugin() == null && DomesHelperPlugin() != null ? "no-zonedomes" : DomesHelperState() } }; + Plugin domes = DomesHelperPlugin(); + + if (domes != null) + { + helper["version"] = domes.Version.ToString(); + + if (domes.Call("StateReason") is string reason) + helper["reason"] = reason; + } + + body["helper"] = helper; + body["ready"] = DomesReady(); + return body; + } + /// A version only for a plugin that is loaded. Absent, not empty. private static Dictionary IntegrationBody(Plugin plugin) { diff --git a/overlay/oxide/plugins/RunicGatewayDomes.cs b/overlay/oxide/plugins/RunicGatewayDomes.cs new file mode 100644 index 0000000..5bba137 --- /dev/null +++ b/overlay/oxide/plugins/RunicGatewayDomes.cs @@ -0,0 +1,261 @@ +// Runic Gateway Domes — a helper for ZoneDomes, shipped beside the Runic Gateway bridge. +// Copyright (C) Runic Gateway contributors. GPL-3.0-or-later. +// +// ONE JOB (docs/modules/rust/PLAN_REDESIGNS.md §3.2, D168, D194): +// +// ZoneDomes draws a dome over a ZoneManager zone, and the bridge asks it to for an event zone +// whose step wants one. Its API was written for a person at a keyboard: `AddNewDome` and +// `RemoveExistingDome` both end by telling the player who asked how it went, with no check that +// there was one. Called by a plugin with no player, the dome is made and saved, and THEN the call +// throws — so the caller cannot tell a made dome from a failed one (plan §0.5, found on the rig). +// +// So this helper patches ZoneDomes with Harmony, the same way the zone helper patches ZoneManager +// (D181): +// +// * **A message with no player is not sent.** A prefix on ZoneDomes' `TranslateMessage(BasePlayer, +// string, string)` skips it when the player is null. Nothing else about the call changes, so +// `AddNewDome` returns its true or false normally. +// * **It says when ZoneDomes has finished starting.** ZoneDomes' own start (`InitializeDomes`) +// drops every dome whose zone does not exist yet and draws every one that does. A dome added +// BEFORE that is drawn a second time, and the first set of spheres stays in the world with +// nothing holding it. A postfix there raises `OnRgDomesReady`, and the bridge puts its domes +// back only after it. +// +// It is optional. Without it the bridge offers no dome and says why; the zones themselves are +// unaffected. `rgd.status` in the server console prints its state. + +using System; +using System.Reflection; +using HarmonyLib; +using Oxide.Core; +using Oxide.Core.Plugins; + +namespace Oxide.Plugins +{ + [Info("Runic Gateway Domes", "Runic Gateway", "0.1.0")] + [Description("Helper for Runic Gateway: ZoneDomes can be called without a player, and says when it has started.")] + public class RunicGatewayDomes : RustPlugin + { + private const string HarmonyId = "runicgateway.domes"; + + /// The instance the static patches report to. Null while unloaded. + private static RunicGatewayDomes _instance; + + private Harmony _harmony; + private MethodInfo _message; + private MethodInfo _initialize; + + /// patched, unsupported (ZoneDomes is here but not the shape patched), or no-zonedomes. + private string _state = "no-zonedomes"; + private string _reason; + + /// Whether ZoneDomes has finished its own start since it was patched. Main thread. + private bool _ready; + + /// Set once the server is up, at this plugin's own OnServerInitialized. + private bool _serverUp; + + // Patched as EARLY as possible — at load, or the moment ZoneDomes loads — so that at a real + // server start the patch is in before ZoneDomes' `OnServerInitialized` runs its start. + + private void Loaded() + { + _instance = this; + Patch(); + } + + private void OnServerInitialized(bool initial) + { + _serverUp = true; + + // Loaded into a running server: ZoneDomes, if it is here, started long ago. + if (!initial) + MarkReadyNextTick(); + } + + private void Unload() + { + Unpatch(); + _instance = null; + } + + private void OnPluginLoaded(Plugin plugin) + { + if (plugin == null || plugin.Name != "ZoneDomes") + return; + + Unpatch(); + Patch(); + + // A ZoneDomes loaded into a running server has run its start by now: the framework + // calls a hot-loaded plugin's `OnServerInitialized` before it tells anybody else it + // loaded. The tick is margin, and costs nothing. + if (_serverUp) + MarkReadyNextTick(); + } + + private void OnPluginUnloaded(Plugin plugin) + { + if (plugin != null && plugin.Name == "ZoneDomes") + { + Unpatch(); + _state = "no-zonedomes"; + _reason = null; + } + } + + // ── The API the bridge calls ───────────────────────────────────────────── + + /// patched, unsupported or no-zonedomes. + private string State() => _state; + + /// Why the state is not patched, or null. + private string StateReason() => _reason; + + /// Whether ZoneDomes is patched and past its own start, so a dome may be added. + private bool Ready() => _state == "patched" && _ready; + + /// The helper's own version, for hello. + private string HelperVersion() => Version.ToString(); + + // ── Patching ───────────────────────────────────────────────────────────── + + private void Patch() + { + _ready = false; + Plugin zoneDomes = plugins.Find("ZoneDomes"); + + if (zoneDomes == null) + { + _state = "no-zonedomes"; + _reason = null; + return; + } + + const BindingFlags any = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + Type type = zoneDomes.GetType(); + + _message = type.GetMethod("TranslateMessage", any, null, new[] { typeof(BasePlayer), typeof(string), typeof(string) }, null); + _initialize = type.GetMethod("InitializeDomes", any, null, Type.EmptyTypes, null); + + string missing = + _message == null ? "TranslateMessage(BasePlayer, string, string)" : + _initialize == null ? "InitializeDomes()" : + type.GetMethod("AddNewDome", any) == null ? "AddNewDome" : + type.GetMethod("RemoveExistingDome", any) == null ? "RemoveExistingDome" : + null; + + if (missing != null) + { + Fail($"this ZoneDomes ({zoneDomes.Version}) has no {missing}"); + return; + } + + try + { + _harmony = new Harmony(HarmonyId); + _harmony.Patch(_message, prefix: new HarmonyMethod( + typeof(RunicGatewayDomes).GetMethod(nameof(BeforeMessage), BindingFlags.Static | BindingFlags.NonPublic))); + _harmony.Patch(_initialize, postfix: new HarmonyMethod( + typeof(RunicGatewayDomes).GetMethod(nameof(AfterInitialize), BindingFlags.Static | BindingFlags.NonPublic))); + } + catch (Exception ex) + { + Fail($"Harmony could not patch ZoneDomes: {ex.Message}"); + return; + } + + _state = "patched"; + _reason = null; + Puts($"patched ZoneDomes {zoneDomes.Version}: its API can be called without a player"); + } + + private void Unpatch() + { + if (_harmony != null) + { + try + { + if (_message != null) + _harmony.Unpatch(_message, HarmonyPatchType.Prefix, HarmonyId); + + if (_initialize != null) + _harmony.Unpatch(_initialize, HarmonyPatchType.Postfix, HarmonyId); + } + catch (Exception ex) + { + PrintWarning($"could not remove the ZoneDomes patches: {ex.Message}"); + } + } + + _harmony = null; + _message = null; + _initialize = null; + _ready = false; + } + + private void Fail(string reason) + { + Unpatch(); + _state = "unsupported"; + _reason = reason; + PrintWarning($"{reason}. Runic Gateway will not offer a dome over its zones on this server."); + } + + private void MarkReadyNextTick() + { + NextTick(() => + { + if (_state != "patched" || _ready) + return; + + _ready = true; + Interface.CallHook("OnRgDomesReady"); + }); + } + + // ── The patches ────────────────────────────────────────────────────────── + + /// + /// Harmony prefix on ZoneDomes.TranslateMessage(BasePlayer, string, string). + /// Returning false skips the message; a real player still gets it. + /// + private static bool BeforeMessage(BasePlayer player) + { + return player != null; + } + + /// + /// Harmony postfix on ZoneDomes.InitializeDomes. Anything thrown here would surface + /// inside ZoneDomes, so nothing is allowed to. + /// + private static void AfterInitialize() + { + try + { + RunicGatewayDomes self = _instance; + + if (self == null || self._state != "patched") + return; + + self._ready = true; + Interface.CallHook("OnRgDomesReady"); + } + catch (Exception) + { + // Never ZoneDomes' problem. + } + } + + // ── Console ────────────────────────────────────────────────────────────── + + [ConsoleCommand("rgd.status")] + private void StatusCommand(ConsoleSystem.Arg arg) + { + if (arg.Connection != null && !arg.IsAdmin) + return; + + arg.ReplyWith($"Runic Gateway Domes {Version}: {_state}{(_state == "patched" ? (_ready ? ", ready" : ", waiting for ZoneDomes to start") : "")}{(_reason != null ? $" ({_reason})" : "")}"); + } + } +} -- 2.49.1 From b30c6edd5991d0551efd1d8c26d6027a8806d03b Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 29 Sep 2026 09:42:17 -0500 Subject: [PATCH 2/2] feat(bridge): a dome without a type is the full dome (D212) ZoneDomes' Standard type is its one whole dome; the four colours show only where they meet the ground or a building. Walked on both rigs 2026-09-29. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- overlay/oxide/plugins/RunicGateway.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index 19a5634..f748189 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -8411,7 +8411,9 @@ namespace Oxide.Plugins } Newtonsoft.Json.Linq.JToken t = d["type"], s = d["stack"]; - domeType = t != null && t.Type == Newtonsoft.Json.Linq.JTokenType.Integer ? (int)t : -1; + // No type is Standard, the one full dome; the colours show only where they meet + // the ground (D212). + domeType = t == null ? 0 : t.Type == Newtonsoft.Json.Linq.JTokenType.Integer ? (int)t : -1; domeStack = s == null ? 1 : s.Type == Newtonsoft.Json.Linq.JTokenType.Integer ? (int)s : -1; if (domeType < 0 || domeType > DomeTypeMax || domeStack < 1 || domeStack > DomeStackMax) -- 2.49.1