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