fix(bridge): protocol 13 step 2 — expiry, gathering, raids, plugin loads, and a ZoneManager helper (F1 F3 F4 F8 F12 F13)

The bridge's half of PLAN_FIXES §6 step 2 (decisions D181-D185, docs#288).

- F13: `world.expired` carries the entity's kind as `what`. It used to write
  it over the frame's own `kind`, so every expiry left filed as "zone".
- F1 (D159): `gathered` also counts OnDispenserBonus (the final-hit bonus),
  OnCollectiblePickup (hemp, mushrooms, stones off the ground) and
  OnGrowableGathered (farmed plants); all three are void and in ExpectedHooks.
- F3: taking down a building block you own, or one on a cupboard you are
  authorised on, no longer counts toward `structures`. The frame still goes.
- F4: every remaining UserIDString is SteamIdOf(userID), so a player another
  plugin spawned is never `steamId: null`.
- F8 (D184): `plugin.loaded` / `plugin.unloaded`, once the world is ready,
  with the permissions that plugin added or removed (a diff of the registered
  list, taken a tick after the hook).
- F12 (D181, D182): RunicGatewayZones.cs, a D168 helper shipped beside the
  bridge, Harmony-postfixes ZoneManager's Zone.InitializeZone and, a tick
  later, enters every connected player ZoneManager's own IsPositionInZone
  places inside, through ZoneManager's own OnPlayerEnterZone. Without it (or
  when it cannot find what it patches) the bridge scores its zones by
  IsPositionInZone. Hello reports `zoneHelper: { state, version?, reason? }`.
- release.yml stages every helper beside the bridge and lists each .cs with
  its sha256 in manifest.files; the installer and the egg place them all.

Not yet compiled: this is walked on both rigs before it merges.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-09-26 21:36:04 -05:00
parent fc7dccb90b
commit 586ad7ceba
5 changed files with 644 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,332 @@
// 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;
foreach (BasePlayer player in BasePlayer.activePlayerList.ToArray())
{
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})" : "")}");
}
}
}