Merge pull request 'fix(bridge): protocol 13 step 2 — expiry, gathering, raids, plugin loads, and a ZoneManager helper (F1 F3 F4 F8 F12 F13)' (#18) from fix/protocol-13-step2 into edge

Reviewed-on: #18
This commit is contained in:
2026-09-27 06:11:46 +00:00
5 changed files with 646 additions and 29 deletions

View File

@@ -29,8 +29,9 @@
# exact commit being released.
#
# 2. A FRAMEWORK-NEUTRAL LAYOUT. The tarball holds one directory,
# `runicgateway-rust-plugin/`, with `RunicGateway.cs` and `manifest.json` at
# its top. It does NOT mirror `oxide/plugins/`: the same file goes to
# `runicgateway-rust-plugin/`, with `RunicGateway.cs`, `RunicGatewayZones.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
# has to parse the version out of a path to find the manifest that states it.
@@ -38,6 +39,10 @@
# 3. THE VERSION IS STAMPED into the shipped copy's `[Info(…)]` attribute, so
# `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
# version: it does one job, changes rarely, and the bridge reads its version at
# hello to know which job it does.
#
# The version is never the protocol. The protocol lives in overlay.toml and
# moves only when a message shape does; the version moves on every releasable
@@ -73,6 +78,9 @@ env:
REPO: RunicGateway/Rust-Plugins
ARTIFACT: runicgateway-rust-plugin
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
SEED_VERSION: "0.1.0"
INSTALLER_REPO: RunicGateway/installer
@@ -279,6 +287,9 @@ jobs:
run: |
set -euo pipefail
[ -f "$PLUGIN" ] || { echo "::error::${PLUGIN} is missing"; exit 1; }
for helper in $HELPERS; do
[ -f "$helper" ] || { echo "::error::${helper} is missing"; exit 1; }
done
[ -f overlay.toml ] || { echo "::error::overlay.toml is missing (protocol + framework declarations)"; exit 1; }
node scripts/checkPlugin.js
# The stamp below rewrites exactly one [Info(...)] line; zero or two
@@ -304,6 +315,19 @@ jobs:
grep -qF "[Info(\"Runic Gateway\", \"RunicGateway\", \"${VERSION}\")]" "${STAGE}/RunicGateway.cs" \
|| { echo "::error::stamping the version into [Info(...)] did not take"; exit 1; }
# The helpers go in as committed, each keeping its own [Info] version.
for helper in $HELPERS; do
cp "$helper" "${STAGE}/$(basename "$helper")"
done
# `files` names every .cs staged, with its sha256 — the installer places
# exactly this set, and checks each file against it.
PAIRS=()
for f in "${STAGE}"/*.cs; do
PAIRS+=("$(basename "$f")" "$(sha256sum "$f" | cut -d' ' -f1)")
done
FILES="$(jq -n '[$ARGS.positional | _nwise(2) | {(.[0]): .[1]}] | add' --args "${PAIRS[@]}")"
# Declarations from overlay.toml. Read, don't hardcode — the point of
# that file is that the protocol number lives in one place.
toml_str() { grep -m1 -E "^$1[[:space:]]*=" overlay.toml | sed -E 's/.*"([^"]+)".*/\1/'; }
@@ -329,7 +353,7 @@ jobs:
--arg min_oxide "${MIN_OXIDE}" \
--arg min_carbon "${MIN_CARBON}" \
--argjson requires "${REQUIRES}" \
--arg sha "$(sha256sum "${STAGE}/RunicGateway.cs" | cut -d' ' -f1)" \
--argjson files "${FILES}" \
'{
component: $component,
version: $version,
@@ -339,7 +363,7 @@ jobs:
min_oxide_version: $min_oxide,
min_carbon_version: $min_carbon,
requires_plugins: $requires,
files: { "RunicGateway.cs": $sha }
files: $files
}' > "${STAGE}/manifest.json"
echo "----- manifest.json -----"
cat "${STAGE}/manifest.json"

View File

