// Runic Gateway Zones — a helper for ZoneManager, shipped beside the Runic Gateway bridge. // Copyright (C) Runic Gateway contributors. GPL-3.0-or-later. // // ITS FIRST JOB (docs/modules/rust/PLAN_FIXES.md F12, D168, D181, D182): // // ZoneManager counts a player as inside a zone only when the zone's trigger fires on ENTRY. A // zone created, updated or loaded around somebody who is already standing there never fires it, // so ZoneManager says nobody is inside — and its flags, its enter message and `OnEnterZone` never // happen for them. The first player walk found it twice: after a restart the org lead woke up // eighteen metres inside an event zone the bridge had just re-created, and ZoneManager's // `IsPlayerInZone` said no; and a zone opened around a player did the same. Every participant of // an event that survives a restart scores nothing until they walk out and back in. // // ZoneManager has no public way to ask it to look again: the routine that does (`UpdatePlayerZones`) // is private and runs only for a player who is connecting. So this helper patches ZoneManager with // Harmony, which is the org lead's decision (D181): // // * **The patch point is `Zone.InitializeZone`**, the one method a zone passes through when it is // created (`CreateOrUpdateZone`), updated (`Zone.Reset`) or loaded from ZoneManager's own data // file at startup. A postfix there sees every one of them. // * **It does not decide who is inside.** One tick later — when ZoneManager has finished // registering the zone — it asks ZoneManager's own public `IsPositionInZone` for each connected // player, and enters those it names through ZoneManager's own `OnPlayerEnterZone`. So ZoneManager's // flags, eject and kill rules, enter message and `OnEnterZone` all apply exactly as if the // player had walked in. A player ZoneManager already counts is skipped, and ZoneManager refuses // a second entry anyway, so a trigger that does fire after all changes nothing. // * **It fixes every zone on the server**, not only the bridge's. That is deliberate: the fault is // ZoneManager's, and it is the same fault for an arena another plugin made. // // A SECOND JOB (RunicNPC stage 9d walk, the org lead's call, 2026-10-07): // // ZoneManager subscribes the hooks that enforce its flags (`OnEntityTakeDamage` for PvpGod and the // other damage flags, `OnEntityBuilt` for NoBuild, and so on) only for the flags some zone had when // ZoneManager loaded, or when a flag is toggled by command. A zone made later through its API — // which is every zone an event opens — never re-subscribes them, so on a server with no such zone // already, an event's PvpGod or NoBuild zone enforces nothing: the 9d walk shot a player dead and // built a foundation inside one. The same postfix that sees every zone made therefore also has // ZoneManager run its own `UpdateHookSubscriptions` on the next tick. ZoneManager decides what to // subscribe; this only asks it to look. // // It is optional. The bridge never depends on it to load: without it — deleted, or loaded but // unable to find what it patches after a ZoneManager update — the bridge scores the zones it made // by `IsPositionInZone` instead (D182), and reports this helper's state at hello so the site can // say what is missing. `rgz.status` in the server console prints the same state. using System; using System.Collections.Generic; using System.Reflection; using HarmonyLib; using Oxide.Core.Plugins; using UnityEngine; namespace Oxide.Plugins { [Info("Runic Gateway Zones", "Runic Gateway", "0.2.0")] [Description("Helper for Runic Gateway: ZoneManager counts players already inside a zone when it is created, updated or loaded, and enforces a new zone's flags.")] public class RunicGatewayZones : RustPlugin { private const string HarmonyId = "runicgateway.zones"; /// The instance the static postfix reports to. Null while unloaded. private static RunicGatewayZones _instance; private Harmony _harmony; private MethodInfo _patched; // Reflected once per ZoneManager load. A ZoneManager reload is a new assembly, so all of it // is looked up again then. private Plugin _zoneManager; private MethodInfo _enter; private MethodInfo _getZone; private FieldInfo _definition; private PropertyInfo _id; /// ZoneManager's private UpdateHookSubscriptions, or null when this ZoneManager has none (the second job is then off). private MethodInfo _updateHooks; /// /// What the bridge reads through : patched, unsupported /// (ZoneManager is here but not the shape this helper patches), or no-zonemanager. /// private string _state = "no-zonemanager"; private string _reason; /// Zones initialised this tick, swept once on the next. Main thread. private readonly Dictionary _pending = new Dictionary(); private bool _sweepQueued; private void OnServerInitialized() { _instance = this; Patch(); } private void Unload() { Unpatch(); _instance = null; } private void OnPluginLoaded(Plugin plugin) { if (plugin != null && plugin.Name == "ZoneManager") { Unpatch(); Patch(); } } private void OnPluginUnloaded(Plugin plugin) { if (plugin != null && plugin.Name == "ZoneManager") { Unpatch(); _state = "no-zonemanager"; _reason = null; } } // ── The API the bridge calls ───────────────────────────────────────────── /// patched, unsupported or no-zonemanager. private string State() => _state; /// Why the state is not patched, or null. private string StateReason() => _reason; /// The helper's own version, for hello. private string HelperVersion() => Version.ToString(); // ── Patching ───────────────────────────────────────────────────────────── private void Patch() { _zoneManager = plugins.Find("ZoneManager"); if (_zoneManager == null) { _state = "no-zonemanager"; _reason = null; return; } const BindingFlags any = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; Type zmType = _zoneManager.GetType(); Type zoneType = zmType.GetNestedType("Zone", BindingFlags.Public | BindingFlags.NonPublic); MethodInfo initialize = zoneType?.GetMethod("InitializeZone", any); _getZone = zmType.GetMethod("GetZoneByID", any, null, new[] { typeof(string) }, null); _updateHooks = zmType.GetMethod("UpdateHookSubscriptions", any, null, Type.EmptyTypes, null); _definition = zoneType?.GetField("definition", any); _id = _definition?.FieldType.GetProperty("Id", any); _enter = zoneType == null ? null : zmType.GetMethod("OnPlayerEnterZone", any, null, new[] { typeof(BasePlayer), zoneType }, null); string missing = zoneType == null ? "ZoneManager.Zone" : initialize == null ? "Zone.InitializeZone" : _definition == null || _id == null ? "Zone.definition.Id" : _enter == null ? "ZoneManager.OnPlayerEnterZone(BasePlayer, Zone)" : _getZone == null ? "ZoneManager.GetZoneByID(string)" : null; if (missing != null) { Fail($"this ZoneManager ({_zoneManager.Version}) has no {missing}"); return; } try { _harmony = new Harmony(HarmonyId); _harmony.Patch(initialize, postfix: new HarmonyMethod( typeof(RunicGatewayZones).GetMethod(nameof(AfterInitialize), BindingFlags.Static | BindingFlags.NonPublic))); _patched = initialize; } catch (Exception ex) { Fail($"Harmony could not patch Zone.InitializeZone: {ex.Message}"); return; } _state = "patched"; _reason = null; Puts($"patched ZoneManager {_zoneManager.Version}: a zone takes in the players already inside it" + (_updateHooks != null ? ", and a new zone's flags are enforced" : "")); if (_updateHooks == null) PrintWarning($"this ZoneManager ({_zoneManager.Version}) has no UpdateHookSubscriptions: a zone made after it loaded may not enforce its flags until ZoneManager reloads"); // The zones ZoneManager loaded before this helper did were initialised unwatched — // which, at a server start, is all of them. Sweep them once now. RefreshHooks(); SweepAll(); } private void Unpatch() { if (_harmony != null && _patched != null) { try { _harmony.Unpatch(_patched, HarmonyPatchType.Postfix, HarmonyId); } catch (Exception ex) { PrintWarning($"could not remove the ZoneManager patch: {ex.Message}"); } } _harmony = null; _patched = null; _pending.Clear(); } private void Fail(string reason) { Unpatch(); _state = "unsupported"; _reason = reason; PrintWarning($"{reason}. Runic Gateway scores its zones by position instead; ZoneManager's own flags will not apply to somebody already inside a new zone."); } // ── The patch ──────────────────────────────────────────────────────────── /// /// Harmony postfix on ZoneManager.Zone.InitializeZone. Static, as Harmony needs; /// it only records the zone. Anything thrown here would surface inside ZoneManager, so /// nothing is allowed to. /// private static void AfterInitialize(object __instance) { try { _instance?.Queue(__instance); } catch (Exception) { // Never ZoneManager's problem. } } private void Queue(object zone) { object definition = _definition.GetValue(zone); string id = definition == null ? null : _id.GetValue(definition) as string; if (string.IsNullOrEmpty(id)) return; // The newest instance wins: a zone re-created in the same tick is one sweep. _pending[id] = zone; if (_sweepQueued) return; _sweepQueued = true; // A tick later, not now. `CreateOrUpdateZone` initialises a zone once with an empty // definition and again after the arguments are applied, and ZoneManager's startup load // registers the zone in its table only AFTER initialising it — so `IsPositionInZone` // answers correctly on the next tick and not before. NextTick(SweepPending); } private void SweepPending() { _sweepQueued = false; if (_state != "patched" || _pending.Count == 0) { _pending.Clear(); return; } var batch = new List>(_pending); _pending.Clear(); // The second job first: the hooks that enforce the new zones' flags, then the players. RefreshHooks(); foreach (KeyValuePair entry in batch) Sweep(entry.Key, entry.Value); } /// /// Main thread. Has ZoneManager subscribe the hooks its zones' flags now need. ZoneManager /// only adds subscriptions here, and subscribing a hook twice is a no-op. /// private void RefreshHooks() { if (_updateHooks == null || _zoneManager == null) return; try { _updateHooks.Invoke(_zoneManager, null); } catch (Exception ex) { PrintWarning($"could not have ZoneManager refresh its hooks: {(ex.InnerException ?? ex).Message}"); } } private void SweepAll() { var ids = _zoneManager.Call("GetZoneIDs") as string[]; if (ids == null) return; foreach (string id in ids) Sweep(id, null); } /// /// Main thread. Enters every connected player ZoneManager says is inside the zone and /// does not yet count. is the instance to enter them into, or null /// to look it up (the startup sweep). /// private void Sweep(string id, object zone) { Plugin zm = _zoneManager; if (zm == null || zm.Call("CheckZoneID", id) == null) return; zone = zone ?? _getZone.Invoke(zm, new object[] { id }); if (zone == null || (zone is UnityEngine.Object unity && unity == null)) return; int entered = 0; // A copy: entering a player can eject or kill them, which changes the live list. // `activePlayerList` is Facepunch's ListHashSet, which has no ToArray. foreach (BasePlayer player in new List(BasePlayer.activePlayerList)) { if (player == null || player.IsNpc || player.transform == null) continue; if (zm.Call("IsPlayerInZone", id, player) is bool already && already) continue; if (!(zm.Call("IsPositionInZone", id, player.transform.position) is bool inside) || !inside) continue; try { _enter.Invoke(zm, new[] { (object)player, zone }); entered++; } catch (Exception ex) { PrintWarning($"could not enter {player.displayName} into zone {id}: {(ex.InnerException ?? ex).Message}"); } } if (entered > 0) Puts($"zone {id}: {entered} player(s) already inside were counted in"); } // ── Console ────────────────────────────────────────────────────────────── [ConsoleCommand("rgz.status")] private void StatusCommand(ConsoleSystem.Arg arg) { if (arg.Connection != null && !arg.IsAdmin) return; arg.ReplyWith($"Runic Gateway Zones {Version}: {_state}{(_reason != null ? $" ({_reason})" : "")}" + $"; new zones' flags {(_updateHooks != null ? "enforced" : "NOT refreshed (no UpdateHookSubscriptions)")}"); } } }