diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 14c8eb9..5f66dba 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -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" diff --git a/README.md b/README.md index bdb5b7f..71e82a6 100644 --- a/README.md +++ b/README.md @@ -34,9 +34,16 @@ unauthenticated command channel on the network. ## Installing it ``` -overlay/oxide/plugins/RunicGateway.cs → /oxide/plugins/RunicGateway.cs +overlay/oxide/plugins/RunicGateway.cs → /oxide/plugins/RunicGateway.cs +overlay/oxide/plugins/RunicGatewayZones.cs → /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-.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. diff --git a/overlay.toml b/overlay.toml index 33a24ba..3093524 100644 --- a/overlay.toml +++ b/overlay.toml @@ -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 diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index 144aba3..3b5d6ab 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -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 { { "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 { - { "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 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 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 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 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 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). + + /// The bonus on the final hit of a tree or a node. Void: an Item return replaces the bonus. + 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); + } + + /// + /// 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. + /// + 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); + } + } + + /// A farmed plant, harvested. No return behaviour on either framework. + 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); + } + /// /// 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 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. + + /// The registered permission list as the last load or unload left it. Main thread. + private HashSet _registeredPerms; + + /// + /// Main thread. The list as it is now; the first call is the baseline. From + /// OnServerInitialized, which is also the moment _worldReady turns true. + /// + private void SeedRegisteredPerms() + { + string[] now = permission.GetPermissions(); + _registeredPerms = new HashSet(now ?? new string[0], StringComparer.OrdinalIgnoreCase); + } + + /// + /// 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 OnPluginUnloaded" 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. + /// + private void EmitPluginChange(string name, string version, bool loaded) + { + if (_registeredPerms == null) + return; + + string[] now = permission.GetPermissions() ?? new string[0]; + HashSet current = new HashSet(now, StringComparer.OrdinalIgnoreCase); + + List changed = new List(); + + 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 frame = Frame(loaded ? "plugin.loaded" : "plugin.unloaded", "event"); + frame["name"] = name; + + if (!string.IsNullOrEmpty(version)) + frame["version"] = version; + + frame["permissions"] = changed; + EmitFrame(frame); + } + + /// Main thread. Queues for somebody else's plugin, once the world is up. + 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"); + } + + /// Main thread. patched, unsupported, no-zonemanager or missing. + private string ZoneHelperState() + { + Plugin helper = ZoneHelperPlugin(); + return helper == null ? "missing" : helper.Call("State") as string ?? "unsupported"; + } + + /// + /// 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. + /// + private bool ZoneMembershipTrusted() + { + return ZoneHelperState() == "patched"; + } + + /// + /// 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). + /// + 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; + } + /// /// The map's monuments in the one order every caller agrees on: grouped by prefab short /// name, then by position. #n in a value counts within its group, so it names the @@ -7898,7 +8103,12 @@ namespace Oxide.Plugins Dictionary 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; + if (ZoneMembershipTrusted()) + { + present = zm.Call("GetPlayersInZone", t.ZoneId) as List; + } + 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(); + + 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. /// + /// + /// 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. + /// + 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; diff --git a/overlay/oxide/plugins/RunicGatewayZones.cs b/overlay/oxide/plugins/RunicGatewayZones.cs new file mode 100644 index 0000000..de90edd --- /dev/null +++ b/overlay/oxide/plugins/RunicGatewayZones.cs @@ -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"; + + /// 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; + + /// + /// 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); + _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 ──────────────────────────────────────────────────────────── + + /// + /// 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(); + + foreach (KeyValuePair 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); + } + + /// + /// 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; + + 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})" : "")}"); + } + } +}