Files
Rust-Plugins/overlay/oxide/plugins/RunicGatewayZones.cs
wtclaude a785ad91ef
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 13s
fix(zones): an event's zone enforces its flags (RunicGatewayZones 0.2.0, runicnpc 9d)
ZoneManager subscribes the hooks that enforce its flags only for the zones it
had when it loaded; a zone made later through its API, which is every zone an
event opens, never re-subscribes them, so an event's PvpGod or NoBuild zone
enforced nothing. The helper's postfix, which already sees every zone created,
updated or loaded, now has ZoneManager run its own UpdateHookSubscriptions on
the next tick, before entering the players already inside. A ZoneManager
without that method is warned about and reported by rgz.status; the first job
still works.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-10-07 13:41:57 -05:00

377 lines
16 KiB
C#

// 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";
/// <summary>The instance the static postfix reports to. Null while unloaded.</summary>
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;
/// <summary>ZoneManager's private <c>UpdateHookSubscriptions</c>, or null when this ZoneManager has none (the second job is then off).</summary>
private MethodInfo _updateHooks;
/// <summary>
/// What the bridge reads through <see cref="State"/>: <c>patched</c>, <c>unsupported</c>
/// (ZoneManager is here but not the shape this helper patches), or <c>no-zonemanager</c>.
/// </summary>
private string _state = "no-zonemanager";
private string _reason;
/// <summary>Zones initialised this tick, swept once on the next. Main thread.</summary>
private readonly Dictionary<string, object> _pending = new Dictionary<string, object>();
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 ─────────────────────────────────────────────
/// <summary><c>patched</c>, <c>unsupported</c> or <c>no-zonemanager</c>.</summary>
private string State() => _state;
/// <summary>Why the state is not <c>patched</c>, or null.</summary>
private string StateReason() => _reason;
/// <summary>The helper's own version, for hello.</summary>
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 ────────────────────────────────────────────────────────────
/// <summary>
/// Harmony postfix on <c>ZoneManager.Zone.InitializeZone</c>. Static, as Harmony needs;
/// it only records the zone. Anything thrown here would surface inside ZoneManager, so
/// nothing is allowed to.
/// </summary>
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<KeyValuePair<string, object>>(_pending);
_pending.Clear();
// The second job first: the hooks that enforce the new zones' flags, then the players.
RefreshHooks();
foreach (KeyValuePair<string, object> entry in batch)
Sweep(entry.Key, entry.Value);
}
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// Main thread. Enters every connected player ZoneManager says is inside the zone and
/// does not yet count. <paramref name="zone"/> is the instance to enter them into, or null
/// to look it up (the startup sweep).
/// </summary>
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>(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)")}");
}
}
}