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})" : "")}"); + } + } +}