@@ -34,9 +34,16 @@ unauthenticated command channel on the network.
## Installing it
```
overlay/oxide/plugins/RunicGateway.cs → <server>/oxide/plugins/RunicGateway.cs
overlay/oxide/plugins/RunicGateway.cs → <server>/oxide/plugins/RunicGateway.cs
overlay/oxide/plugins/RunicGatewayZones.cs → <server>/oxide/plugins/RunicGatewayZones.cs
```
`RunicGatewayZones.cs` is the **ZoneManager helper** (docs/modules/rust/PLAN_FIXES.md D181, D182). ZoneManager
counts a player in a zone only when its trigger fires on entry, so a zone created, updated or re-created around
somebody already standing there holds nobody. The helper patches that with Harmony. It is optional: without it the
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.
Oxide compiles and loads it on the write, and writes `oxide/config/RunicGateway.json` on first load:
```json
@@ -69,7 +76,8 @@ egg, both of which deploy a **released** plugin and install the sidecar beside i
Every merge to `main` that carries a `feat`, `fix` or `perf` commit cuts a release
(`.gitea/workflows/release.yml`): `runicgateway-rust-plugin-<version>.tar.gz` and `SHA256SUMS`. The
tarball holds one directory, `runicgateway-rust-plugin/`, with `RunicGateway.cs` (its `[Info]`
version stamped to the release) and a `manifest.json` folding in `overlay.toml`: the protocol, the
version stamped to the release), the helpers beside it (each keeping its own version; every `.cs` is listed with
its sha256 in the manifest's `files`, and the installer and the egg place them all) and a `manifest.json` folding in `overlay.toml`: the protocol, the
Oxide and Carbon floors, and `requires_plugins`. The layout names no framework; the installer and
the egg decide where the file goes. A release then asks the installer repo to recompose its Rust
bundle.

View File

@@ -38,9 +38,12 @@ protocol = 13
# ── Oxide compatibility ──────────────────────────────────────────────────────
#
# The overlay only ADDS a file — one plugin into `oxide/plugins/` — and patches
# nothing, so it is expected to work on any reasonably current Oxide. This is the
# oldest build it is known good on.
# The overlay only ADDS files — the bridge, and beside it the ZoneManager helper
# (`RunicGatewayZones.cs`, docs/modules/rust/PLAN_FIXES.md D181, D182), both into
# `oxide/plugins/` — and patches nothing on disk, so it is expected to work on any
# reasonably current Oxide. The helper patches ZoneManager in memory with Harmony,
# which both frameworks load; without it the bridge still loads and falls back.
# This is the oldest build it is known good on.
#
# There is no `patches_verified_against` key, and there is no `patches/` tier:
# Rust's server is a binary and Oxide's hook API is the supported way in, so

View File

@@ -234,6 +234,7 @@ namespace Oxide.Plugins
"OnServerInitialized", "OnServerShutdown", "OnNewSave",
"OnPlayerConnected", "OnPlayerDisconnected", "OnPlayerRespawned",
"OnPlayerDeath", "OnPlayerChat", "OnDispenserGather", "OnEntityDeath",
"OnDispenserBonus", "OnCollectiblePickup", "OnGrowableGathered",
"OnPlayerReported", "OnUserBanned", "OnUserUnbanned",
"CanUserLogin", "OnUserApproved",
"OnUserPermissionGranted", "OnUserPermissionRevoked",
@@ -1143,6 +1144,25 @@ namespace Oxide.Plugins
// must not do it until the world it would be asking about exists (§28.6).
hello["worldReady"] = _worldReady;
// The ZoneManager helper (D182), only where there is a ZoneManager for it to help.
// `patched` is the whole fix; anything else means this plugin scores its zones by
// position and ZoneManager's flags miss somebody already inside a new zone.
if (ZoneManagerPlugin() != null)
{
var helper = new Dictionary<string, object> { { "state", ZoneHelperState() } };
Plugin zones = ZoneHelperPlugin();
if (zones != null)
{
helper["version"] = zones.Version.ToString();
if (zones.Call("StateReason") is string reason)
helper["reason"] = reason;
}
hello["zoneHelper"] = helper;
}
EmitFrame(hello);
}
@@ -1163,7 +1183,7 @@ namespace Oxide.Plugins
var row = new Dictionary<string, object>
{
{ "steamId", player.UserIDString },
{ "steamId", SteamIdOf(player) },
{ "name", player.displayName },
{ "sleeping", player.IsSleeping() }
};
@@ -1298,7 +1318,7 @@ namespace Oxide.Plugins
if (attacker != null && !attacker.IsNpc)
{
frame["attackerType"] = "player";
frame["attackerId"] = attacker.UserIDString;
frame["attackerId"] = SteamIdOf(attacker);
frame["attackerName"] = attacker.displayName;
}
else
@@ -1447,6 +1467,7 @@ namespace Oxide.Plugins
// and this hook also fires on a hotload, which is exactly when ZoneManager has just
// erased every zone the previous load of this plugin owned (D92).
_worldReady = true;
SeedRegisteredPerms();
DropWorldFromOtherWipes();
RecreateZones("load");
@@ -1533,7 +1554,7 @@ namespace Oxide.Plugins
_connectedAt[player.userID] = NowMs();
Dictionary<string, object> frame = Frame("player.connected", "event");
frame["steamId"] = player.UserIDString;
frame["steamId"] = SteamIdOf(player);
frame["name"] = player.displayName;
EmitFrame(frame);
}
@@ -1550,7 +1571,7 @@ namespace Oxide.Plugins
FlushTally(player.userID);
Dictionary<string, object> frame = Frame("player.disconnected", "event");
frame["steamId"] = player.UserIDString;
frame["steamId"] = SteamIdOf(player);
frame["name"] = player.displayName;
if (!string.IsNullOrEmpty(reason))
@@ -1578,7 +1599,7 @@ namespace Oxide.Plugins
return;
Dictionary<string, object> frame = Frame("player.respawned", "event");
frame["steamId"] = player.UserIDString;
frame["steamId"] = SteamIdOf(player);
frame["name"] = player.displayName;
EmitFrame(frame);
}
@@ -1601,7 +1622,7 @@ namespace Oxide.Plugins
CreditKill(killer, true);
Dictionary<string, object> frame = Frame("player.death", "event");
frame["steamId"] = player.UserIDString;
frame["steamId"] = SteamIdOf(player);
frame["name"] = player.displayName;
frame["sleeping"] = player.IsSleeping();
@@ -1625,7 +1646,7 @@ namespace Oxide.Plugins
return;
Dictionary<string, object> frame = Frame("player.chat", "event");
frame["steamId"] = player.UserIDString;
frame["steamId"] = SteamIdOf(player);
frame["name"] = player.displayName;
frame["channel"] = channel.ToString();
@@ -1650,6 +1671,60 @@ namespace Oxide.Plugins
TallyFor(player).Gather(item.info.shortname, item.amount);
}
// ---- the rest of what a player gathers (F1, D159) ----
//
// `OnDispenserGather` alone is the swings. The first walk had 825 wood in the inventory
// and 587 on the tally, and 20 cloth from hemp and none on it: the bonus on a tree's final
// hit is `OnDispenserBonus`, and anything picked up off the ground — hemp, mushrooms,
// stones, a wild pumpkin — is `OnCollectiblePickup`. A farmed plant is
// `OnGrowableGathered`. D159: "gathered" is everything a player harvests, so the
// leaderboard agrees with the inventory. All three are counters (rule 2) and void (rule 1).
/// <summary>The bonus on the final hit of a tree or a node. Void: an Item return replaces the bonus.</summary>
private void OnDispenserBonus(ResourceDispenser dispenser, BasePlayer player, Item item)
{
MarkHook("OnDispenserBonus");
if (player == null || player.IsNpc || item == null || item.info == null)
return;
TallyFor(player).Gather(item.info.shortname, item.amount);
}
/// <summary>
/// A pick-up off the ground. Void: a non-null return cancels it. It fires BEFORE the
/// items move, so what is counted is the collectible's list — and a pick-up another
/// plugin vetoes is counted anyway, which is a rarity worth less than a second hook.
/// </summary>
private void OnCollectiblePickup(CollectibleEntity collectible, BasePlayer player)
{
MarkHook("OnCollectiblePickup");
if (player == null || player.IsNpc || collectible == null || collectible.itemList == null)
return;
Tally tally = TallyFor(player);
foreach (ItemAmount amount in collectible.itemList)
{
if (amount == null || amount.itemDef == null)
continue;
tally.Gather(amount.itemDef.shortname, (int)amount.amount);
}
}
/// <summary>A farmed plant, harvested. No return behaviour on either framework.</summary>
private void OnGrowableGathered(GrowableEntity plant, Item item, BasePlayer player)
{
MarkHook("OnGrowableGathered");
if (player == null || player.IsNpc || item == null || item.info == null)
return;
TallyFor(player).Gather(item.info.shortname, item.amount);
}
/// <summary>
/// Everything that dies which is not a player death: NPC kills, which are a counter,
/// and raided structures, which are an event with a `staff` class because a
@@ -1712,7 +1787,13 @@ namespace Oxide.Plugins
// The tally's meaning is unchanged since protocol 2 — building blocks only — so a
// leaderboard column does not quietly change what it counts in the middle of a wipe.
if (structure == "block")
//
// **But not your own** (F3). The first walk's org lead broke their own wall and the
// next tally carried `structures: 1`. Taking down a piece you placed, or one on a
// cupboard you are authorised on, is building, not raiding. The frame still goes: the
// site already skips the raid ALERT for an authorised attacker (D59), and a staff feed
// that loses a line because the player happened to own it is a feed with a hole.
if (structure == "block" && !OwnsOrAuthed(attacker, entity))
TallyFor(attacker).Structures++;
Dictionary<string, object> frame = Frame("entity.destroyed", "event");
@@ -1834,7 +1915,7 @@ namespace Oxide.Plugins
if (reporter != null)
{
frame["reporterId"] = reporter.UserIDString;
frame["reporterId"] = SteamIdOf(reporter);
frame["reporterName"] = reporter.displayName;
}
@@ -2620,7 +2701,7 @@ namespace Oxide.Plugins
if (player == null)
return;
string steamId = player.UserIDString;
string steamId = SteamIdOf(player);
if (string.IsNullOrEmpty(steamId))
return;
@@ -2683,7 +2764,7 @@ namespace Oxide.Plugins
if (player == null)
return;
string steamId = player.UserIDString;
string steamId = SteamIdOf(player);
if (string.IsNullOrEmpty(steamId))
return;
@@ -3965,13 +4046,90 @@ namespace Oxide.Plugins
// produces `OnPluginLoaded` for the target or it does not, and the second case is a plugin
// that is DOWN because of an edit the website just made.
//
// They are deliberately not a general plugin-lifecycle feed. Nothing is emitted from
// either: a busy server loads and unloads plugins for reasons that have nothing to do with
// us, and a frame per event would be noise on the wire and a trigger nobody asked for.
// Protocol 13 made them a feed as well, and a narrow one (F8, D184). A grant the site made
// for a plugin that was not loaded is `unresolved` until the plugin comes back — and the
// first walk watched it land thirteen minutes after the plugin did, on the fifteen-minute
// audit, under a page promising "it will land by itself". So a load or an unload is now
// announced with the permissions that plugin added or removed, and the site re-syncs when
// that list is not empty. Only once the world is ready: a boot loads every plugin, and a
// boot is the restart sync's job.
/// <summary>The registered permission list as the last load or unload left it. Main thread.</summary>
private HashSet<string> _registeredPerms;
/// <summary>
/// Main thread. The list as it is now; the first call is the baseline. From
/// <c>OnServerInitialized</c>, which is also the moment <c>_worldReady</c> turns true.
/// </summary>
private void SeedRegisteredPerms()
{
string[] now = permission.GetPermissions();
_registeredPerms = new HashSet<string>(now ?? new string[0], StringComparer.OrdinalIgnoreCase);
}
/// <summary>
/// Main thread, one tick after the hook. The diff is taken then rather than inside the
/// hook because the order of "the framework drops an unloaded plugin's permissions" and
/// "the framework calls <c>OnPluginUnloaded</c>" is the framework's, and a tick later
/// both have happened on either. Two plugins loaded in the same tick share one diff and
/// the first name gets it — the site's answer (re-sync) is the same either way, and
/// §4.1's owner is taken from the inventory, not from this.
/// </summary>
private void EmitPluginChange(string name, string version, bool loaded)
{
if (_registeredPerms == null)
return;
string[] now = permission.GetPermissions() ?? new string[0];
HashSet<string> current = new HashSet<string>(now, StringComparer.OrdinalIgnoreCase);
List<string> changed = new List<string>();
if (loaded)
{
foreach (string perm in current)
{
if (!_registeredPerms.Contains(perm))
changed.Add(perm);
}
}
else
{
foreach (string perm in _registeredPerms)
{
if (!current.Contains(perm))
changed.Add(perm);
}
}
_registeredPerms = current;
changed.Sort(StringComparer.Ordinal);
Dictionary<string, object> frame = Frame(loaded ? "plugin.loaded" : "plugin.unloaded", "event");
frame["name"] = name;
if (!string.IsNullOrEmpty(version))
frame["version"] = version;
frame["permissions"] = changed;
EmitFrame(frame);
}
/// <summary>Main thread. Queues <see cref="EmitPluginChange"/> for somebody else's plugin, once the world is up.</summary>
private void NotePluginChange(Plugin plugin, bool loaded)
{
if (plugin == null || plugin == this || !_worldReady)
return;
string name = plugin.Name;
string version = plugin.Version.ToString();
NextTick(() => EmitPluginChange(name, version, loaded));
}
private void OnPluginLoaded(Plugin plugin)
{
MarkHook("OnPluginLoaded");
NotePluginChange(plugin, true);
// ZoneManager keeps temporary zones in memory, so a reload of it loses every zone an
// event opened. They are put back from this plugin's own record (D92).
@@ -3995,6 +4153,7 @@ namespace Oxide.Plugins
private void OnPluginUnloaded(Plugin plugin)
{
MarkHook("OnPluginUnloaded");
NotePluginChange(plugin, false);
if (plugin != null && plugin.Name == "BetterChat")
_titlesRegistered = false;
@@ -6925,6 +7084,52 @@ namespace Oxide.Plugins
return plugins.Find("ZoneManager");
}
// ---- the ZoneManager helper (F12, D181, D182) ----
//
// ZoneManager counts a player in a zone only when its trigger fires on entry, so a zone
// created, updated or re-created around somebody standing there holds nobody. The helper,
// `RunicGatewayZones.cs`, ships beside this file and patches that. It is optional: without it,
// or when it loaded and could not find what it patches, the tally and the kill credit ask
// ZoneManager's public `IsPositionInZone` instead of its membership — so a missing helper
// costs ZoneManager's own flags for somebody already inside, never their score.
private Plugin ZoneHelperPlugin()
{
return plugins.Find("RunicGatewayZones");
}
/// <summary>Main thread. <c>patched</c>, <c>unsupported</c>, <c>no-zonemanager</c> or <c>missing</c>.</summary>
private string ZoneHelperState()
{
Plugin helper = ZoneHelperPlugin();
return helper == null ? "missing" : helper.Call("State") as string ?? "unsupported";
}
/// <summary>
/// Main thread. Whether ZoneManager's own membership can be trusted for a zone that may
/// have been made around somebody. Asked once per tally tick or kill, not per player.
/// </summary>
private bool ZoneMembershipTrusted()
{
return ZoneHelperState() == "patched";
}
/// <summary>
/// Main thread. Is this player in the zone: ZoneManager's membership when the helper has
/// made it right, and the zone's shape otherwise (D182).
/// </summary>
private static bool InZone(Plugin zm, string zoneId, BasePlayer player, bool trusted)
{
if (zm == null || player == null)
return false;
object inside = trusted
? zm.Call("IsPlayerInZone", zoneId, player)
: zm.Call("IsPositionInZone", zoneId, player.transform.position);
return inside is bool && (bool)inside;
}
/// <summary>
/// The map's monuments in the one order every caller agrees on: grouped by prefab short
/// name, then by position. <c>#n</c> in a value counts within its group, so it names the
@@ -7898,7 +8103,12 @@ namespace Oxide.Plugins
Dictionary<string, object> frame = Frame("world.expired", "event");
frame["id"] = e.Id;
frame["kind"] = e.Kind;
// `what`, never `kind` (F13, protocol 13). Until 13 this line wrote the entity's
// kind over the FRAME's, so every expiry left the plugin filed as `kind: "zone"`
// and nothing listening for `world.expired` ever heard one. `what` is the name the
// site's resource rows already use for the same thing.
frame["what"] = e.Kind;
if (!string.IsNullOrEmpty(e.RunId))
frame["runId"] = e.RunId;
@@ -8456,7 +8666,24 @@ namespace Oxide.Plugins
if (zm == null || zm.Call("CheckZoneID", t.ZoneId) == null)
return;
present = zm.Call("GetPlayersInZone", t.ZoneId) as List<BasePlayer>;
if (ZoneMembershipTrusted())
{
present = zm.Call("GetPlayersInZone", t.ZoneId) as List<BasePlayer>;
}
else
{
// No helper (D182): ZoneManager's membership misses whoever was standing here
// when the zone was made, so the zone's own shape decides.
var inside = new List<BasePlayer>();
foreach (BasePlayer player in BasePlayer.activePlayerList)
{
if (InZone(zm, t.ZoneId, player, false))
inside.Add(player);
}
present = inside;
}
}
else
{
@@ -8534,6 +8761,7 @@ namespace Oxide.Plugins
long now = NowMs();
Plugin zm = null;
bool zmLooked = false;
bool trusted = false;
foreach (RunTally t in _runTallies.Values)
{
@@ -8548,12 +8776,12 @@ namespace Oxide.Plugins
if (!zmLooked)
{
zm = ZoneManagerPlugin();
trusted = ZoneMembershipTrusted();
zmLooked = true;
}
object inside = zm != null ? zm.Call("IsPlayerInZone", t.ZoneId, killer) : null;
if (!(inside is bool) || !(bool)inside)
// Membership with the helper, the zone's shape without it (D182).
if (!InZone(zm, t.ZoneId, killer, trusted))
continue;
}
@@ -8913,7 +9141,7 @@ namespace Oxide.Plugins
KitCredit c;
if (!_credits.TryGetValue(CreditKey(player.UserIDString, kitName), out c))
if (!_credits.TryGetValue(CreditKey(SteamIdOf(player), kitName), out c))
return;
int applied = 0, withdrawn = 0;
@@ -10470,6 +10698,26 @@ namespace Oxide.Plugins
/// read it back empty from a sleeper made on the server: an id the map could not name
/// is an own dot and a clan mate the site can never find.
/// </summary>
/// <summary>
/// Whether the player placed this piece or is authorised on the cupboard that covers
/// it — the test for "their own base" (F3). Main thread: it reads the building's
/// privilege. A piece with no cupboard is judged on its owner alone.
/// </summary>
private static bool OwnsOrAuthed(BasePlayer player, BaseEntity entity)
{
if (player == null || entity == null)
return false;
if (entity.OwnerID == (ulong)player.userID)
return true;
// `authorizedPlayers` rather than `IsAuthed`, for AddAuthorization's reason: it is the
// set this plugin already reads for the raid frame, proven on both frameworks.
BuildingPrivlidge privilege = entity.GetBuildingPrivilege();
return privilege != null && privilege.authorizedPlayers != null
&& privilege.authorizedPlayers.Contains(player.userID);
}
private static string SteamIdOf(BasePlayer p)
{
ulong id = p.userID;

View File

@@ -0,0 +1,334 @@
// Runic Gateway Zones — a helper for ZoneManager, shipped beside the Runic Gateway bridge.
// Copyright (C) Runic Gateway contributors. GPL-3.0-or-later.
//
// ONE 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.
//
// 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.1.0")]
[Description("Helper for Runic Gateway: ZoneManager counts players already inside a zone when it is created, updated or loaded.")]
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>
/// 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);
_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");
// The zones ZoneManager loaded before this helper did were initialised unwatched —
// which, at a server start, is all of them. Sweep them once now.
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();
foreach (KeyValuePair<string, object> entry in batch)
Sweep(entry.Key, entry.Value);
}
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})" : "")}");
}
}
}