diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 14c8eb9..6799076 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`, its helpers +# (`RunicGatewayZones.cs`, `RunicGatewayDomes.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,11 @@ # 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` for ZoneManager and `RunicGatewayDomes.cs` +# for ZoneDomes, PLAN_REDESIGNS D194) 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 +79,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 overlay/oxide/plugins/RunicGatewayDomes.cs SEED_VERSION: "0.1.0" INSTALLER_REPO: RunicGateway/installer @@ -279,6 +288,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 +316,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/'; } @@ -313,6 +338,10 @@ jobs: # requires_plugins = ["Kits", "ZoneManager"] is already a JSON array. # One line, quoted strings only; anything else fails the check below. REQUIRES="$(grep -m1 -E '^requires_plugins[[:space:]]*=' overlay.toml | sed -E 's/^[^=]*=[[:space:]]*//')" + # The RunicNPC API the bridge's npc.* commands need (runicnpc stage 4). 0 when + # absent, which a bundle reads as "carries no RunicNPC". + RUNICNPC_API="$(grep -m1 -E '^runicnpc_api[[:space:]]*=' overlay.toml | sed -E 's/[^0-9]//g')" + RUNICNPC_API="${RUNICNPC_API:-0}" [ -n "$PROTOCOL" ] || { echo "::error::could not read protocol from overlay.toml"; exit 1; } [ -n "$MIN_OXIDE" ] || { echo "::error::could not read min_oxide_version from overlay.toml"; exit 1; } [ -n "$MIN_CARBON" ] || { echo "::error::could not read min_carbon_version from overlay.toml"; exit 1; } @@ -329,7 +358,8 @@ jobs: --arg min_oxide "${MIN_OXIDE}" \ --arg min_carbon "${MIN_CARBON}" \ --argjson requires "${REQUIRES}" \ - --arg sha "$(sha256sum "${STAGE}/RunicGateway.cs" | cut -d' ' -f1)" \ + --argjson runicnpc "${RUNICNPC_API}" \ + --argjson files "${FILES}" \ '{ component: $component, version: $version, @@ -339,7 +369,8 @@ jobs: min_oxide_version: $min_oxide, min_carbon_version: $min_carbon, requires_plugins: $requires, - files: { "RunicGateway.cs": $sha } + runicnpc_api: $runicnpc, + files: $files }' > "${STAGE}/manifest.json" echo "----- manifest.json -----" cat "${STAGE}/manifest.json" diff --git a/README.md b/README.md index bdb5b7f..5ba7364 100644 --- a/README.md +++ b/README.md @@ -34,9 +34,24 @@ 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 +overlay/oxide/plugins/RunicGatewayDomes.cs → /oxide/plugins/RunicGatewayDomes.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. + +`RunicGatewayDomes.cs` is the **ZoneDomes helper** (docs/modules/rust/PLAN_REDESIGNS.md §3.2, D194). ZoneDomes' +API ends by telling the player who asked how it went, and throws when a plugin calls it with no player; and at its +own start it drops the dome of every zone that does not exist yet, which is every zone the bridge re-creates. The +helper patches the first with Harmony and tells the bridge when ZoneDomes has started, so the bridge can put its +domes back without drawing any twice. It is optional: without it the site offers no dome, and says which piece is +missing. `rgd.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 +84,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 2ba4501..55d57c2 100644 --- a/overlay.toml +++ b/overlay.toml @@ -26,19 +26,24 @@ # the same change that alters the emitters, exactly as the sidecar bumps # PROTOCOL_VERSION and the module bumps its own constant. # -# Current: 12 — the transport, the read path, identity, the permission mirror, +# Current: 13 — the transport, the read path, identity, the permission mirror, # configuration from the site, first-party clans, a raid frame that names who # lives there, the event leases, the world verbs events place and give back, and # the rewards: a run's participation tally, kit credits and chat (PROTOCOL.md §16), # the map: its picture and everything that moves on it (PROTOCOL.md §17), and the -# optional mods: BetterChat titles and group styles, and popups (PROTOCOL.md §18). -protocol = 12 +# optional mods: BetterChat titles and group styles, and popups (PROTOCOL.md §18), +# and the first player walk's fixes (PROTOCOL.md §19) — opened by a configuration +# save that answers at once and reports its reload as a `config.outcome` frame. +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 @@ -72,3 +77,16 @@ min_carbon_version = "2.0.259" # asked of a server without it is refused with a reason, and chat is the default. # BetterChat is optional in the same way — titles and group styles wait for it. requires_plugins = ["Kits", "ZoneManager"] + +# ── The RunicNPC API the bridge needs ─────────────────────────────────────── +# +# RunicNPC (docs/runicnpc/PLAN.md stages 4 to 7): the bridge's npc.* commands call +# RunicNPC's API and need at least this version of it. It MUST equal +# `RunicNpcApiNeeded` in RunicGateway.cs; `node scripts/checkPlugin.js` holds the +# two equal. The release copies it into manifest.json, and the installer's +# bundle carries the latest RunicNPC release that answers at least it (D224). +# +# RunicNPC is required since its stage 9 (D310): without it, or with one older +# than this, every npc.* command and every NPC an event places, Rust's own +# scientists included, is refused as `runicnpc-missing` or `runicnpc-old`. +runicnpc_api = 6 diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index b8595ae..4217de3 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -57,7 +57,7 @@ namespace Oxide.Plugins /// overlay's overlay.toml carries this same number so the installer can refuse /// to pair a sidecar and an overlay that disagree. /// - private const int ProtocolVersion = 12; + private const int ProtocolVersion = 13; /// /// Loopback reconnects are cheap, so the ceiling is low. A sidecar restart should cost @@ -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", @@ -243,7 +244,13 @@ namespace Oxide.Plugins "OnClanCreated", "OnClanDisbanded", "OnClanMemberAdded", "OnClanMemberLeft", "OnClanMemberKicked", "OnClanColorChanged", "OnKitRedeemed", - "OnEntitySpawned", "OnEntityKill" + "OnEntitySpawned", "OnEntityKill", + "OnEntityTakeDamage", "OnItemCraftFinished", "OnEntityBuilt", "OnStructureRepair", + "OnHealingItemUse", "OnPlayerRevive", "OnRocketLaunched", + "OnExplosiveThrown", "OnExplosiveDropped", "OnMissionSucceeded", + "OnEnterZone", "OnExitZone", "OnRgDomesReady", + "OnRunicNpcDied", "OnRunicNpcHealth", "OnRunicNpcPlacementChanged", + "OnRunicNpcBossSpawned", "OnRunicNpcBossPhase", "OnRunicNpcBossDied" }; private void Init() @@ -318,10 +325,17 @@ namespace Oxide.Plugins _permJob = null; StopPermTimer(); + // The same for an inventory being built: the site's request times out and it asks + // again from page 0, which is the only way it ever reads one. + _inventoryJob = null; + _inventory = null; + StopInventoryTimer(); + // A config write waiting on a reload dies here too, and its files stay as written. // That is the honest outcome: the rollback exists to undo an edit that took a plugin // down, and this plugin being unloaded is not evidence of either — it is usually THIS - // plugin being reloaded by somebody. The caller times out and re-reads. + // plugin being reloaded by somebody. No `config.outcome` follows; the website calls a + // write it never heard about lost once twice `ceilingMs` has passed, and re-reads. StopConfigWatch(); _configJob = null; @@ -768,10 +782,25 @@ namespace Oxide.Plugins } } + /// + /// Reader thread. Splits the stream into lines as bytes and decodes each finished + /// line as UTF-8. + /// + /// + /// Bytes, not characters, because a character can straddle two reads: the first half of + /// an em dash at the end of one buffer and the rest at the start of the next. Splitting + /// on the byte is safe where splitting the text is not — UTF-8 never uses 0x0A + /// inside a multi-byte sequence, so a newline byte is always a newline — and decoding + /// the whole line at once is what keeps the halves together. The old loop cast each byte + /// to a char, which is Latin-1, and turned every non-ASCII character the website + /// sent into two or three wrong ones: announcements, titles, kit names, and the text of + /// every configuration file saved from the site (F15). + /// + /// private void ReadLoop(NetworkStream stream, int epoch) { byte[] buffer = new byte[8192]; - StringBuilder line = new StringBuilder(512); + var line = new MemoryStream(512); bool discarding = false; try @@ -785,9 +814,9 @@ namespace Oxide.Plugins for (int i = 0; i < read; i++) { - char c = (char)buffer[i]; + byte b = buffer[i]; - if (c == '\n') + if (b == (byte)'\n') { if (discarding) { @@ -797,22 +826,22 @@ namespace Oxide.Plugins } else { - Dispatch(line.ToString()); + Dispatch(Encoding.UTF8.GetString(line.GetBuffer(), 0, (int)line.Length)); } - line.Length = 0; + line.SetLength(0); } - else if (c != '\r') + else if (b != (byte)'\r') { if (discarding) continue; - line.Append(c); + line.WriteByte(b); if (line.Length > MaxInboundLineBytes) { Puts("inbound line over the cap; discarding to the next newline"); - line.Length = 0; + line.SetLength(0); discarding = true; } } @@ -938,6 +967,12 @@ namespace Oxide.Plugins HandlePermSync(cmd, reqId); break; + case "perm.inventory": + // Protocol 13 (D160). The whole store, owners included, in pages — what the + // site reads before every sync now that it owns all of it. + HandlePermInventory(cmd, reqId); + break; + case "config.list": // Protocol 5 (R18). What settings files exist on this host, and which plugins // are loaded to reload them. A description of the tree, never its contents. @@ -1035,6 +1070,25 @@ namespace Oxide.Plugins HandleMapLive(reqId); break; + case "npc.profiles": + // Protocol 13, RunicNPC (docs/runicnpc/PLAN.md stage 4). The server's profiles, + // read before the site's first push so it can adopt them (D244). + HandleNpcProfiles(reqId); + break; + + case "npc.profiles.set": + HandleNpcProfilesSet(cmd, reqId); + break; + + case "npc.placements": + HandleNpcPlacements(reqId); + break; + + case "npc.placement": + // One change to one placement, by `op` (D245, D246). + HandleNpcPlacement(cmd, reqId); + break; + default: Puts($"unknown command '{verb}'"); break; @@ -1127,6 +1181,36 @@ 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; + + // D211: ZoneManager's own flag list, for the site's zone presets (§3.1). In hello + // rather than every status frame, because it changes only when ZoneManager does, + // and a ZoneManager load sends hello again. + var zoneManager = new Dictionary { { "version", ZoneManagerPlugin().Version.ToString() } }; + List flags = ZoneFlagNames(ZoneManagerPlugin()); + + if (flags != null) + zoneManager["flags"] = flags; + + hello["zoneManager"] = zoneManager; + } + EmitFrame(hello); } @@ -1147,7 +1231,7 @@ namespace Oxide.Plugins var row = new Dictionary { - { "steamId", player.UserIDString }, + { "steamId", SteamIdOf(player) }, { "name", player.displayName }, { "sleeping", player.IsSleeping() } }; @@ -1248,21 +1332,13 @@ namespace Oxide.Plugins /// the environment case and not an error. /// /// - private static void DescribeAttacker( + private void DescribeAttacker( Dictionary frame, BasePlayer victim, HitInfo info) { BaseEntity initiator = info != null ? info.Initiator : null; BasePlayer attacker = info != null ? info.InitiatorPlayer : null; - string weapon = null; - - if (info != null) - { - if (info.WeaponPrefab != null) - weapon = info.WeaponPrefab.ShortPrefabName; - else if (info.Weapon != null) - weapon = info.Weapon.ShortPrefabName; - } + string weapon = WeaponOf(info); if (weapon != null) frame["weapon"] = weapon; @@ -1282,25 +1358,69 @@ namespace Oxide.Plugins if (attacker != null && !attacker.IsNpc) { frame["attackerType"] = "player"; - frame["attackerId"] = attacker.UserIDString; + frame["attackerId"] = SteamIdOf(attacker); frame["attackerName"] = attacker.displayName; } else { frame["attackerType"] = "npc"; frame["attackerName"] = initiator.ShortPrefabName; + + // One of RunicNPC's: its own name, as the victim's death screen shows it, and its + // profile. Beside `attackerName`, not over it, so a reader that knows only prefab + // names still reads a scientist (runicnpc PLAN.md §1.4, stage 4). + string profile = RunicNpcProfileOf(initiator); + + if (profile != null && attacker != null) + { + frame["attackerNpc"] = attacker.displayName; + frame["attackerProfile"] = profile; + } } - float distance = info != null ? info.ProjectileDistance : 0f; - - if (distance <= 0f && initiator.transform != null && victim.transform != null) - distance = UnityEngine.Vector3.Distance( - initiator.transform.position, victim.transform.position); + float distance = DistanceOf(info, victim); if (distance > 0f) frame["distance"] = (float)Math.Round(distance, 1); } + /// + /// The killing blow's weapon as the killfeed names it: the held entity's short prefab + /// name (bow_hunting.entity), or what was thrown or placed. Null when the hit + /// carries neither. The tally's weapon kills (§5.3) read this same name, so the lists + /// the site keeps are lists of what the killfeed already shows. + /// + private static string WeaponOf(HitInfo info) + { + if (info == null) + return null; + + if (info.WeaponPrefab != null) + return info.WeaponPrefab.ShortPrefabName; + + return info.Weapon != null ? info.Weapon.ShortPrefabName : null; + } + + /// + /// How far the killing blow travelled, in metres: the projectile's own distance, or the + /// gap between the two when there was no projectile. 0 when neither can be read. + /// + private static float DistanceOf(HitInfo info, BaseEntity victim) + { + if (info == null) + return 0f; + + float distance = info.ProjectileDistance; + BaseEntity initiator = info.Initiator; + + if (distance <= 0f && initiator != null && initiator.transform != null + && victim != null && victim.transform != null) + distance = UnityEngine.Vector3.Distance( + initiator.transform.position, victim.transform.position); + + return distance; + } + // ---- the map grid ---- /// @@ -1431,6 +1551,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"); @@ -1517,7 +1638,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); } @@ -1534,7 +1655,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)) @@ -1562,7 +1683,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); } @@ -1582,10 +1703,16 @@ namespace Oxide.Plugins BasePlayer killer = info != null ? info.InitiatorPlayer : null; if (killer != null && !killer.IsNpc && (ulong)killer.userID != (ulong)player.userID) + { CreditKill(killer, true); + // The kill itself is `player.death`'s, counted by the site from this frame; the + // tally adds only what a title needs about it (§5.1). + TallyFor(killer).Killed(info, player, true); + } + Dictionary frame = Frame("player.death", "event"); - frame["steamId"] = player.UserIDString; + frame["steamId"] = SteamIdOf(player); frame["name"] = player.displayName; frame["sleeping"] = player.IsSleeping(); @@ -1609,7 +1736,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(); @@ -1634,6 +1761,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 @@ -1652,6 +1833,15 @@ namespace Oxide.Plugins return; BasePlayer attacker = info != null ? info.InitiatorPlayer : null; + + // Before the early return below: a patrol helicopter usually dies by crashing, and a + // crash's HitInfo names no player (§5.4). + if (IsVehicleKill(entity)) + { + CreditVehicle(entity, attacker); + return; + } + bool byPlayer = attacker != null && !attacker.IsNpc; // Nothing below is interesting unless a real player did it. That single test discards @@ -1667,7 +1857,14 @@ namespace Oxide.Plugins // double every kill in the feed. Only the NPC kind is ours to count. if (victimPlayer.IsNpc) { - TallyFor(attacker).NpcKills++; + Tally tally = TallyFor(attacker); + tally.NpcKills++; + tally.Killed(info, entity, false); + + string profile = RunicNpcProfileOf(victimPlayer); + + if (profile != null) + tally.KilledProfile(profile); CreditKill(attacker, false); } @@ -1676,9 +1873,29 @@ namespace Oxide.Plugins // An animal is an NPC to a run's tally exactly as it is to the leaderboard (§29.5): // two meanings of one word would be two answers to "how many NPCs did I kill". - if (entity is BaseNpc) + // `animalKills` counts it AGAIN, separately (§5.2): the public NPC column keeps its + // meaning mid-wipe, and the site takes the human NPCs as the difference. + // + // Rust's own animals are now `Rust.Ai.Gen2.BaseNPC2` — stag, boar, wolf, bears, big cats, + // crocodile, farm animals, critters, sea life — and not `BaseNpc`, which is all this + // tested until the RunicNPC 9d walk found no animal kill had counted at all. `BaseNpc` + // stays for whatever still derives from it. `ScientistNPC2` is the one person among + // them, so it is a human NPC kill, not an animal. + if (entity is Rust.Ai.Gen2.ScientistNPC2) { - TallyFor(attacker).NpcKills++; + Tally tally = TallyFor(attacker); + tally.NpcKills++; + tally.Killed(info, entity, false); + CreditKill(attacker, false); + return; + } + + if (entity is BaseNpc || entity is Rust.Ai.Gen2.BaseNPC2) + { + Tally tally = TallyFor(attacker); + tally.NpcKills++; + tally.AnimalKills++; + tally.Killed(info, entity, false); CreditKill(attacker, false); return; } @@ -1696,7 +1913,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"); @@ -1720,6 +1943,259 @@ namespace Oxide.Plugins EmitFrame(frame); } + // ---- what a chat title counts (PLAN_REDESIGNS §5, D172–D174) ---- + // + // Every one of these is a counter (rule 2) and void (rule 1). Three of them can veto — + // `OnStructureRepair`, `OnHealingItemUse`, `OnPlayerRevive` — and `OnEntityTakeDamage` + // can cancel the damage outright, which is exactly why none of them returns anything. + + /// How long a player's hit on a vehicle still earns its kill when the blow that ends it names nobody (§5.4). + private const float VehicleCreditSeconds = 60f; + + /// The last player to hurt each APC or helicopter, by net id. A handful at most. + private readonly Dictionary> _vehicleHits = + new Dictionary>(); + + /// The APC and the two helicopters: the three things a title credits a kill of (D174). + private static bool IsVehicleKill(BaseEntity entity) + { + return entity is BradleyAPC || entity is PatrolHelicopter || entity is CH47Helicopter; + } + + /// + /// Void: a non-null return cancels the damage. It fires for every hit on everything, + /// so the type test is the first thing it does; only a hit on one of the three + /// vehicles is remembered, and only who dealt it and when. + /// + private void OnEntityTakeDamage(BaseCombatEntity entity, HitInfo info) + { + MarkHook("OnEntityTakeDamage"); + + if (entity == null || info == null || !IsVehicleKill(entity) || entity.net == null) + return; + + BasePlayer player = info.InitiatorPlayer; + if (player == null || player.IsNpc) + return; + + float now = UnityEngine.Time.realtimeSinceStartup; + + // A helicopter that flies off the map is never killed, so its row is never removed + // at death; a stale one is dropped here instead. + if (_vehicleHits.Count > 16) + { + var stale = new List(); + foreach (KeyValuePair> hit in _vehicleHits) + if (now - hit.Value.Value > VehicleCreditSeconds) + stale.Add(hit.Key); + foreach (ulong id in stale) + _vehicleHits.Remove(id); + } + + _vehicleHits[entity.net.ID.Value] = new KeyValuePair(player.userID, now); + } + + /// + /// The killing blow's player (D174), or, when the blow names nobody — a helicopter + /// crashing — the last player who hurt it within . + /// + private void CreditVehicle(BaseCombatEntity entity, BasePlayer attacker) + { + KeyValuePair last = default(KeyValuePair); + bool hit = entity.net != null && _vehicleHits.TryGetValue(entity.net.ID.Value, out last); + + if (entity.net != null) + _vehicleHits.Remove(entity.net.ID.Value); + + BasePlayer credited = attacker != null && !attacker.IsNpc ? attacker : null; + + if (credited == null && hit && UnityEngine.Time.realtimeSinceStartup - last.Value <= VehicleCreditSeconds) + credited = BasePlayer.FindByID(last.Key) ?? BasePlayer.FindSleeping(last.Key); + + if (credited == null) + return; + + Tally tally = TallyFor(credited); + + if (entity is BradleyAPC) + tally.ApcKills++; + else + tally.HeliKills++; + } + + /// + /// No return behaviour. A crafted shirt, rifle or anything else, by its item category. + /// Who crafted it is the crafter's owner: the task no longer carries one (read off the + /// rig, 2026-09-29). + /// + private void OnItemCraftFinished(ItemCraftTask task, Item item, ItemCrafter crafter) + { + MarkHook("OnItemCraftFinished"); + + BasePlayer player = crafter != null ? crafter.owner : null; + + if (player == null || player.IsNpc || item == null || item.info == null) + return; + + TallyFor(player).Craft(item.info.category.ToString().ToLowerInvariant(), item.amount); + } + + /// No return behaviour. Only a building block is a structure built; a deployable is not. + private void OnEntityBuilt(Planner planner, UnityEngine.GameObject go) + { + MarkHook("OnEntityBuilt"); + + BasePlayer player = planner != null ? planner.GetOwnerPlayer() : null; + + if (player == null || player.IsNpc || go == null || go.GetComponent() == null) + return; + + TallyFor(player).Built++; + } + + /// + /// Void: a non-null return refuses the repair. It fires on every hammer hit, BEFORE the + /// game decides whether there is anything to repair, the resources to pay for it, or a + /// recent attack that forbids it. So the health is read now and again next tick, and + /// only a hit that actually raised it is a repair. + /// + private void OnStructureRepair(BaseCombatEntity entity, BasePlayer player) + { + MarkHook("OnStructureRepair"); + + if (entity == null || player == null || player.IsNpc) + return; + + float before = entity.health; + ulong id = player.userID; + + NextTick(() => + { + if (entity == null || entity.IsDestroyed || entity.health <= before) + return; + + BasePlayer still = BasePlayer.FindByID(id); + if (still != null) + TallyFor(still).Repaired++; + }); + } + + // A syringe on a downed player raises `OnHealingItemUse` and then, from inside the same + // `GiveEffectsTo`, `OnPlayerRevive`; one act of help is one heal. The last one counted is + // remembered for the frame it happened in, and the other hook skips the same pair. + private ulong _helpHealer; + private ulong _helpTarget; + private int _helpFrame = -1; + + /// Counts one act of help unless it is yourself, an NPC, or the other hook already did this frame. + private void CountHelp(BasePlayer healer, BasePlayer player) + { + if (healer == null || healer.IsNpc || player == null || player.IsNpc + || (ulong)healer.userID == (ulong)player.userID) + return; + + int frame = UnityEngine.Time.frameCount; + + if (_helpFrame == frame && _helpHealer == (ulong)healer.userID && _helpTarget == (ulong)player.userID) + return; + + _helpHealer = healer.userID; + _helpTarget = player.userID; + _helpFrame = frame; + + TallyFor(healer).Healed++; + } + + /// + /// Void: a non-null return refuses the heal. Healing yourself is not a heal (D174). + /// + /// + /// Rust's `MedicalTool.GiveEffectsTo(fromPlayer, toTarget)` raises it as + /// (tool, fromPlayer, toTarget). Until the RunicNPC 9d walk this took two + /// parameters, so the second — the healer — arrived as the target, every heal looked like + /// healing yourself, and none counted. The target is object: `IMedicalToolTarget` + /// is not always a player, and only a player healed counts. + /// + /// + private void OnHealingItemUse(MedicalTool tool, BasePlayer fromPlayer, object target) + { + MarkHook("OnHealingItemUse"); + + CountHelp(fromPlayer ?? (tool != null ? tool.GetOwnerPlayer() : null), target as BasePlayer); + } + + /// Void: a non-null return refuses the revive. Reviving yourself is not possible, and is not counted. + private void OnPlayerRevive(BasePlayer reviver, BasePlayer player) + { + MarkHook("OnPlayerRevive"); + + CountHelp(reviver, player); + } + + /// No return behaviour. One rocket leaving a launcher. + private void OnRocketLaunched(BasePlayer player, BaseEntity entity) + { + MarkHook("OnRocketLaunched"); + + if (player == null || player.IsNpc) + return; + + TallyFor(player).Rockets++; + } + + /// No return behaviour. A grenade, beancan, satchel or C4 thrown. + private void OnExplosiveThrown(BasePlayer player, BaseEntity entity, ThrownWeapon item) + { + MarkHook("OnExplosiveThrown"); + CountExplosive(player, entity); + } + + /// No return behaviour. The same, dropped at your feet rather than thrown. + private void OnExplosiveDropped(BasePlayer player, BaseEntity entity, ThrownWeapon item) + { + MarkHook("OnExplosiveDropped"); + CountExplosive(player, entity); + } + + /// + /// A supply signal and a smoke grenade are thrown through the same hooks, and neither + /// is an explosive. What is one is a timed explosive that does damage. + /// + private void CountExplosive(BasePlayer player, BaseEntity entity) + { + if (player == null || player.IsNpc) + return; + + TimedExplosive explosive = entity as TimedExplosive; + if (explosive == null || explosive is SupplySignal || explosive.damageTypes == null) + return; + + foreach (Rust.DamageTypeEntry damage in explosive.damageTypes) + { + if (damage != null && damage.amount > 0f) + { + TallyFor(player).Explosives++; + return; + } + } + } + + /// + /// No return behaviour. A mission one of the game's NPCs handed out, finished (D173). A + /// failed or abandoned one raises a different hook, and is never counted. + /// + private void OnMissionSucceeded(BaseMission mission, BaseMission.MissionInstance instance, BasePlayer assignee) + { + MarkHook("OnMissionSucceeded"); + + // The game hands the assignee over: `MissionSuccess(instance, assignee)`. The instance + // itself has no accessor for it (read off the rig, 2026-09-29). + if (assignee == null || assignee.IsNpc) + return; + + TallyFor(assignee).Missions++; + } + /// /// The most people one entity.destroyed frame names as authorised. A cupboard /// has no hard cap on vanilla, and an unbounded list is an unbounded line on the game @@ -1818,7 +2294,7 @@ namespace Oxide.Plugins if (reporter != null) { - frame["reporterId"] = reporter.UserIDString; + frame["reporterId"] = SteamIdOf(reporter); frame["reporterName"] = reporter.displayName; } @@ -2341,19 +2817,78 @@ namespace Oxide.Plugins public int Structures; public string Name; + // Protocol 13, the chat-title conditions (PLAN_REDESIGNS §5.1). Sums unless named a best. + public int AnimalKills; + public readonly Dictionary WeaponKills = new Dictionary(); + public int Headshots; + /// The longest player kill this interval, in metres: a best, not a sum. + public float BestPvpM; + /// The longest NPC or animal kill this interval: a best, not a sum. + public float BestPveM; + public int ApcKills; + public int HeliKills; + public readonly Dictionary Crafted = new Dictionary(); + + /// Stage 4 (D247): kills of RunicNPC's NPCs, by profile name. Also in . + public readonly Dictionary NpcProfileKills = new Dictionary(); + public int Built; + public int Repaired; + public int Healed; + public int Rockets; + public int Explosives; + public int Missions; + public void Gather(string shortname, int amount) { - if (string.IsNullOrEmpty(shortname) || amount <= 0) + Add(Gathered, shortname, amount); + } + + public void Craft(string category, int amount) + { + Add(Crafted, category, amount); + } + + public void KilledProfile(string profile) + { + Add(NpcProfileKills, profile, 1); + } + + /// + /// What a credited kill adds for the titles: its weapon (any victim, D174), a + /// headshot, and its distance as this interval's best of its kind. + /// + public void Killed(HitInfo info, BaseEntity victim, bool victimIsPlayer) + { + Add(WeaponKills, WeaponOf(info), 1); + + if (info != null && info.isHeadshot) + Headshots++; + + float distance = DistanceOf(info, victim); + + if (victimIsPlayer) + BestPvpM = Math.Max(BestPvpM, distance); + else + BestPveM = Math.Max(BestPveM, distance); + } + + private static void Add(Dictionary into, string key, int amount) + { + if (string.IsNullOrEmpty(key) || amount <= 0) return; int have; - Gathered.TryGetValue(shortname, out have); - Gathered[shortname] = have + amount; + into.TryGetValue(key, out have); + into[key] = have + amount; } public bool IsEmpty() { - return Gathered.Count == 0 && NpcKills == 0 && Structures == 0; + return Gathered.Count == 0 && NpcKills == 0 && Structures == 0 + && AnimalKills == 0 && WeaponKills.Count == 0 && Headshots == 0 + && BestPvpM <= 0f && BestPveM <= 0f && ApcKills == 0 && HeliKills == 0 + && Crafted.Count == 0 && Built == 0 && Repaired == 0 && Healed == 0 + && Rockets == 0 && Explosives == 0 && Missions == 0 && NpcProfileKills.Count == 0; } } @@ -2423,6 +2958,38 @@ namespace Oxide.Plugins if (tally.Structures > 0) frame["structures"] = tally.Structures; + // The title conditions (§5.1), each absent when nothing moved, as the three above. + if (tally.AnimalKills > 0) + frame["animalKills"] = tally.AnimalKills; + if (tally.WeaponKills.Count > 0) + frame["weaponKills"] = tally.WeaponKills; + if (tally.Headshots > 0) + frame["headshots"] = tally.Headshots; + if (tally.BestPvpM > 0f) + frame["bestPvpM"] = (float)Math.Round(tally.BestPvpM, 1); + if (tally.BestPveM > 0f) + frame["bestPveM"] = (float)Math.Round(tally.BestPveM, 1); + if (tally.ApcKills > 0) + frame["apcKills"] = tally.ApcKills; + if (tally.HeliKills > 0) + frame["heliKills"] = tally.HeliKills; + if (tally.Crafted.Count > 0) + frame["crafted"] = tally.Crafted; + if (tally.Built > 0) + frame["built"] = tally.Built; + if (tally.Repaired > 0) + frame["repaired"] = tally.Repaired; + if (tally.Healed > 0) + frame["healed"] = tally.Healed; + if (tally.Rockets > 0) + frame["rockets"] = tally.Rockets; + if (tally.Explosives > 0) + frame["explosives"] = tally.Explosives; + if (tally.Missions > 0) + frame["missions"] = tally.Missions; + if (tally.NpcProfileKills.Count > 0) + frame["npcProfileKills"] = tally.NpcProfileKills; + EmitFrame(frame); } @@ -2604,7 +3171,7 @@ namespace Oxide.Plugins if (player == null) return; - string steamId = player.UserIDString; + string steamId = SteamIdOf(player); if (string.IsNullOrEmpty(steamId)) return; @@ -2667,7 +3234,7 @@ namespace Oxide.Plugins if (player == null) return; - string steamId = player.UserIDString; + string steamId = SteamIdOf(player); if (string.IsNullOrEmpty(steamId)) return; @@ -2899,9 +3466,11 @@ namespace Oxide.Plugins // log (PLAN.md §12.2 rule 1) — so without the pre-check a re-push into a server missing // one plugin is a silent partial. Registering the name here instead would fabricate a // permission the operator never installed (D33). - // • A holder the site does not author is reported `foreign` and never touched (D31). - // Somebody typing `oxide.grant` during an incident is drift, not an error, and the - // website offers an operator two answers to it. + // • A holder the site does not author is never touched by a sync. Until protocol 13 the + // sync scanned the names the site claimed and reported such holders `foreign` (D31). + // Now the site owns the whole store (D160) and reads it with `perm.inventory`, so it + // finds a hand edit itself and answers it with the server's policy (D161): the sync + // only ever carries what the site decided. // • A group member who has never connected is reported `pending`. The store has no user // record to put in a group yet (§12.2 rule 4), and the membership lands on their first // connection — a direct grant would have reached them immediately, and pretending the @@ -2945,8 +3514,12 @@ namespace Oxide.Plugins RetireMember, RetireGrant, RetireGroup, - ScanPermission, - ScanGroup, + + /// + /// Protocol 13: a group's parent (PLAN_REDESIGNS §1.4). Compiled after every group, + /// so the parent it names already exists when it is applied. + /// + EnsureGroupParent, /// Protocol 12: a group's BetterChat style (D138). EnsureChatStyle, @@ -2969,6 +3542,12 @@ namespace Oxide.Plugins public string Title; public int Rank; + + /// + /// Whether the site sent a rank. A group whose frame has none keeps the rank it has, + /// rather than being set to 0 by an absent field. + /// + public bool HasRank; } /// @@ -2986,6 +3565,10 @@ namespace Oxide.Plugins public int Grants; public int Revokes; public int GroupsCreated; + + /// Protocol 13: an existing group whose title, rank or parent the sync changed. + public int GroupsUpdated; + public int GroupsRemoved; public int GroupPermissions; public int GroupPermissionsRemoved; @@ -2995,7 +3578,6 @@ namespace Oxide.Plugins public int Absent; public readonly List Unresolved = new List(); - public readonly List Foreign = new List(); public readonly List Pending = new List(); /// @@ -3006,8 +3588,8 @@ namespace Oxide.Plugins public readonly List NotLanded = new List(); /// - /// Group permissions an event lease holds (protocol 8). Neither applied to nor - /// reported foreign; what the site asked for is kept on the hold instead. + /// Group permissions an event lease holds (protocol 8). Not applied; what the site + /// asked for is kept on the hold instead. /// public readonly List Leased = new List(); @@ -3016,17 +3598,6 @@ namespace Oxide.Plugins /// Permission names already reported unresolved, so each is reported once. public readonly HashSet UnresolvedSeen = new HashSet(); - /// Who the site says should hold each managed permission, for the foreign scan. - public readonly Dictionary> DesiredHolders = - new Dictionary>(); - - /// Who the site says is in each managed group, and what it should carry. - public readonly Dictionary> DesiredMembers = - new Dictionary>(); - - public readonly Dictionary> DesiredGroupPermissions = - new Dictionary>(); - /// /// The site's kit credits for this server (protocol 10, D103), or null when the sync /// carried none — an older website, whose silence must not withdraw anybody's credit. @@ -3162,6 +3733,471 @@ namespace Oxide.Plugins EmitFrame(reply); } + // ---- protocol 13: the whole store (PLAN_REDESIGNS §1.2, D160) ---- + // + // The site owns every permission and group on this server, so it has to be able to read + // all of them — not just the names it once authored, which is all the foreign scan ever + // looked at. One verb, `perm.inventory`, answers with: + // + // • every registered permission and the plugin that REGISTERED it. The owner comes from + // `PermissionExists(name, plugin)` asked of each loaded plugin — public API on both + // frameworks, proven on both rigs — never from the name's prefix, which is how + // `zonemanager.ignoreflag.nokits` ends up filed under Kits in other tools. + // • every group with its title, rank, parent and the permissions it carries itself. + // • every Steam id that holds anything: its own permissions and its groups, except + // `default`, which every connected player is in by the framework's own rule. + // • the group permissions an event lease is holding, which are the lease's, not a hand + // edit's. + // + // Neither framework can list its users, so the holders come from asking each permission + // and each group who has it, and each of those walks every user. On a real store that is + // permissions × users, so it is built a slice per tick, like the sync drain, and then + // sent in pages that each fit the game link's 1 MiB line cap. The snapshot is taken once + // and paged from memory, so a grant made between two pages cannot tear it. + + /// How many permissions or groups one build step asks about. + private const int InventorySourcesPerStep = 25; + + /// How many bytes of rows one page may carry, well inside the 1 MiB line cap. + private const int InventoryPageBudgetBytes = 700 * 1024; + + /// + /// How long a build may run before it is abandoned. The sidecar waits ten seconds for a + /// reply; a build past this has already lost that wait, and a store that large needs a + /// person to hear about it rather than a hitch every sync. + /// + private const long InventoryMaxBuildMs = 8000; + + /// How long a finished snapshot is kept for the pages after the first. + private const long InventoryKeepMs = 2 * 60 * 1000; + + private sealed class InventoryUser + { + public string SteamId; + public string Name; + public readonly List Permissions = new List(); + public readonly List Groups = new List(); + } + + /// A build in flight. There is at most one; a second request is answered `busy`. + private sealed class InventoryJob + { + public string ReqId; + public long StartedMs; + + /// Started from the server console (rg.inventory): logged, never sent. + public bool FromConsole; + + /// Permissions first, then groups prefixed with a NUL, one store question each. + public readonly List Sources = new List(); + + public int Next; + public readonly Dictionary Users = new Dictionary(); + public List Permissions; + public List Groups; + public List Leased; + } + + /// A finished snapshot, paged from memory. + private sealed class Inventory + { + public string Id; + public long BuiltMs; + public long BuildMs; + public List Permissions; + public List Groups; + public List Leased; + public int UserCount; + public readonly List> Pages = new List>(); + } + + private InventoryJob _inventoryJob; + private Timer _inventoryTimer; + private Inventory _inventory; + + /// The last inventory built, for rg.perms. Diagnostics only. + private string _lastInventorySummary = "none yet"; + + /// + /// The same build from the server console, logged rather than sent: how a rig proves the + /// read — and times it on a large store — without the website or the sidecar's token. + /// + [ConsoleCommand("rg.inventory")] + private void CmdInventory(ConsoleSystem.Arg arg) + { + if (arg.Connection != null) + return; + + if (_inventoryJob != null) + { + arg.ReplyWith("an inventory is already being built"); + return; + } + + StartInventory(null, true); + arg.ReplyWith("inventory started; the summary is logged"); + } + + /// Main thread. Page 0 starts a build; any other page is read from the snapshot it names. + private void HandlePermInventory(Dictionary cmd, string reqId) + { + string snapshotId = StringField(cmd, "snapshotId"); + int page = IntField(cmd, "page"); + + if (!string.IsNullOrEmpty(snapshotId)) + { + Inventory held = _inventory; + + if (held == null || held.Id != snapshotId || NowMs() - held.BuiltMs > InventoryKeepMs || + page < 0 || page >= held.Pages.Count) + { + // The site starts again from page 0; a stale page is never a partial answer. + EmitPermError(reqId, "stale"); + return; + } + + EmitInventoryPage(held, page, reqId); + return; + } + + if (_inventoryJob != null) + { + EmitPermError(reqId, "busy"); + return; + } + + StartInventory(reqId, false); + } + + /// Main thread. Reads the small parts at once and starts the sliced holder walk. + private void StartInventory(string reqId, bool fromConsole) + { + var job = new InventoryJob { ReqId = reqId, StartedMs = NowMs(), FromConsole = fromConsole }; + + // Owners, groups and leases are small and read at once; the holder walk is sliced. + var owner = new Dictionary(StringComparer.OrdinalIgnoreCase); + string[] registered = permission.GetPermissions() ?? new string[0]; + + foreach (Plugin plugin in plugins.GetAll()) + { + if (plugin == null) + continue; + + foreach (string perm in registered) + { + if (!string.IsNullOrEmpty(perm) && !owner.ContainsKey(perm) && permission.PermissionExists(perm, plugin)) + owner[perm] = plugin.Name; + } + } + + job.Permissions = new List(); + + foreach (string perm in registered) + { + if (string.IsNullOrEmpty(perm)) + continue; + + string lowered = Lower(perm); + var row = new Dictionary { { "name", lowered } }; + string by; + + // A name no plugin owns — Carbon's built-in modules register theirs that way — is + // sent without one, and the site groups it by prefix. + if (owner.TryGetValue(perm, out by)) + row["owner"] = by; + + job.Permissions.Add(row); + job.Sources.Add(lowered); + } + + job.Groups = new List(); + + foreach (string name in permission.GetGroups() ?? new string[0]) + { + if (string.IsNullOrEmpty(name)) + continue; + + string lowered = Lower(name); + var carried = new List(); + + foreach (string perm in permission.GetGroupPermissions(name, false) ?? new string[0]) + { + if (!string.IsNullOrEmpty(perm)) + carried.Add(Lower(perm)); + } + + job.Groups.Add(new Dictionary + { + { "name", lowered }, + { "title", permission.GetGroupTitle(name) ?? "" }, + { "rank", permission.GetGroupRank(name) }, + { "parent", Lower(permission.GetGroupParent(name)) ?? "" }, + { "permissions", carried } + }); + + // `default` holds every player who ever connected, by the framework's rule rather + // than anybody's decision, so its members are not the site's to own. + if (!string.Equals(lowered, "default", StringComparison.Ordinal)) + job.Sources.Add("\0" + lowered); + } + + job.Leased = new List(); + + foreach (LeaseHold hold in _leases.Values) + { + if (hold.Key != GroupPermissionKey || string.IsNullOrEmpty(hold.Target)) + continue; + + int slash = hold.Target.IndexOf('/'); + + if (slash <= 0) + continue; + + job.Leased.Add(new Dictionary + { + { "kind", "group-permission" }, + { "subject", Lower(hold.Target.Substring(0, slash)) }, + { "object", Lower(hold.Target.Substring(slash + 1)) } + }); + } + + _inventoryJob = job; + _inventoryTimer = timer.Every(PermStepSeconds, StepInventory); + + // A small store — every rig, and most real servers — finishes inside this call. + StepInventory(); + } + + /// Main thread. Asks up to permissions or groups who holds them. + private void StepInventory() + { + InventoryJob job = _inventoryJob; + + if (job == null) + { + StopInventoryTimer(); + return; + } + + if (NowMs() - job.StartedMs > InventoryMaxBuildMs) + { + _inventoryJob = null; + StopInventoryTimer(); + Puts($"permission inventory abandoned after {InventoryMaxBuildMs} ms at {job.Next} of {job.Sources.Count} sources, {job.Users.Count} users"); + + if (!job.FromConsole) + EmitPermError(job.ReqId, "too-large"); + + return; + } + + int budget = InventorySourcesPerStep; + + while (budget-- > 0 && job.Next < job.Sources.Count) + { + string source = job.Sources[job.Next++]; + + try + { + bool isGroup = source.Length > 0 && source[0] == '\0'; + string name = isGroup ? source.Substring(1) : source; + string[] entries = isGroup ? permission.GetUsersInGroup(name) : permission.GetPermissionUsers(name); + + if (entries == null) + continue; + + foreach (string entry in entries) + { + string id = UserId(entry); + + if (string.IsNullOrEmpty(id)) + continue; + + InventoryUser user; + + if (!job.Users.TryGetValue(id, out user)) + { + user = new InventoryUser { SteamId = id, Name = StoredName(entry) }; + job.Users[id] = user; + } + + if (isGroup) + user.Groups.Add(name); + else + user.Permissions.Add(name); + } + } + catch (Exception ex) + { + // One odd name must not lose the whole read; the next sync reads it again. + Puts($"permission inventory: '{source.TrimStart('\0')}' threw: {Flatten(ex.Message)}"); + } + } + + if (job.Next < job.Sources.Count) + return; + + _inventoryJob = null; + StopInventoryTimer(); + + Inventory done = Paginate(job); + _inventory = done; + _lastInventorySummary = $"{done.Id}: {done.Permissions.Count} permissions, {done.Groups.Count} groups, " + + $"{done.UserCount} users, {done.Pages.Count} pages, {done.BuildMs} ms at={DateTime.UtcNow:HH:mm:ss}Z"; + + if (job.FromConsole) + { + // To the log, where a rig walk can read it: the console's own reply never reaches + // Oxide's log file. + Puts($"inventory {_lastInventorySummary}"); + + var owners = new Dictionary(); + + foreach (Dictionary row in done.Permissions) + { + object by; + string key = row.TryGetValue("owner", out by) ? (string)by : "(none)"; + int n; + owners.TryGetValue(key, out n); + owners[key] = n + 1; + } + + var ownerText = new List(); + + foreach (KeyValuePair kv in owners) + ownerText.Add(kv.Key + "=" + kv.Value); + + List sample = done.Pages[0].GetRange(0, Math.Min(10, done.Pages[0].Count)); + + Puts("inventory owners: " + string.Join(", ", ownerText.ToArray())); + Puts("inventory groups: " + JsonConvert.SerializeObject(done.Groups, Formatting.None)); + Puts("inventory leased: " + JsonConvert.SerializeObject(done.Leased, Formatting.None)); + Puts("inventory users: " + JsonConvert.SerializeObject(sample, Formatting.None)); + return; + } + + EmitInventoryPage(done, 0, job.ReqId); + } + + private void StopInventoryTimer() + { + if (_inventoryTimer == null) + return; + + _inventoryTimer.Destroy(); + _inventoryTimer = null; + } + + /// + /// Splits the users into pages by their serialised size. Page 0 also carries the + /// permissions, groups and leases, so its budget is what they leave. + /// + private Inventory Paginate(InventoryJob job) + { + var inv = new Inventory + { + Id = Guid.NewGuid().ToString("N").Substring(0, 12), + BuiltMs = NowMs(), + BuildMs = NowMs() - job.StartedMs, + Permissions = job.Permissions, + Groups = job.Groups, + Leased = job.Leased, + UserCount = job.Users.Count + }; + + int header = Encoding.UTF8.GetByteCount(JsonConvert.SerializeObject(new Dictionary + { + { "permissions", job.Permissions }, + { "groups", job.Groups }, + { "leased", job.Leased } + }, Formatting.None)); + + var ids = new List(job.Users.Keys); + ids.Sort(StringComparer.Ordinal); + + var current = new List(); + int bytes = header; + + foreach (string id in ids) + { + InventoryUser u = job.Users[id]; + var row = new Dictionary + { + { "steamId", u.SteamId }, + { "permissions", u.Permissions }, + { "groups", u.Groups } + }; + + if (!string.IsNullOrEmpty(u.Name)) + row["name"] = u.Name; + + int size = Encoding.UTF8.GetByteCount(JsonConvert.SerializeObject(row, Formatting.None)) + 1; + + if (current.Count > 0 && bytes + size > InventoryPageBudgetBytes) + { + inv.Pages.Add(current); + current = new List(); + bytes = 0; + } + + current.Add(row); + bytes += size; + } + + // Always at least one page, so an empty store still answers page 0. + inv.Pages.Add(current); + return inv; + } + + private void EmitInventoryPage(Inventory inv, int page, string reqId) + { + Dictionary reply = Frame("perm.inventory", "reply"); + reply["snapshotId"] = inv.Id; + reply["page"] = page; + reply["pages"] = inv.Pages.Count; + reply["more"] = page + 1 < inv.Pages.Count; + reply["users"] = inv.Pages[page]; + + if (page == 0) + { + reply["permissions"] = inv.Permissions; + reply["groups"] = inv.Groups; + reply["leased"] = inv.Leased; + reply["stats"] = new Dictionary + { + { "permissions", inv.Permissions.Count }, + { "groups", inv.Groups.Count }, + { "users", inv.UserCount }, + { "buildMs", inv.BuildMs } + }; + } + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + /// + /// The name out of a store listing entry — 76561198…(Wanderer) or + /// 76561198… (Wanderer) — or null. Between the FIRST bracket and the LAST, so a + /// name with brackets of its own survives. + /// + private static string StoredName(string entry) + { + if (string.IsNullOrEmpty(entry)) + return null; + + int open = entry.IndexOf('('); + int close = entry.LastIndexOf(')'); + + if (open < 0 || close <= open + 1) + return null; + + string name = entry.Substring(open + 1, close - open - 1).Trim(); + return name.Length > 0 && name != "Unnamed" ? name : null; + } + /// /// Main thread. Compiles a desired set into operations and starts draining it. /// @@ -3194,21 +4230,39 @@ namespace Oxide.Plugins // Order matters and it is not cosmetic: a group has to exist before anything can be // put in it, and a permission has to be on the group before a member inherits it. + // Protocol 13: parents are applied after every group exists, because a group may name + // a parent that comes later in the frame. + var parentOps = new List(); + foreach (Dictionary group in Rows(cmd, "groups")) { string name = Lower(StringField(group, "name")); if (string.IsNullOrEmpty(name)) continue; + // Protocol 13: an absent title or rank means "leave it" — the site holds an + // in-game change to them while a person decides (D161's `adopt`) — and an + // empty title is a title, sent verbatim, not a reason to use the name. job.Ops.Add(new PermOp { Kind = PermOpKind.EnsureGroup, Subject = name, - Title = StringField(group, "title") ?? name, - Rank = IntField(group, "rank") + Title = group.ContainsKey("title") ? (StringField(group, "title") ?? "") : null, + Rank = IntField(group, "rank"), + HasRank = group.ContainsKey("rank") }); - HashSet wanted = Bucket(job.DesiredGroupPermissions, name); + // Only a frame that says something about the parent changes it: "" clears it, and + // an absent key leaves whatever the group has. + if (group.ContainsKey("parent")) + { + parentOps.Add(new PermOp + { + Kind = PermOpKind.EnsureGroupParent, + Subject = name, + Object = Lower(StringField(group, "parent")) ?? "" + }); + } foreach (string perm in Strings(group, "permissions")) { @@ -3216,7 +4270,6 @@ namespace Oxide.Plugins if (string.IsNullOrEmpty(lowered)) continue; - wanted.Add(lowered); job.Ops.Add(new PermOp { Kind = PermOpKind.EnsureGroupPermission, @@ -3230,7 +4283,6 @@ namespace Oxide.Plugins if (string.IsNullOrEmpty(member)) continue; - Bucket(job.DesiredMembers, name).Add(member); job.Ops.Add(new PermOp { Kind = PermOpKind.EnsureMember, @@ -3239,10 +4291,6 @@ namespace Oxide.Plugins }); } - // A group with no members still has to be scanned: the interesting case is a - // member the site did not put there. - Bucket(job.DesiredMembers, name); - // Protocol 12: the group's BetterChat style. After the group's own ops, so the // Oxide group BetterChat's members are read from exists first. With BetterChat // absent nothing is compiled, and the report says why (§33.2). @@ -3270,7 +4318,6 @@ namespace Oxide.Plugins if (string.IsNullOrEmpty(lowered)) continue; - Bucket(job.DesiredHolders, lowered).Add(steamId); job.Ops.Add(new PermOp { Kind = PermOpKind.EnsureGrant, @@ -3280,6 +4327,10 @@ namespace Oxide.Plugins } } + // Parents after every group exists and before any retirement, so a group the site is + // about to remove is no longer anybody's parent when it goes. + job.Ops.AddRange(parentOps); + // ── What the site has withdrawn ──────────────────────────────────── // // The site remembers what it put here, so a grant it has since removed arrives as an @@ -3319,24 +4370,8 @@ namespace Oxide.Plugins } } - // ── The namespace the site claims ────────────────────────────────── - // - // Foreign holders can only be found for names the site says it manages. A permission it - // does not claim is somebody else's business entirely and is never enumerated — which - // also bounds this scan by the site's own set rather than by the size of the store. - - foreach (string perm in Strings(cmd, "managed")) - { - string lowered = Lower(perm); - if (string.IsNullOrEmpty(lowered)) - continue; - - Bucket(job.DesiredHolders, lowered); - job.Ops.Add(new PermOp { Kind = PermOpKind.ScanPermission, Subject = lowered }); - } - - foreach (string group in job.DesiredMembers.Keys) - job.Ops.Add(new PermOp { Kind = PermOpKind.ScanGroup, Subject = group }); + // Protocol 13 dropped the `managed` list and the foreign scan it drove: the site reads + // the whole store with `perm.inventory` and decides for itself (D160, D161). // ── The kit credits (protocol 10) ────────────────────────────────── // @@ -3453,15 +4488,54 @@ namespace Oxide.Plugins switch (op.Kind) { case PermOpKind.EnsureGroup: - if (permission.GroupExists(op.Subject)) + if (!permission.GroupExists(op.Subject)) + { + permission.CreateGroup(op.Subject, op.Title ?? op.Subject, op.Rank); + job.GroupsCreated++; + break; + } + + // Protocol 13: the site owns an existing group's title and rank too (D160), so + // a difference is written rather than left. Until now a group's title was only + // ever set when the sync created it. + bool updated = false; + + if (op.Title != null && !string.Equals(permission.GetGroupTitle(op.Subject) ?? "", op.Title, StringComparison.Ordinal)) + { + permission.SetGroupTitle(op.Subject, op.Title); + updated = true; + } + + if (op.HasRank && permission.GetGroupRank(op.Subject) != op.Rank) + { + permission.SetGroupRank(op.Subject, op.Rank); + updated = true; + } + + if (updated) + job.GroupsUpdated++; + else + job.AlreadyCorrect++; + + break; + + case PermOpKind.EnsureGroupParent: + string currentParent = Lower(permission.GetGroupParent(op.Subject)) ?? ""; + + if (string.Equals(currentParent, op.Object, StringComparison.Ordinal)) { job.AlreadyCorrect++; + break; } + + // Both frameworks refuse a parent that does not exist or that would make a + // loop, and say so only by returning false. The read-back is what counts. + permission.SetGroupParent(op.Subject, string.IsNullOrEmpty(op.Object) ? null : op.Object); + + if (string.Equals(Lower(permission.GetGroupParent(op.Subject)) ?? "", op.Object, StringComparison.Ordinal)) + job.GroupsUpdated++; else - { - permission.CreateGroup(op.Subject, op.Title, op.Rank); - job.GroupsCreated++; - } + job.NotLanded.Add(op.Subject + ":parent:" + op.Object); break; @@ -3588,14 +4662,6 @@ namespace Oxide.Plugins break; - case PermOpKind.ScanPermission: - ScanPermission(job, op.Subject); - break; - - case PermOpKind.ScanGroup: - ScanGroup(job, op.Subject); - break; - case PermOpKind.EnsureChatStyle: ApplyChatStyle(job, op.Subject); break; @@ -3606,105 +4672,6 @@ namespace Oxide.Plugins } } - /// - /// Everybody holding a managed permission DIRECTLY, minus everybody the site says - /// should — the rest is a hand edit, and it is reported rather than undone. - /// - private void ScanPermission(PermSyncJob job, string perm) - { - if (!permission.PermissionExists(perm)) - return; - - HashSet desired; - if (!job.DesiredHolders.TryGetValue(perm, out desired)) - desired = new HashSet(); - - string[] holders = permission.GetPermissionUsers(perm); - - if (holders == null) - return; - - foreach (string entry in holders) - { - string id = UserId(entry); - - if (string.IsNullOrEmpty(id) || desired.Contains(id)) - continue; - - job.Foreign.Add(new Dictionary - { - { "kind", "grant" }, - { "subject", id }, - { "object", perm } - }); - } - } - - /// - /// The same question about a managed group, asked twice: who is in it that the site did - /// not put there, and what does it carry that the site did not put on it. - /// - private void ScanGroup(PermSyncJob job, string group) - { - if (!permission.GroupExists(group)) - return; - - HashSet members; - if (!job.DesiredMembers.TryGetValue(group, out members)) - members = new HashSet(); - - string[] present = permission.GetUsersInGroup(group); - - if (present != null) - { - foreach (string entry in present) - { - string id = UserId(entry); - - if (string.IsNullOrEmpty(id) || members.Contains(id)) - continue; - - job.Foreign.Add(new Dictionary - { - { "kind", "member" }, - { "subject", id }, - { "object", group } - }); - } - } - - HashSet wanted; - if (!job.DesiredGroupPermissions.TryGetValue(group, out wanted)) - wanted = new HashSet(); - - string[] carried = permission.GetGroupPermissions(group, false); - - if (carried == null) - return; - - foreach (string perm in carried) - { - string lowered = Lower(perm); - - if (string.IsNullOrEmpty(lowered) || wanted.Contains(lowered)) - continue; - - // A pair an event has borrowed is not somebody's console grant (§27.3). - if (LeasedGroupPermission(group, lowered) != null) - { - NoteLeased(job, group, lowered); - continue; - } - - job.Foreign.Add(new Dictionary - { - { "kind", "group-permission" }, - { "subject", group }, - { "object", lowered } - }); - } - } - /// /// A sync op about a pair an event lease holds. The store is left to the lease; what /// the site wants is recorded on the hold and becomes the value the lease gives back @@ -3822,6 +4789,7 @@ namespace Oxide.Plugins { "grants", job.Grants }, { "revokes", job.Revokes }, { "groupsCreated", job.GroupsCreated }, + { "groupsUpdated", job.GroupsUpdated }, { "groupsRemoved", job.GroupsRemoved }, { "groupPermissions", job.GroupPermissions }, { "groupPermissionsRemoved", job.GroupPermissionsRemoved }, @@ -3834,7 +4802,6 @@ namespace Oxide.Plugins report["alreadyCorrect"] = job.AlreadyCorrect; report["absent"] = job.Absent; report["unresolved"] = job.Unresolved; - report["foreign"] = job.Foreign; report["leased"] = job.Leased; report["pending"] = job.Pending; report["notLanded"] = job.NotLanded; @@ -3875,8 +4842,8 @@ namespace Oxide.Plugins _lastPermSummary = $"ops={job.Ops.Count} grants={job.Grants} revokes={job.Revokes} " + - $"groups={job.GroupsCreated} members={job.Members} same={job.AlreadyCorrect} " + - $"unresolved={job.Unresolved.Count} foreign={job.Foreign.Count} " + + $"groups={job.GroupsCreated}+{job.GroupsUpdated} members={job.Members} same={job.AlreadyCorrect} " + + $"unresolved={job.Unresolved.Count} " + $"pending={job.Pending.Count} notLanded={job.NotLanded.Count} " + $"chat={(job.ChatLoaded ? $"applied {job.ChatApplied} saved {job.ChatSaved} drift {job.ChatDrift.Count} removed {job.ChatRemoved.Count} failed {job.ChatFailed.Count}" : "absent")} " + $"at={DateTime.UtcNow:HH:mm:ss}Z"; @@ -3899,8 +4866,8 @@ namespace Oxide.Plugins // ---- drift, as it happens ---- // // Both frameworks raise a hook for every permission write, ours included. They are NOT the - // drift record — the sync report is, because only the site's desired set can say whether a - // grant is foreign. What they are is a reason to reconcile SOON: an operator typing + // drift record — the inventory is (protocol 13), because only the site's own record can say + // whether a grant is a hand edit. What they are is a reason to reconcile SOON: an operator typing // `oxide.grant` at the console shows up on the website in seconds instead of at the next // scheduled audit, and the authoritative answer still comes from a sync. // @@ -3949,19 +4916,123 @@ 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); + } + + private bool IsZonePlugin(Plugin plugin) + { + if (plugin == null || plugin == this) + return false; + + string name = plugin.Name; + return name == "ZoneManager" || name == "ZoneDomes" || name == "RunicGatewayZones" || name == "RunicGatewayDomes"; + } + + /// RunicNPC's presence and API are in hello, so its load and unload say it again (stage 4). + private bool IsRunicNpc(Plugin plugin) + { + return plugin != null && plugin != this && plugin.Name == "RunicNPC"; + } + + /// Main thread. Hello again, once the world is up and the link is. + private void ResendHello() + { + if (_worldReady && _connected) + SendHello(); + } + + /// 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). if (plugin != null && plugin != this && plugin.Name == "ZoneManager") NextTick(() => RecreateZones("ZoneManager reloaded")); + // Hello carries ZoneManager's flag list and both helpers' states, so a change to any + // of the four is said again rather than waiting for the next connect (§3.1, §3.2). + if (IsZonePlugin(plugin) || IsRunicNpc(plugin)) + NextTick(ResendHello); + // BetterChat keeps third-party titles on its INSTANCE, so a reloaded BetterChat has // forgotten our getter. Registering again is the only way back (§33.1). if (plugin != null && plugin != this && plugin.Name == "BetterChat") @@ -3979,10 +5050,14 @@ namespace Oxide.Plugins private void OnPluginUnloaded(Plugin plugin) { MarkHook("OnPluginUnloaded"); + NotePluginChange(plugin, false); if (plugin != null && plugin.Name == "BetterChat") _titlesRegistered = false; + if (IsZonePlugin(plugin) || IsRunicNpc(plugin)) + NextTick(ResendHello); + ConfigWriteJob job = _configJob; if (job == null || plugin == null) @@ -4072,23 +5147,90 @@ namespace Oxide.Plugins private const int ConfigMaxWriteFiles = 10; /// - /// How long a reload has to produce OnPluginLoaded before the write is undone. + /// The longest a reload may take before this plugin stops waiting for it (D178). /// /// - /// It is under half the sidecar's RPC timeout, and that is arithmetic rather than - /// taste. The worst path through one write is two of these windows — wait for the - /// edit's reload, give up, restore, wait for the restore's reload — with a caller - /// holding a socket for the whole of it (REPLY_TIMEOUT, 10s, rust-link - /// sidecar/src/rpc.rs). A window that does not leave room for both makes the - /// rollback report arrive after the only thing waiting to hear it has given up, which - /// is the one outcome worse than no rollback: the plugin is fine and nobody knows. + /// A ceiling, not the test. Protocol 12 rolled back any reload that had not + /// announced itself within four seconds, because the caller held a socket for the whole + /// wait. That window lost to work whose length nothing here controls: the org lead's + /// first walk saw Oxide's compiler idle out, restart, and load Kits 2.85 s later — just + /// past the window — and the plugin put a valid edit back (F9). Protocol 13 answers the + /// save at once and sends the outcome as a frame, so the wait no longer has to fit + /// inside an RPC, and a reload fails on evidence: the framework's log saying the + /// plugin did not compile or initialise (). This number + /// only bounds a reload that says nothing at all. + /// + /// + /// + /// Each phase gets it once — the edit's reload, and the restore's if there is one — and + /// the pending reply carries it as ceilingMs, so the website knows when a write it + /// never heard back about is lost rather than slow. /// /// - private const float ConfigReloadWindowSeconds = 4f; + private const float ConfigReloadCeilingSeconds = 30f; - /// How much of the newest log file is read back when a reload does not arrive. + /// How often a pending reload reads the log for its failure. + private const float ConfigLogPollSeconds = 1f; + + /// The most one poll reads from the log. A server spamming it cannot make a tick expensive. + private const int ConfigLogPollMaxBytes = 64 * 1024; + + /// How much of the newest log file is read back when a reload fails. private const int ConfigLogTailBytes = 8 * 1024; + /// + /// What a failed load says in the log, beside the plugin's name. + /// + /// + /// Lower case, matched case-insensitively. Oxide's are its compiler's and its loader's + /// own words ("Error while compiling", "Failed to initialize plugin", and "Could not + /// initialize plugin 'Kits v4.4.9' (JsonReaderException: …)" when a plugin's own + /// LoadConfig throws, seen on the rig in configuration step 4). The last one + /// carries the exception, so it is the line an operator needs; Oxide follows it with + /// "Previous version of plugin failed to load", which says nothing about why. Carbon's + /// differ, and the rest of the list is there so that the same file works on both (R19); + /// the Carbon pass of the re-walk is what pins them. The first pinned is Carbon + /// 2.0.259's "[Kits] Failed ILoadConfig (Could not convert string to double: abc. …)" — + /// after which Carbon loads the plugin anyway, broken, and raises Loaded. + /// + /// + private static readonly string[] ConfigFailureWords = + { + "error while compiling", + "could not initialize", + "failed to initialize", + "failed initializing", + "failed to compile", + "failed compiling", + "failed to load", + "failed loading", + "failed iloadconfig", + "unable to load", + }; + + /// + /// What the framework says when it gave up on a compile, beside the plugin's name. + /// + /// + /// Not a failure of the file: Oxide shuts its compiler down after a minute idle, and a + /// reload asked for in that moment is dropped with "Timed out waiting for plugin to be + /// compiled: ZoneManager" while the plugin carries on, loaded, on its old file (seen on + /// the rig, configuration step 2b). The compiler starts again on demand, so the reload + /// is asked for once more; only a second give-up settles the job. + /// + /// + private static readonly string[] ConfigStallWords = + { + "timed out waiting for plugin to be compiled", + }; + + /// Which reload a job is waiting on: the edit's, or the restore's after a rollback. + private enum ConfigPhase + { + Edit, + Restore + } + /// One file, resolved and checked, on its way to disk. private sealed class StagedConfigWrite { @@ -4113,13 +5255,50 @@ namespace Oxide.Plugins /// private sealed class ConfigWriteJob { - public string ReqId; + /// + /// Ours, not the sidecar's reqId. The reply and the later + /// config.outcome both carry it, and the website files the outcome by it — + /// the sidecar's request ids restart with the sidecar, so they cannot name a write + /// across one. + /// + public string WriteId; + public string Reload; public List Staged; + + /// The one-second poll that reads the log and enforces the ceiling. public Timer Watch; - /// Whether the target was seen leaving. It separates "never started" from "did not come back". + public ConfigPhase Phase; + + /// When the current phase's reload was asked for. Each phase gets the whole ceiling. + public DateTime PhaseStarted; + + /// When the write landed, for the outcome's elapsedMs. + public DateTime Started; + + /// Whether the target was seen leaving in this phase. It separates "never started" from "did not come back". public bool Unloaded; + + /// Whether this phase's reload was already asked for again after the framework gave up compiling. + public bool Retried; + + /// When OnPluginLoaded named the target in this phase, or null. The next log read settles it. + public DateTime? LoadedAt; + + /// The log file being read, and how far into it this job has read. + public string LogPath; + + public long LogOffset; + + /// A line cut off at the end of the last read, kept until the rest arrives. + public string LogPartial = ""; + + /// Why the edit was rolled back, carried into the outcome once the restore settles. + public string RollbackReason; + + /// The log lines that showed the failure, or the tail read when the ceiling passed. + public string RollbackLog; } private ConfigWriteJob _configJob; @@ -4530,68 +5709,254 @@ namespace Oxide.Plugins return; } - _configJob = new ConfigWriteJob + var job = new ConfigWriteJob { - ReqId = reqId, + WriteId = Guid.NewGuid().ToString("N"), Reload = reload, - Staged = staged + Staged = staged, + Phase = ConfigPhase.Edit, + Started = DateTime.UtcNow, + PhaseStarted = DateTime.UtcNow }; - _configJob.Watch = timer.Once(ConfigReloadWindowSeconds, ConfigReloadTimedOut); + // Where the log is NOW, before the reload writes anything to it. Only lines after this + // point can be about this reload. + MarkLogPosition(job); + + _configJob = job; if (!ReloadPluginByName(reload)) { - StopConfigWatch(); _configJob = null; RestoreConfig(staged); EmitConfigError(reqId, "reload-failed", $"this framework refused to reload '{reload}'"); + return; } + + // The reload was asked for and has not finished; it may be seconds away behind a cold + // compile. Answer now — the caller is holding a socket with a ten-second budget — and + // send the outcome as a frame when there is one (F9, D177). + job.Watch = timer.Every(ConfigLogPollSeconds, ConfigWatchTick); + SendConfigReport(reqId, staged, false, false, null, null, job); } - /// Main thread. The reload arrived: the plugin is back, so the write stands. + /// + /// Main thread. OnPluginLoaded named the target. It is noted, not settled: the + /// next log read settles it (W5). + /// + /// + /// "Loaded" is not "loaded well" on Carbon. A config its LoadConfig cannot read + /// is logged as a failure and the plugin is loaded anyway, broken, in the same instant + /// (seen on the rig, configuration step 4). Settling on the hook reported that edit as + /// applied; reading the log first rolls it back, for about a second's wait. + /// + /// private void ConfigReloadArrived() { ConfigWriteJob job = _configJob; - if (job == null) + if (job == null || job.LoadedAt.HasValue) return; - StopConfigWatch(); - _configJob = null; - - SendConfigReport(job.ReqId, job.Staged, true, false, null, null); + job.LoadedAt = DateTime.UtcNow; } /// - /// Main thread. The window closed with no OnPluginLoaded, which means the plugin - /// is down and the edit is why. Put every file back, reload again, and say so. + /// Main thread. A load that a full log read after it found no failure: in the edit + /// phase the write stands; in the restore phase the plugin is back on its old file. + /// + private void ConfigLoadConfirmed(ConfigWriteJob job) + { + if (job.Phase == ConfigPhase.Edit) + SettleConfigJob(job, true, false, null, null, null); + else + SettleConfigJob(job, false, true, true, job.RollbackReason, job.RollbackLog); + } + + /// + /// Main thread, once a second while a reload is pending. Reads what the framework has + /// logged since the last tick, and enforces the ceiling. /// /// - /// This is the feature's real content. Without it, R18 is a web form that takes a - /// required plugin off a production server one typo at a time. + /// A failure is a line, not a silence (D178). Both frameworks log the moment a + /// plugin fails to compile or to initialise, so a broken edit is rolled back about a + /// second after it breaks, and a slow compile — which logs nothing until it succeeds — + /// is simply waited for. The ceiling is for a reload that says nothing either way. + /// + /// + /// + /// What the ceiling does depends on what is loaded, not on the clock (F10). + /// Protocol 12 restored the files whenever the window closed, even over a plugin that + /// was about to finish loading the edit, and then reported "did not come back" about a + /// plugin that did. Now: a target that is loaded again was reloaded and its announcement + /// missed, so the edit stands; one that is loaded and was never seen unloading was never + /// reloaded at all, so the file stands and applies at its next load; only a target that + /// is not loaded has its files put back. /// /// - private void ConfigReloadTimedOut() + private void ConfigWatchTick() { ConfigWriteJob job = _configJob; if (job == null) + { + return; + } + + string failure = ReadConfigFailure(job); + + if (failure != null && IsCompileStallOf(failure, job.Reload)) + { + ConfigCompileStalled(job, failure); + return; + } + + if (failure != null) + { + if (job.Phase == ConfigPhase.Edit) + { + RollBackConfig(job, $"'{job.Reload}' failed to load: {ShortLine(failure)}", failure); + } + else + { + SettleConfigJob(job, false, true, false, + $"{job.RollbackReason}; the restored file did not load either: {ShortLine(failure)}", + job.RollbackLog + "\n" + failure); + } + + return; + } + + // A load is trusted once a read that began at least a poll after it found nothing: + // the failure it would have to beat is logged before the load, never after. + if (job.LoadedAt.HasValue && + (DateTime.UtcNow - job.LoadedAt.Value).TotalSeconds >= ConfigLogPollSeconds) + { + ConfigLoadConfirmed(job); + return; + } + + if ((DateTime.UtcNow - job.PhaseStarted).TotalSeconds < ConfigReloadCeilingSeconds) return; - _configJob = null; - StopConfigWatch(); + SettleByWhatIsLoaded(job, $"within {ConfigReloadCeilingSeconds:0}s"); + } + /// + /// Main thread. The framework gave up compiling the target (W1). The first time, the + /// reload is asked for again with a fresh ceiling; the second time, the job is settled + /// at once by what is loaded — the same judgement the ceiling makes, without waiting + /// for it, because the framework has already said it is not going to finish. + /// + private void ConfigCompileStalled(ConfigWriteJob job, string line) + { + if (!job.Retried) + { + job.Retried = true; + job.PhaseStarted = DateTime.UtcNow; + job.Unloaded = false; + job.LoadedAt = null; + MarkLogPosition(job); + + Puts($"the framework gave up compiling '{job.Reload}'; asking for the reload once more"); + + if (ReloadPluginByName(job.Reload)) + return; + } + + SettleByWhatIsLoaded(job, $"before the framework gave up compiling it ({ShortLine(line)})"); + } + + /// + /// Main thread. Settles a reload that said nothing conclusive, by what is loaded — not + /// by the clock (F10). finishes the sentence: "within 30s", or + /// why it stopped sooner. + /// + private void SettleByWhatIsLoaded(ConfigWriteJob job, string when) + { + bool loaded = IsPluginLoaded(job.Reload); + + if (job.Phase == ConfigPhase.Restore) + { + SettleConfigJob(job, false, true, loaded, + loaded + ? job.RollbackReason + : $"{job.RollbackReason}; '{job.Reload}' did not come back on the restored file " + + $"{when} either", + job.RollbackLog); + return; + } + + if (loaded && job.Unloaded) + { + SettleConfigJob(job, true, false, null, + $"'{job.Reload}' is loaded again, but its load was not announced", null); + } + else if (loaded) + { + SettleConfigJob(job, false, false, null, + $"the framework did not reload '{job.Reload}' {when}; " + + "the file stands and applies at its next load", + null); + } + else + { + RollBackConfig(job, + job.Unloaded + ? $"'{job.Reload}' unloaded and did not come back {when}" + : $"'{job.Reload}' did not load {when}", + LogTail()); + } + } + + /// + /// Main thread. The edit took the plugin down: put every file back and reload again. + /// + /// + /// This is the feature's real content. Without it, R18 is a web form that takes a + /// required plugin off a production server one typo at a time. The outcome waits for the + /// restore's own reload, so it can say whether the plugin is back, rather than only that + /// the files are. + /// + /// + private void RollBackConfig(ConfigWriteJob job, string reason, string log) + { RestoreConfig(job.Staged); - string reason = job.Unloaded - ? $"'{job.Reload}' unloaded and did not come back within {ConfigReloadWindowSeconds:0}s" - : $"'{job.Reload}' did not reload within {ConfigReloadWindowSeconds:0}s"; + job.Phase = ConfigPhase.Restore; + job.PhaseStarted = DateTime.UtcNow; + job.Unloaded = false; + job.Retried = false; + job.LoadedAt = null; + job.RollbackReason = reason; + job.RollbackLog = log; - // Fire and forget: the restore is on disk either way, and waiting for a second window - // would spend the caller's whole RPC budget to learn something the operator can see. - ReloadPluginByName(job.Reload); + // The restore's failure, if it has one, is logged after this point. + MarkLogPosition(job); - SendConfigReport(job.ReqId, job.Staged, false, true, reason, LogTail()); + if (!ReloadPluginByName(job.Reload)) + { + SettleConfigJob(job, false, true, false, + $"{reason}; the files are back, but this framework refused to reload '{job.Reload}'", log); + } + } + + /// Main thread. The job is over: stop watching and say how it ended. + private void SettleConfigJob( + ConfigWriteJob job, + bool reloaded, + bool rolledBack, + bool? restored, + string reason, + string log) + { + if (_configJob != job) + return; + + StopConfigWatch(); + _configJob = null; + + EmitConfigOutcome(job, reloaded, rolledBack, restored, reason, log); } private void StopConfigWatch() @@ -4605,8 +5970,183 @@ namespace Oxide.Plugins job.Watch = null; } + private bool IsPluginLoaded(string name) + { + Plugin found = plugins.Find(name); + return found != null; + } + + /// Main thread. Points the job at the end of the newest log file. + private void MarkLogPosition(ConfigWriteJob job) + { + job.LogPath = NewestLogFile(); + job.LogPartial = ""; + + try + { + job.LogOffset = job.LogPath != null ? new FileInfo(job.LogPath).Length : 0; + } + catch (Exception) + { + job.LogOffset = 0; + } + } + /// - /// Main thread. What happened, and what is on disk now. + /// Main thread. The first line logged since the last read that says the target failed to + /// load, or null. + /// + /// + /// Bounded per call. A log that rotates mid-job (midnight) is followed to the new file + /// from its start; one that shrank was truncated and is read again from the top. Lines + /// this plugin wrote itself are skipped, so a message of ours that names the target + /// beside the word "failed" cannot roll back a reload that is going fine. + /// + /// + private string ReadConfigFailure(ConfigWriteJob job) + { + try + { + string newest = NewestLogFile(); + + if (newest == null) + return null; + + if (!string.Equals(newest, job.LogPath, StringComparison.OrdinalIgnoreCase)) + { + job.LogPath = newest; + job.LogOffset = 0; + job.LogPartial = ""; + } + + string text; + + using (var stream = new FileStream(job.LogPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + { + if (stream.Length < job.LogOffset) + { + job.LogOffset = 0; + job.LogPartial = ""; + } + + long available = stream.Length - job.LogOffset; + + if (available <= 0) + return null; + + int take = (int)Math.Min(available, ConfigLogPollMaxBytes); + stream.Seek(job.LogOffset, SeekOrigin.Begin); + + var buffer = new byte[take]; + int read = stream.Read(buffer, 0, take); + job.LogOffset += read; + + text = job.LogPartial + Encoding.UTF8.GetString(buffer, 0, read); + } + + int lastBreak = text.LastIndexOf('\n'); + + if (lastBreak < 0) + { + job.LogPartial = text.Length > ConfigLogPollMaxBytes ? "" : text; + return null; + } + + job.LogPartial = text.Substring(lastBreak + 1); + + if (job.LogPartial.Length > ConfigLogPollMaxBytes) + job.LogPartial = ""; + + string own = "[" + Title + "]"; + + foreach (string raw in text.Substring(0, lastBreak).Split('\n')) + { + string line = raw.TrimEnd('\r'); + + if (line.IndexOf(own, StringComparison.OrdinalIgnoreCase) >= 0) + continue; + + if (IsLoadFailureOf(line, job.Reload) || IsCompileStallOf(line, job.Reload)) + return line; + } + } + catch (Exception) + { + // A log that cannot be read costs the fast path and nothing else: the ceiling + // still settles the job. + } + + return null; + } + + /// Whether a log line says failed to compile or to initialise. + private static bool IsLoadFailureOf(string line, string plugin) + { + return SaysAbout(line, plugin, ConfigFailureWords); + } + + /// Whether a log line says the framework gave up compiling (W1). + private static bool IsCompileStallOf(string line, string plugin) + { + return SaysAbout(line, plugin, ConfigStallWords); + } + + private static bool SaysAbout(string line, string plugin, string[] words) + { + if (string.IsNullOrEmpty(line) || string.IsNullOrEmpty(plugin)) + return false; + + string lower = line.ToLowerInvariant(); + bool said = false; + + foreach (string word in words) + { + if (lower.IndexOf(word, StringComparison.Ordinal) >= 0) + { + said = true; + break; + } + } + + return said && MentionsName(lower, plugin.ToLowerInvariant()); + } + + /// + /// Whether appears as a whole name — so a failure of + /// KitsExtended is not read as one of Kits. A trailing .cs or a + /// version still counts: "Kits.cs", "'Kits v4.4.9'". + /// + private static bool MentionsName(string lower, string name) + { + int from = 0; + + while (true) + { + int at = lower.IndexOf(name, from, StringComparison.Ordinal); + + if (at < 0) + return false; + + int end = at + name.Length; + bool startOk = at == 0 || !char.IsLetterOrDigit(lower[at - 1]); + bool endOk = end == lower.Length || !char.IsLetterOrDigit(lower[end]); + + if (startOk && endOk) + return true; + + from = at + 1; + } + } + + /// A log line short enough for a reason sentence. + private static string ShortLine(string line) + { + string trimmed = (line ?? "").Trim(); + return trimmed.Length <= 240 ? trimmed : trimmed.Substring(0, 240) + "…"; + } + + /// + /// Main thread. Each file as it is on disk now. /// /// /// The versions are re-read rather than assumed, because both frameworks merge missing @@ -4615,13 +6155,7 @@ namespace Oxide.Plugins /// conflict with itself on the next save. /// /// - private void SendConfigReport( - string reqId, - List staged, - bool reloaded, - bool rolledBack, - string reason, - string log) + private List ConfigFilesNow(List staged, bool rolledBack) { var files = new List(); @@ -4644,11 +6178,40 @@ namespace Oxide.Plugins files.Add(row); } + return files; + } + + /// + /// Main thread. The reply to config.write. + /// + /// + /// With a job it says "written, reloading": pending, + /// the writeId the outcome will carry, and ceilingMs. Without one it is the + /// whole answer — nothing was asked to reload, or the target was not loaded. + /// + /// + private void SendConfigReport( + string reqId, + List staged, + bool reloaded, + bool rolledBack, + string reason, + string log, + ConfigWriteJob pending = null) + { Dictionary report = Frame("config.report", "reply"); report["ok"] = !rolledBack; report["reloaded"] = reloaded; report["rolledBack"] = rolledBack; - report["files"] = files; + report["pending"] = pending != null; + report["files"] = ConfigFilesNow(staged, rolledBack); + + if (pending != null) + { + report["writeId"] = pending.WriteId; + report["reload"] = pending.Reload; + report["ceilingMs"] = (long)(ConfigReloadCeilingSeconds * 1000); + } if (!string.IsNullOrEmpty(reason)) report["reason"] = reason; @@ -4659,14 +6222,59 @@ namespace Oxide.Plugins if (reqId != null) report["reqId"] = reqId; - _lastConfigSummary = - $"files={staged.Count} reloaded={reloaded} rolledBack={rolledBack} " + - $"at={DateTime.UtcNow:HH:mm:ss}Z" + - (string.IsNullOrEmpty(reason) ? "" : $" reason={reason}"); + _lastConfigSummary = pending != null + ? $"files={staged.Count} reloading={pending.Reload} at={DateTime.UtcNow:HH:mm:ss}Z" + : $"files={staged.Count} reloaded={reloaded} rolledBack={rolledBack} " + + $"at={DateTime.UtcNow:HH:mm:ss}Z" + + (string.IsNullOrEmpty(reason) ? "" : $" reason={reason}"); EmitFrame(report); } + /// + /// Main thread. How a pending reload ended, as an event (protocol 13, F9). + /// + /// + /// An event, not a reply, because nothing is waiting for it: the sidecar files it in its + /// feed and the website's ingest settles the write it names. Staff class on the site — + /// the log tail is an operator's console. + /// + /// + private void EmitConfigOutcome( + ConfigWriteJob job, + bool reloaded, + bool rolledBack, + bool? restored, + string reason, + string log) + { + Dictionary frame = Frame("config.outcome", "event"); + frame["writeId"] = job.WriteId; + frame["reload"] = job.Reload; + frame["ok"] = !rolledBack; + frame["reloaded"] = reloaded; + frame["rolledBack"] = rolledBack; + frame["elapsedMs"] = (long)(DateTime.UtcNow - job.Started).TotalMilliseconds; + frame["files"] = ConfigFilesNow(job.Staged, rolledBack); + + if (restored.HasValue) + frame["restored"] = restored.Value; + + if (!string.IsNullOrEmpty(reason)) + frame["reason"] = reason; + + if (!string.IsNullOrEmpty(log)) + frame["log"] = log; + + _lastConfigSummary = + $"files={job.Staged.Count} reloaded={reloaded} rolledBack={rolledBack}" + + (restored.HasValue ? $" restored={restored.Value}" : "") + + $" at={DateTime.UtcNow:HH:mm:ss}Z" + + (string.IsNullOrEmpty(reason) ? "" : $" reason={reason}"); + + EmitFrame(frame); + } + private void EmitConfigError(string reqId, string reason, string detail) { Dictionary frame = Frame("config.error", "reply"); @@ -4811,6 +6419,55 @@ namespace Oxide.Plugins } } + /// + /// The framework's newest log file, or null. Oxide's own log is the newest *.txt + /// directly in LogDirectory; plugins that log to files write into subdirectories + /// of it, which this does not look in. + /// + /// + /// Carbon's is Carbon.Core.log in carbon/logs, beside four other + /// *.log files and no *.txt at all (read on the rig, Carbon 2.0.259). Its + /// Harmony log is the one written most often, so "newest" would pick the wrong file; + /// the name is the only reliable way to choose. + /// + /// + private static string NewestLogFile() + { + try + { + string dir = Interface.Oxide.LogDirectory; + + if (string.IsNullOrEmpty(dir) || !Directory.Exists(dir)) + return null; + + if (IsCarbon()) + { + string core = Path.Combine(dir, "Carbon.Core.log"); + return File.Exists(core) ? core : null; + } + + string newest = null; + DateTime newestAt = DateTime.MinValue; + + foreach (string candidate in Directory.GetFiles(dir, "*.txt")) + { + DateTime at = File.GetLastWriteTimeUtc(candidate); + + if (at <= newestAt) + continue; + + newest = candidate; + newestAt = at; + } + + return newest; + } + catch (Exception) + { + return null; + } + } + /// /// The tail of the newest log file, read only when a reload failed. /// @@ -4824,29 +6481,7 @@ namespace Oxide.Plugins { try { - string dir = Interface.Oxide.LogDirectory; - - if (string.IsNullOrEmpty(dir) || !Directory.Exists(dir)) - return null; - - string[] logs = Directory.GetFiles(dir, "*.txt"); - - if (logs.Length == 0) - return null; - - string newest = null; - DateTime newestAt = DateTime.MinValue; - - foreach (string candidate in logs) - { - DateTime at = File.GetLastWriteTimeUtc(candidate); - - if (at <= newestAt) - continue; - - newest = candidate; - newestAt = at; - } + string newest = NewestLogFile(); if (newest == null) return null; @@ -4882,6 +6517,13 @@ namespace Oxide.Plugins /// private bool ReloadPluginByName(string name) { + // Carbon's Oxide-compatible ReloadPlugin is not a reload (W3, Carbon 2.0.259 on the + // rig): one call unloaded Kits and never compiled it again, the next did nothing at + // all. Its own console command, typed at the panel, reloads the same plugin in about a + // second — so on Carbon the reload IS that command, run on the server console. + if (IsCarbon()) + return RunReloadCommand("c.reload", name); + try { System.Reflection.MethodInfo reload = Interface.Oxide.GetType() @@ -4898,14 +6540,20 @@ namespace Oxide.Plugins Puts($"framework reload of '{name}' threw: {ex.Message}"); } + return RunReloadCommand("oxide.reload", name); + } + + /// Runs a reload command on the server console, as an operator at the panel would. + private bool RunReloadCommand(string command, string name) + { try { - covalence.Server.Command(IsCarbon() ? "c.reload" : "oxide.reload", name); + ConsoleSystem.Run(ConsoleSystem.Option.Server, command, name); return true; } catch (Exception ex) { - Puts($"console reload of '{name}' threw: {ex.Message}"); + Puts($"console {command} of '{name}' threw: {ex.Message}"); return false; } } @@ -6145,8 +7793,8 @@ namespace Oxide.Plugins /// /// Whether a lease holds this group permission, for the permission mirror. A pair a - /// lease has borrowed is the lease's until it ends: a sync neither applies to it nor - /// reports it foreign. + /// lease has borrowed is the lease's until it ends: a sync does not apply to it, and the + /// inventory lists it under leased so the site never takes it for a hand edit. /// private LeaseHold LeasedGroupPermission(string group, string perm) { @@ -6286,6 +7934,24 @@ namespace Oxide.Plugins public float Radius; public string Name; + /// + /// A zone's ZoneManager flags and settings (PLAN_REDESIGNS §3.1), kept so a re-created + /// zone gets them back. Null on a zone from before protocol 13's options. + /// + public List Flags; + public Dictionary Settings; + + /// What a player is told on the way in and out, and how (D193). Said by this plugin, not ZoneManager. + public string EnterMessage; + public string LeaveMessage; + public string Delivery; + public string Format; + + /// A ZoneDomes dome over the zone (D194): its type (0–4) and how many spheres are stacked. + public bool Dome; + public int DomeType; + public int DomeStack; + /// When a zone is erased without being asked, on this host's clock (D96). Zero for an entity. public long DeadlineMs; @@ -6336,6 +8002,447 @@ 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; + } + + // ---- a zone's options (PLAN_REDESIGNS §3.1, D166, D211) ---- + // + // ZoneManager's flags are whatever the installed version declares, so the list is read from + // it, never kept here. `ZoneFieldListRaw` is in ZoneManager's own API region and answers + // the zone fields followed by every flag name; the fields this plugin controls or does not + // offer are dropped from the front. Plan §0.6 believed only a plugin that references + // ZoneManager's types could read the list, so it put the read in the zone helper. That was + // wrong for 3.1.14 (D211), and a server without the helper still gets every flag. + + /// The fields `ZoneFieldListRaw` answers that are not flags, lower-cased. + private static readonly HashSet ZoneNonFlagFields = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "name", "id", "radius", "rotation", "size", "location", "enter_message", "leave_message", + "radiation", "comfort", "temperature", "safezone", "permission", "ejectspawns", "parentid", "enabled", "enable" + }; + + /// + /// The settings a step may give a zone, and their bounds. Every other ZoneManager field + /// (rotation, parent, eject spawns, size) changes what this plugin believes the zone is, + /// so it stays out (§3.1). The messages are this plugin's own (D193). + /// + private static readonly string[] ZoneSettingKeys = { "radiation", "comfort", "temperature", "safezone", "permission" }; + + private const float ZoneRadiationMax = 500f; + private const float ZoneComfortMax = 1f; + private const float ZoneTemperatureLimit = 100f; + private const int ZoneMessageMax = 256; + private const int ZoneFlagsMax = 80; + + /// ZoneDomes' own sphere types: Standard, Red, Blue, Green, Purple. + private const int DomeTypeMax = 4; + + /// The most spheres one dome stacks. Each is an entity; ZoneDomes itself has no limit. + private const int DomeStackMax = 10; + + /// A zone this plugin just put back says nothing to whoever is inside it for this long. + private const long ZoneQuietMs = 5000L; + + /// Zone id → the moment its enter and leave messages start again. Main thread. + private readonly Dictionary _zoneQuietUntil = new Dictionary(); + + /// + /// Main thread. ZoneManager's flag names, as it spells them, or null when it is not loaded + /// or does not answer. Asked when needed: an operator can update ZoneManager at any time. + /// + private static List ZoneFlagNames(Plugin zm) + { + var fields = zm == null ? null : zm.Call("ZoneFieldListRaw") as List; + + if (fields == null) + return null; + + var flags = new List(); + + foreach (string field in fields) + { + if (!string.IsNullOrEmpty(field) && !ZoneNonFlagFields.Contains(field)) + flags.Add(field); + } + + return flags; + } + + /// + /// Main thread. Reads a step's flags and settings against the live ZoneManager. Each flag + /// is returned as ZoneManager spells it; each setting is checked and written as + /// ZoneManager parses it. False with a refusal on anything unknown or out of range. + /// + private static bool ReadZoneOptions(Plugin zm, Dictionary cmd, + out List flags, out Dictionary settings, out string refusal) + { + flags = new List(); + settings = new Dictionary(StringComparer.Ordinal); + refusal = null; + + object raw; + + if (cmd.TryGetValue("flags", out raw) && raw != null) + { + var list = raw as Newtonsoft.Json.Linq.JArray; + + if (list == null) + { + refusal = "a zone's flags are a list of names"; + return false; + } + + if (list.Count > ZoneFlagsMax) + { + refusal = $"a zone takes at most {ZoneFlagsMax} flags, not {list.Count}"; + return false; + } + + List known = ZoneFlagNames(zm); + + if (known == null) + { + refusal = "this server's ZoneManager did not say which flags it has"; + return false; + } + + var byName = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (string k in known) + byName[k] = k; + + foreach (Newtonsoft.Json.Linq.JToken token in list) + { + string name = token.Type == Newtonsoft.Json.Linq.JTokenType.String ? (string)token : null; + string spelled; + + if (string.IsNullOrEmpty(name) || !byName.TryGetValue(name.Trim(), out spelled)) + { + refusal = $"'{name}' is not a flag this server's ZoneManager has"; + return false; + } + + if (!flags.Contains(spelled)) + flags.Add(spelled); + } + } + + if (cmd.TryGetValue("settings", out raw) && raw != null) + { + var obj = raw as Newtonsoft.Json.Linq.JObject; + + if (obj == null) + { + refusal = "a zone's settings are an object of name and value"; + return false; + } + + foreach (Newtonsoft.Json.Linq.JProperty p in obj.Properties()) + { + string key = p.Name.ToLowerInvariant(); + + if (Array.IndexOf(ZoneSettingKeys, key) < 0) + { + refusal = $"'{p.Name}' is not a zone setting this server offers (radiation, comfort, temperature, safezone, permission)"; + return false; + } + + // Invariant, because a JSON 0.5 read under the host's culture can come back "0,5". + var jv = p.Value as Newtonsoft.Json.Linq.JValue; + string value = jv == null ? null : Convert.ToString(jv.Value, System.Globalization.CultureInfo.InvariantCulture); + string written; + + if (!ZoneSettingValue(key, value, out written, out refusal)) + return false; + + settings[key] = written; + } + } + + return true; + } + + private static bool ZoneSettingValue(string key, string value, out string written, out string refusal) + { + written = null; + refusal = null; + var inv = System.Globalization.CultureInfo.InvariantCulture; + + if (key == "safezone") + { + bool on; + + if (!bool.TryParse(value, out on)) + { + refusal = $"safezone is true or false, not '{value}'"; + return false; + } + + written = on ? "true" : "false"; + return true; + } + + if (key == "permission") + { + string perm = (value ?? string.Empty).Trim().ToLowerInvariant(); + + if (perm.StartsWith("zonemanager.", StringComparison.Ordinal)) + perm = perm.Substring("zonemanager.".Length); + + if (perm.Length == 0 || perm.Length > 32) + { + refusal = "a zone's permission is 1 to 32 characters"; + return false; + } + + foreach (char c in perm) + { + if (!(c >= 'a' && c <= 'z') && !(c >= '0' && c <= '9') && c != '_') + { + refusal = $"a zone's permission is letters, digits and _, not '{value}'"; + return false; + } + } + + written = perm; + return true; + } + + float number; + + if (!float.TryParse(value, System.Globalization.NumberStyles.Float, inv, out number) || float.IsNaN(number) || float.IsInfinity(number)) + { + refusal = $"{key} is a number, not '{value}'"; + return false; + } + + float low = key == "temperature" ? -ZoneTemperatureLimit : 0f; + float high = key == "radiation" ? ZoneRadiationMax : key == "comfort" ? ZoneComfortMax : ZoneTemperatureLimit; + + if (number < low || number > high) + { + refusal = $"{key} is {low} to {high}, not {number.ToString(inv)}"; + return false; + } + + written = number.ToString(inv); + return true; + } + + // ---- the dome (PLAN_REDESIGNS §3.2, D167, D194) ---- + // + // ZoneDomes draws the dome. Two things it lacks are made up here and in the domes helper, + // `RunicGatewayDomes.cs`: its API ends in a chat message to the player who asked, which + // throws for a call with none (the helper patches that), and at its own start it drops + // every dome whose zone does not exist yet, which is every zone this plugin re-creates. + // So this plugin puts its domes back after it puts its zones back, and only once ZoneDomes + // has finished its own start: a dome added before that is drawn twice, and the first set + // of spheres is left in the world where nothing can remove it. The helper says when. + + private Plugin ZoneDomesPlugin() + { + return plugins.Find("ZoneDomes"); + } + + private Plugin DomesHelperPlugin() + { + return plugins.Find("RunicGatewayDomes"); + } + + /// Main thread. patched, unsupported, no-zonedomes or missing. + private string DomesHelperState() + { + Plugin helper = DomesHelperPlugin(); + return helper == null ? "missing" : helper.Call("State") as string ?? "unsupported"; + } + + /// Main thread. Whether a dome may be added now: ZoneDomes loaded, patched, and past its own start. + private bool DomesReady() + { + Plugin helper = DomesHelperPlugin(); + return ZoneDomesPlugin() != null && helper != null + && helper.Call("State") as string == "patched" && helper.Call("Ready") is bool ready && ready; + } + + /// Main thread. Why a dome cannot be added now, or null when it can. + private string DomeRefusal() + { + if (ZoneDomesPlugin() == null) + return "ZoneDomes is not loaded on this server, so a zone cannot have a dome"; + + string state = DomesHelperState(); + + if (state == "missing") + return "RunicGatewayDomes.cs is not installed beside the bridge, and ZoneDomes cannot be called without it"; + + if (state != "patched") + return $"RunicGatewayDomes.cs could not patch this ZoneDomes ({state})"; + + return DomesReady() ? null : "ZoneDomes has not finished starting"; + } + + /// Main thread. Adds a zone's dome. True when ZoneDomes made it, or already had it. + private bool AddDome(WorldEntry e) + { + Plugin zd = ZoneDomesPlugin(); + + if (zd == null || !e.Dome) + return false; + + object made = zd.Call("AddNewDome", null, e.Id, e.DomeType, e.DomeStack); + return made is bool && (bool)made; + } + + /// + /// Main thread. Takes a zone's dome out of the world and out of ZoneDomes' data file. + /// Called BEFORE the zone is erased, so a dome never outlives its zone. + /// + private void RemoveDome(WorldEntry e) + { + Plugin zd = ZoneDomesPlugin(); + + if (zd == null || !e.Dome) + return; + + // Without the helper the call still removes the dome and then throws on its message; + // the framework logs that, and the dome is gone either way. + zd.Call("RemoveExistingDome", null, e.Id); + } + + /// + /// Main thread. Puts back the dome of every standing zone that has one. ZoneDomes answers + /// false for a dome it already has, so this is safe to repeat. + /// + private void RestoreDomes(string why) + { + if (!_worldReady || !DomesReady()) + return; + + Plugin zm = ZoneManagerPlugin(); + + if (zm == null) + return; + + int added = 0; + + foreach (WorldEntry e in _world.Values) + { + if (e.Kind != WorldKindZone || !e.Dome || ZoneExpired(e) || zm.Call("CheckZoneID", e.Id) == null) + continue; + + if (AddDome(e)) + added++; + } + + if (added > 0) + { + _lastWorldSummary = $"domes after {why}: {added} put back"; + Puts($"world: {_lastWorldSummary}"); + } + } + + /// Raised by the domes helper when ZoneDomes has finished its own start. + private void OnRgDomesReady() + { + MarkHook("OnRgDomesReady"); + NextTick(() => RestoreDomes("ZoneDomes started")); + } + + // ---- the zone's messages (D193) ---- + // + // ZoneManager can say a zone's enter and leave messages itself, but only to chat unless its + // own config turns popups on for every zone on the server. So this plugin never gives it + // the messages for its own zones, and says them here, as each zone's step chose. + + private void OnEnterZone(string zoneId, BasePlayer player) + { + MarkHook("OnEnterZone"); + SayZoneMessage(zoneId, player, true); + } + + private void OnExitZone(string zoneId, BasePlayer player) + { + MarkHook("OnExitZone"); + SayZoneMessage(zoneId, player, false); + } + + private void SayZoneMessage(string zoneId, BasePlayer player, bool entering) + { + WorldEntry e; + + if (zoneId == null || player == null || !player.IsConnected || !_world.TryGetValue(zoneId, out e) || e.Kind != WorldKindZone) + return; + + string message = entering ? e.EnterMessage : e.LeaveMessage; + + if (string.IsNullOrEmpty(message)) + return; + + // A zone this plugin just put back (a restart, a ZoneManager reload) re-enters whoever + // is standing in it. They were told when they walked in. + long quiet; + + if (_zoneQuietUntil.TryGetValue(zoneId, out quiet)) + { + if (NowMs() < quiet) + return; + + _zoneQuietUntil.Remove(zoneId); + } + + // A popup where PopupNotifications is loaded, and chat where it is not (§2.4): the + // step was accepted long before this moment, and a player is told either way. + Plugin popup = e.Delivery == DeliveryPopup ? PopupPlugin() : null; + + if (popup != null) + popup.Call("CreatePopupNotification", message, player, 0f); + else + player.ChatMessage(e.Format == null ? message : Voice(e.Format, message)); + } + /// /// 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 @@ -6698,6 +8805,86 @@ namespace Oxide.Plugins if (name.Length > 64) name = name.Substring(0, 64); + // PLAN_REDESIGNS §3.1: the flags and settings, each checked against this server's own + // ZoneManager. An unknown one is refused, never dropped: a zone that quietly lacks the + // "no building" its author chose is worse than a step that fails and says why. + List flags; + Dictionary settings; + string optionRefusal; + + if (!ReadZoneOptions(zm, cmd, out flags, out settings, out optionRefusal)) + { + EmitWorldError(reqId, "bad-option", optionRefusal); + return; + } + + // D193: what a player is told on the way in and out, and whether as chat or a popup. + string enterMessage = StringField(cmd, "enterMessage"); + string leaveMessage = StringField(cmd, "leaveMessage"); + + if ((enterMessage != null && enterMessage.Length > ZoneMessageMax) || (leaveMessage != null && leaveMessage.Length > ZoneMessageMax)) + { + EmitWorldError(reqId, "out-of-range", $"a zone's message is at most {ZoneMessageMax} characters"); + return; + } + + string delivery = StringField(cmd, "delivery"); + + if (string.IsNullOrEmpty(delivery)) + delivery = DeliveryChat; + + if (delivery != DeliveryChat && delivery != DeliveryPopup) + { + EmitWorldError(reqId, "malformed", $"a zone's messages go to chat or to a popup, not to '{delivery}'"); + return; + } + + string format = StringField(cmd, "format"); + + if (format != null && (format.Length > ChatFormatMax || Occurrences(format, "{message}") != 1)) + { + EmitWorldError(reqId, "malformed", $"a chat format holds exactly one {{message}} and at most {ChatFormatMax} characters"); + return; + } + + // D194: a dome, where ZoneDomes and the domes helper are both here. + bool dome = false; + int domeType = 0, domeStack = 1; + object domeRaw; + + if (cmd.TryGetValue("dome", out domeRaw) && domeRaw != null) + { + var d = domeRaw as Newtonsoft.Json.Linq.JObject; + + if (d == null) + { + EmitWorldError(reqId, "malformed", "a zone's dome is an object of type and stack"); + return; + } + + Newtonsoft.Json.Linq.JToken t = d["type"], s = d["stack"]; + // No type is Standard, the one full dome; the colours show only where they meet + // the ground (D212). + domeType = t == null ? 0 : t.Type == Newtonsoft.Json.Linq.JTokenType.Integer ? (int)t : -1; + domeStack = s == null ? 1 : s.Type == Newtonsoft.Json.Linq.JTokenType.Integer ? (int)s : -1; + + if (domeType < 0 || domeType > DomeTypeMax || domeStack < 1 || domeStack > DomeStackMax) + { + EmitWorldError(reqId, "out-of-range", $"a dome's type is 0 to {DomeTypeMax} and its stack 1 to {DomeStackMax}"); + return; + } + + string noDome = DomeRefusal(); + + if (noDome != null) + { + EmitWorldError(reqId, "dome-unavailable", noDome); + return; + } + + dome = true; + } + var entry = new WorldEntry { Id = $"rg-{ShortRun(runId)}-{NowMs() % 100000000L}-{++_zoneSerial}", @@ -6709,6 +8896,15 @@ namespace Oxide.Plugins Z = point.z, Radius = (float)radius, Name = name, + Flags = flags.Count > 0 ? flags : null, + Settings = settings.Count > 0 ? settings : null, + EnterMessage = string.IsNullOrEmpty(enterMessage) ? null : enterMessage, + LeaveMessage = string.IsNullOrEmpty(leaveMessage) ? null : leaveMessage, + Delivery = delivery, + Format = delivery == DeliveryChat ? format : null, + Dome = dome, + DomeType = domeType, + DomeStack = domeStack, DeadlineMs = NowMs() + holdMs, BootId = _bootId, WipeId = _wipeId, @@ -6721,11 +8917,20 @@ namespace Oxide.Plugins return; } + // The dome after the zone, because ZoneDomes reads the zone's place and size from + // ZoneManager. A dome that is not made takes its zone with it: the step asked for both. + if (dome && !AddDome(entry)) + { + zm.Call("EraseTemporaryZone", this, entry.Id); + EmitWorldError(reqId, "dome-unavailable", "ZoneDomes did not make the dome, so the zone was not opened"); + return; + } + _world[entry.Id] = entry; SaveWorld(); EnsureWorldTimer(); - _lastWorldSummary = $"zone {entry.Id} r={radius:0} for {holdMs / 60000L} min, run {runId}"; + _lastWorldSummary = $"zone {entry.Id} r={radius:0} for {holdMs / 60000L} min, {flags.Count} flag(s){(dome ? ", domed" : "")}, run {runId}"; Puts($"world: {_lastWorldSummary}"); EmitWorldPlaced(reqId, new List { entry }, false); } @@ -6753,13 +8958,35 @@ namespace Oxide.Plugins /// private bool CreateZone(Plugin zm, WorldEntry entry) { - string[] args = + var args = new List { "name", entry.Name ?? "Event zone", "radius", entry.Radius.ToString(System.Globalization.CultureInfo.InvariantCulture) }; - object created = zm.Call("CreateOrUpdateTemporaryZone", this, entry.Id, args, new UnityEngine.Vector3(entry.X, entry.Y, entry.Z)); + // ZoneManager's own key/value arguments (§3.1): each setting by its key, and each flag + // as "", "true". Kept on the record, so a zone put back after a restart or a + // ZoneManager reload gets them back. A flag a newer ZoneManager no longer has is + // ignored by ZoneManager rather than failing the zone. + if (entry.Settings != null) + { + foreach (KeyValuePair kv in entry.Settings) + { + args.Add(kv.Key); + args.Add(kv.Value); + } + } + + if (entry.Flags != null) + { + foreach (string flag in entry.Flags) + { + args.Add(flag); + args.Add("true"); + } + } + + object created = zm.Call("CreateOrUpdateTemporaryZone", this, entry.Id, args.ToArray(), new UnityEngine.Vector3(entry.X, entry.Y, entry.Z)); return created is bool && (bool)created && zm.Call("CheckZoneID", entry.Id) != null; } @@ -6789,9 +9016,14 @@ namespace Oxide.Plugins continue; if (CreateZone(zm, e)) + { restored++; + _zoneQuietUntil[e.Id] = NowMs() + ZoneQuietMs; + } else + { failed++; + } } if (restored > 0 || failed > 0) @@ -6799,6 +9031,10 @@ namespace Oxide.Plugins _lastWorldSummary = $"zones after {why}: {restored} re-created, {failed} failed"; Puts($"world: {_lastWorldSummary}"); } + + // The domes after the zones (D194). At a real start ZoneDomes has not finished its own + // start yet, and the helper's ready hook brings this back when it has. + RestoreDomes(why); } /// Main thread. Place crates or NPCs an event owns, one registry entry per net id. @@ -6823,6 +9059,21 @@ namespace Oxide.Plugins return; } + // Stage 4 (D243): a RunicNPC profile instead of one of Rust's own prefabs. + string profile = StringField(cmd, "profile"); + + if (!string.IsNullOrEmpty(profile)) + { + if (!string.IsNullOrEmpty(StringField(cmd, "prefab"))) + { + EmitWorldError(reqId, "malformed", "a placement names a prefab or a profile, not both"); + return; + } + + HandleWorldPlaceProfile(cmd, reqId, key, runId, profile); + return; + } + WorldPrefab prefab = FindWorldPrefab(StringField(cmd, "prefab")); if (prefab == null) @@ -6832,6 +9083,11 @@ namespace Oxide.Plugins return; } + // RunicNPC stage 9 (D310): Rust's own scientists need RunicNPC too, so a module that + // missed the hello cannot place NPCs on a server the site would call incomplete. + if (prefab.Kind == WorldKindNpc && !RunicNpcForWorld(reqId)) + return; + int count = IntField(cmd, "count"); int max = prefab.Kind == WorldKindNpc ? (_config != null ? _config.EventsMaxNpcs : WorldHardMaxNpcs) @@ -7021,12 +9277,18 @@ namespace Oxide.Plugins // it lives in memory only — and its record is all there is left to remove. wasThere = zm != null && zm.Call("CheckZoneID", e.Id) != null; + // The dome first: ZoneDomes keeps it in its own data file, and a dome + // left there outlives the zone it was drawn over (D194). + RemoveDome(e); + if (wasThere) zm.Call("EraseTemporaryZone", this, e.Id); + + _zoneQuietUntil.Remove(e.Id); } else { - wasThere = KillEntity(e.Id); + wasThere = e.Kind == WorldKindNpc ? KillWorldNpc(e) : KillEntity(e.Id); } _world.Remove(e.Id); @@ -7302,14 +9564,22 @@ namespace Oxide.Plugins foreach (WorldEntry e in due) { + RemoveDome(e); + if (zm != null && zm.Call("CheckZoneID", e.Id) != null) zm.Call("EraseTemporaryZone", this, e.Id); _world.Remove(e.Id); + _zoneQuietUntil.Remove(e.Id); 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; @@ -7867,7 +10137,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 { @@ -7945,6 +10232,7 @@ namespace Oxide.Plugins long now = NowMs(); Plugin zm = null; bool zmLooked = false; + bool trusted = false; foreach (RunTally t in _runTallies.Values) { @@ -7959,12 +10247,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; } @@ -8093,6 +10381,901 @@ namespace Oxide.Plugins EnsureTallyTimer(); } + // ── RunicNPC (docs/runicnpc/PLAN.md stage 4, PROTOCOL.md §19.12) ───────────────────── + // + // RunicNPC is Runic Gateway's own NPC plugin. The bridge is its only link to the site + // (runicnpc PLAN.md §4): it calls RunicNPC's API for the site's commands and turns its hooks + // into frames. RunicNPC never talks to the sidecar, so "the sidecar is a dumb forwarder" + // stays true. It is required since RunicNPC's stage 9 (D310): without it every `npc.*` command + // answers `runicnpc-missing`, and so does every NPC an event places, Rust's own included. + + /// + /// The RunicNPC API these commands call: 3 added placements from a website (D249), 4 the + /// faction table and an event's escort, ally and tether (runicnpc stage 5), 5 bosses and + /// passive NPCs (runicnpc stage 6): a profile's `boss` and `use`, and the boss hooks, 6 loot (runicnpc + /// stage 7): a profile's `loot`, and the `dropTable` override a Place NPCs step's switch sends. + /// + private const int RunicNpcApiNeeded = 6; + + private Plugin RunicNpcPlugin() + { + return plugins.Find("RunicNPC"); + } + + /// RunicNPC's API version, or 0 when it is not loaded. + private int RunicNpcApi() + { + Plugin npc = RunicNpcPlugin(); + + if (npc == null) + return 0; + + object api = npc.Call("RunicNpc_ApiVersion"); + return api is int ? (int)api : 0; + } + + /// For the integrations body: loaded, its version, and the API it answers. + private Dictionary RunicNpcBody() + { + Dictionary body = IntegrationBody(RunicNpcPlugin()); + + if (RunicNpcPlugin() != null) + body["api"] = RunicNpcApi(); + + return body; + } + + /// + /// Main thread. The profile name of one of RunicNPC's NPCs, or null for anything else — + /// a Rust scientist, an animal, or any NPC while RunicNPC is not loaded. + /// + private string RunicNpcProfileOf(BaseEntity entity) + { + if (entity == null) + return null; + + Plugin npc = RunicNpcPlugin(); + return npc != null ? npc.Call("RunicNpc_ProfileOf", entity) as string : null; + } + + /// + /// Main thread. The plugin to call, or null after answering npc.error — missing, or + /// older than the API these commands need. + /// + private Plugin RunicNpcFor(string reqId) + { + Plugin npc = RunicNpcPlugin(); + + if (npc == null) + { + EmitNpcError(reqId, "runicnpc-missing", "RunicNPC is not loaded on this server"); + return null; + } + + int api = RunicNpcApi(); + + if (api < RunicNpcApiNeeded) + { + EmitNpcError(reqId, "runicnpc-old", + $"RunicNPC {npc.Version} answers API {api}, and this bridge needs {RunicNpcApiNeeded}"); + return null; + } + + return npc; + } + + /// + /// Main thread. Whether an event may place NPCs here (stage 9, D310): RunicNPC loaded and + /// answering the API this bridge needs, for a profile and for Rust's own scientists alike. + /// Answers world.error with the reason when not. + /// + private bool RunicNpcForWorld(string reqId) + { + Plugin npc = RunicNpcPlugin(); + + if (npc == null) + { + EmitWorldError(reqId, "runicnpc-missing", "this server needs RunicNPC to place NPCs, and it is not loaded"); + return false; + } + + int api = RunicNpcApi(); + + if (api < RunicNpcApiNeeded) + { + EmitWorldError(reqId, "runicnpc-old", + $"this server needs RunicNPC to place NPCs: RunicNPC {npc.Version} answers API {api}, and this bridge needs {RunicNpcApiNeeded}"); + return false; + } + + return true; + } + + /// + /// Main thread. npc.profiles: the server's profile set as RunicNPC holds it — whether + /// a site manages it, every profile, and those refused and why. The site reads this before + /// its first push to a server, to adopt the server's own (D244). + /// + private void HandleNpcProfiles(string reqId) + { + Plugin npc = RunicNpcFor(reqId); + + if (npc == null) + return; + + var all = npc.Call("RunicNpc_Profiles") as Newtonsoft.Json.Linq.JObject; + + if (all == null) + { + EmitNpcError(reqId, "refused", "RunicNPC did not answer with its profiles"); + return; + } + + Dictionary reply = Frame("npc.profiles", "reply"); + reply["managed"] = all["managed"] != null && (bool)all["managed"]; + reply["profiles"] = all["profiles"] ?? new Newtonsoft.Json.Linq.JObject(); + reply["refused"] = all["refused"] ?? new Newtonsoft.Json.Linq.JObject(); + + // Runicnpc stage 5 (D254): the faction table, read with the profiles so the site can adopt it. + reply["factions"] = all["factions"] ?? new Newtonsoft.Json.Linq.JArray(); + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + /// + /// Main thread. npc.profiles.set: replaces the whole profile set, as the site does on + /// every push, and so marks the server managed (D221). RunicNPC checks each profile — a kit + /// this server lacks, for one — and the answer lists those it refused. + /// + private void HandleNpcProfilesSet(Dictionary cmd, string reqId) + { + Plugin npc = RunicNpcFor(reqId); + + if (npc == null) + return; + + object value; + var profiles = cmd.TryGetValue("profiles", out value) ? value as Newtonsoft.Json.Linq.JObject : null; + + if (profiles == null) + { + EmitNpcError(reqId, "malformed", "a push carries `profiles`, an object of profiles by name"); + return; + } + + // Runicnpc stage 5 (D254): the faction table travels with the profiles. It is set first, so the + // profiles are checked against the table they will be used with; a refused table is reported + // and the old one kept, and the profiles are pushed either way. + var factions = cmd.TryGetValue("factions", out value) ? value as Newtonsoft.Json.Linq.JArray : null; + string factionsRefused = factions != null ? npc.Call("RunicNpc_SetFactions", factions) as string : null; + + var refused = npc.Call("RunicNpc_SetProfiles", profiles) as Dictionary; + _lastNpcSummary = $"profiles pushed: {profiles.Count}, refused {(refused != null ? refused.Count : 0)}" + + (factions != null ? $", factions {factions.Count}{(factionsRefused != null ? " REFUSED" : "")}" : ""); + Puts($"runicnpc: {_lastNpcSummary}"); + + Dictionary reply = Frame("npc.ok", "reply"); + reply["refused"] = refused ?? new Dictionary(); + + if (factionsRefused != null) + reply["factionsRefused"] = factionsRefused; + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + /// + /// Main thread. npc.placements: every placement with its values, how many of its NPCs + /// are alive, what it waits for and its note (D237, D239); the routes a placement may walk; + /// and the cost warning for what the server plans now (D227). + /// + private void HandleNpcPlacements(string reqId) + { + Plugin npc = RunicNpcFor(reqId); + + if (npc == null) + return; + + var routes = npc.Call("RunicNpc_Routes") as Newtonsoft.Json.Linq.JObject; + var names = new List(); + + if (routes != null) + { + foreach (KeyValuePair route in routes) + names.Add(route.Key); + } + + names.Sort(StringComparer.Ordinal); + + Dictionary reply = Frame("npc.placements", "reply"); + reply["placements"] = npc.Call("RunicNpc_Placements") ?? new List>(); + reply["routes"] = names; + reply["cost"] = npc.Call("RunicNpc_CostWarning", 0) as string; + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + /// + /// Main thread. npc.placement: one change to one placement, by op — add + /// (a new one, named by RunicNPC as in game, from a map point it puts on the ground and + /// checks against the navmesh: D245, D246), set (new values for an existing one), + /// remove, rename and respawn. Every refusal is RunicNPC's own sentence. + /// + private void HandleNpcPlacement(Dictionary cmd, string reqId) + { + Plugin npc = RunicNpcFor(reqId); + + if (npc == null) + return; + + string op = StringField(cmd, "op"); + string id = StringField(cmd, "id"); + object value; + var placement = cmd.TryGetValue("placement", out value) ? value as Newtonsoft.Json.Linq.JObject : null; + Dictionary reply = Frame("npc.ok", "reply"); + + switch (op) + { + case "add": + { + if (placement == null) + { + EmitNpcError(reqId, "malformed", "add carries `placement`"); + return; + } + + var answer = npc.Call("RunicNpc_AddPlacement", placement) as Dictionary; + object error = null; + + if (answer == null || answer.TryGetValue("error", out error)) + { + EmitNpcError(reqId, "refused", answer != null ? error as string : "RunicNPC did not answer"); + return; + } + + Merge(reply, answer); + id = answer["id"] as string; + break; + } + + case "set": + { + if (string.IsNullOrEmpty(id) || placement == null) + { + EmitNpcError(reqId, "malformed", "set carries `id` and `placement`"); + return; + } + + if (!PlacementExists(npc, id)) + { + EmitNpcError(reqId, "not-found", $"there is no placement '{id}'"); + return; + } + + string error = npc.Call("RunicNpc_SetPlacement", id, placement) as string; + + if (error != null) + { + EmitNpcError(reqId, "refused", error); + return; + } + + reply["id"] = id; + reply["cost"] = npc.Call("RunicNpc_CostWarning", 0) as string; + break; + } + + case "remove": + { + object removed = npc.Call("RunicNpc_RemovePlacement", id); + + if (!(removed is bool) || !(bool)removed) + { + EmitNpcError(reqId, "not-found", $"there is no placement '{id}'"); + return; + } + + reply["id"] = id; + break; + } + + case "rename": + { + string to = StringField(cmd, "to"); + + if (string.IsNullOrEmpty(id) || string.IsNullOrEmpty(to)) + { + EmitNpcError(reqId, "malformed", "rename carries `id` and `to`"); + return; + } + + if (!PlacementExists(npc, id)) + { + EmitNpcError(reqId, "not-found", $"there is no placement '{id}'"); + return; + } + + string error = npc.Call("RunicNpc_RenamePlacement", id, to) as string; + + if (error != null) + { + EmitNpcError(reqId, "refused", error); + return; + } + + reply["id"] = to; + reply["previous"] = id; + break; + } + + case "respawn": + { + object count = npc.Call("RunicNpc_RespawnPlacement", id); + int n = count is int ? (int)count : -1; + + if (n < 0) + { + EmitNpcError(reqId, "not-found", $"there is no placement '{id}'"); + return; + } + + reply["id"] = id; + reply["respawned"] = n; + break; + } + + default: + EmitNpcError(reqId, "malformed", "op is add, set, remove, rename or respawn"); + return; + } + + _lastNpcSummary = $"placement {op} {id}"; + Puts($"runicnpc: {_lastNpcSummary}"); + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + private static bool PlacementExists(Plugin npc, string id) + { + var all = npc.Call("RunicNpc_Placements") as List>; + + if (all == null) + return false; + + foreach (Dictionary row in all) + { + object rowId; + + if (row.TryGetValue("id", out rowId) && rowId as string == id) + return true; + } + + return false; + } + + private void EmitNpcError(string reqId, string reason, string message) + { + Dictionary frame = Frame("npc.error", "reply"); + frame["reason"] = reason; + + if (!string.IsNullOrEmpty(message)) + frame["message"] = message; + + if (reqId != null) + frame["reqId"] = reqId; + + _lastNpcSummary = $"refused ({reason}): {message}"; + EmitFrame(frame); + } + + /// What the last RunicNPC command did, for rg.status. + private string _lastNpcSummary; + + // ---- RunicNPC's hooks, as frames ---- + + /// + /// One of RunicNPC's NPCs died (stage 4). npc.died carries its profile and name, who + /// landed the killing blow, and every player who took health from it, most first — what a + /// phase waiting on "8 guards died" counts, and what a boss's reward goes to (stage 6). + /// The per-player kill count is the tally's (npcProfileKills), not this frame's. + /// + private void OnRunicNpcDied(BasePlayer npc, string profile, string owner, HitInfo info, Dictionary contributors) + { + MarkHook("OnRunicNpcDied"); + + if (npc == null) + return; + + Dictionary frame = Frame("npc.died", "event"); + PutNpc(frame, npc, profile, owner); + + BasePlayer killer = info != null ? info.InitiatorPlayer : null; + + if (killer != null && !killer.IsNpc) + { + frame["killerId"] = ((ulong)killer.userID).ToString(); + frame["killerName"] = killer.displayName; + } + + string weapon = WeaponOf(info); + + if (weapon != null) + frame["weapon"] = weapon; + + var rows = new List(); + + if (contributors != null) + { + var sorted = new List>(contributors); + sorted.Sort((a, b) => b.Value.CompareTo(a.Value)); + + foreach (KeyValuePair kv in sorted) + { + BasePlayer who = BasePlayer.FindByID(kv.Key) ?? BasePlayer.FindSleeping(kv.Key); + var row = new Dictionary + { + { "steamId", kv.Key.ToString() }, + { "damage", (float)Math.Round(kv.Value, 1) } + }; + + if (who != null) + row["name"] = who.displayName; + + rows.Add(row); + } + } + + frame["contributors"] = rows; + EmitFrame(frame); + } + + /// An NPC fell to one of its profile's healthThresholds (a boss below 50%). + private void OnRunicNpcHealth(BasePlayer npc, string profile, float threshold) + { + MarkHook("OnRunicNpcHealth"); + + if (npc == null) + return; + + Dictionary frame = Frame("npc.health", "event"); + PutNpc(frame, npc, profile, null); + frame["threshold"] = threshold; + frame["health"] = (float)Math.Round(npc.health, 1); + frame["maxHealth"] = (float)Math.Round(npc.MaxHealth(), 1); + EmitFrame(frame); + } + + /// + /// A placement was set, removed or renamed, from the site or in game (D249). The frame says + /// which; the site reads the list again rather than trusting a diff. + /// + private void OnRunicNpcPlacementChanged(string id, string change, string previous) + { + MarkHook("OnRunicNpcPlacementChanged"); + + Dictionary frame = Frame("npc.placement.changed", "event"); + frame["id"] = id; + frame["change"] = change; + + if (!string.IsNullOrEmpty(previous)) + frame["previous"] = previous; + + EmitFrame(frame); + } + + /// The fields every NPC frame shares. owner null reads it from the NPC's placement, if any. + // ---- bosses (runicnpc stage 6: D277) ---- + // + // Three frames for the site's public boss triggers: spawned, a phase, killed. A boss's death + // also raises npc.died as every NPC's does; npc.boss.died adds the boss's own facts, and names + // no Steam id but the killer's, because the triggers it feeds are public. + + /// A boss spawned: an admin's placement, an event's step or another plugin. + private void OnRunicNpcBossSpawned(BasePlayer npc, string profile, string owner) + { + MarkHook("OnRunicNpcBossSpawned"); + + if (npc == null) + return; + + Dictionary frame = Frame("npc.boss.spawned", "event"); + PutNpc(frame, npc, profile, owner); + frame["maxHealth"] = (float)Math.Round(npc.MaxHealth(), 1); + EmitFrame(frame); + } + + /// A boss entered a phase: phase counts from 1, at is its health fraction. + private void OnRunicNpcBossPhase(BasePlayer npc, string profile, string owner, int phase, float at) + { + MarkHook("OnRunicNpcBossPhase"); + + if (npc == null) + return; + + Dictionary frame = Frame("npc.boss.phase", "event"); + PutNpc(frame, npc, profile, owner); + frame["phase"] = phase; + frame["at"] = at; + frame["health"] = (float)Math.Round(npc.health, 1); + frame["maxHealth"] = (float)Math.Round(npc.MaxHealth(), 1); + EmitFrame(frame); + } + + /// A boss died: the killing blow's player, and how many players hurt it. + private void OnRunicNpcBossDied(BasePlayer npc, string profile, string owner, HitInfo info, Dictionary contributors) + { + MarkHook("OnRunicNpcBossDied"); + + if (npc == null) + return; + + Dictionary frame = Frame("npc.boss.died", "event"); + PutNpc(frame, npc, profile, owner); + BasePlayer killer = info != null ? info.InitiatorPlayer : null; + + if (killer != null && !killer.IsNpc) + { + frame["killerId"] = ((ulong)killer.userID).ToString(); + frame["killerName"] = killer.displayName; + } + + frame["damagers"] = contributors != null ? contributors.Count : 0; + EmitFrame(frame); + } + + private void PutNpc(Dictionary frame, BasePlayer npc, string profile, string owner) + { + frame["netId"] = npc.net != null ? npc.net.ID.Value.ToString(System.Globalization.CultureInfo.InvariantCulture) : null; + frame["profile"] = profile; + frame["name"] = npc.displayName; + + if (!string.IsNullOrEmpty(owner)) + { + frame["owner"] = owner; + + // An event run's NPC names its run, for the site's phase gates; a placement's names + // the placement. `plugin:` owners are another plugin's business. + if (owner.StartsWith("run:", StringComparison.Ordinal)) + frame["runId"] = owner.Substring(4); + else if (owner.StartsWith("placement:", StringComparison.Ordinal)) + frame["placement"] = owner.Substring(10); + } + + UnityEngine.Vector3 at = npc.transform.position; + frame["x"] = (float)Math.Round(at.x, 1); + frame["z"] = (float)Math.Round(at.z, 1); + } + + // ---- events: an NPC of a profile (D243) ---- + + /// + /// Main thread. world.place with a profile: the NPCs are RunicNPC's, owned by + /// the run (run:<runId>), so its restart and teardown rules are the bridge's as + /// for any placed NPC (§15.3). All or nothing, like a prefab: if RunicNPC refuses one, the + /// ones already spawned are removed. + /// + private void HandleWorldPlaceProfile(Dictionary cmd, string reqId, string key, string runId, string profile) + { + if (!RunicNpcForWorld(reqId)) + return; + + Plugin npc = RunicNpcPlugin(); + var profiles = npc.Call("RunicNpc_Profiles") as Newtonsoft.Json.Linq.JObject; + Newtonsoft.Json.Linq.JToken known = profiles != null ? profiles["profiles"]?[profile] : null; + + if (known == null) + { + EmitWorldError(reqId, "unknown-profile", $"this server has no RunicNPC profile '{profile}'"); + return; + } + + Newtonsoft.Json.Linq.JToken refusal = profiles["refused"]?[profile]; + + if (refusal != null) + { + EmitWorldError(reqId, "unknown-profile", $"RunicNPC refuses the profile '{profile}': {refusal}"); + return; + } + + int count = IntField(cmd, "count"); + int max = _config != null ? _config.EventsMaxNpcs : WorldHardMaxNpcs; + + if (count < 1 || count > max) + { + EmitWorldError(reqId, "out-of-range", $"this server places 1 to {max} NPCs at a time, not {count}"); + return; + } + + double spread = DoubleField(cmd, "spread") ?? (count > 1 ? 10.0 : 0.0); + + if (spread < 0 || spread > WorldMaxSpread) + { + EmitWorldError(reqId, "out-of-range", $"a scatter is 0 to {WorldMaxSpread} m, not {spread}"); + return; + } + + UnityEngine.Vector3 center; + string reason, message; + + if (!ResolveWorldPoint(cmd, out center, out reason, out message)) + { + EmitWorldError(reqId, reason, message); + return; + } + + NpcOrders orders; + + if (!ReadNpcOrders(cmd, runId, center, out orders, out reason, out message)) + { + EmitWorldError(reqId, reason, message); + return; + } + + string owner = "run:" + runId; + var made = new List(); + + // Runicnpc stage 7 (D296): the step's "drop loot" switch. Off, the NPCs keep their corpse's start + // but roll no loot table; on (or absent, a step older than the switch) they drop it as anywhere. + object dropLootValue; + bool dropLoot = !(cmd.TryGetValue("dropLoot", out dropLootValue) && dropLootValue is bool && !(bool)dropLootValue); + Newtonsoft.Json.Linq.JObject spawnOverrides = dropLoot ? null : Newtonsoft.Json.Linq.JObject.Parse("{\"loot\":{\"dropTable\":false}}"); + + for (int i = 0; i < count; i++) + { + BasePlayer spawned = null; + + // A scattered point may land off the navmesh (a rock, a slope); a few tries, then the + // centre itself, before the step is refused. + for (int attempt = 0; attempt < 4 && spawned == null; attempt++) + { + UnityEngine.Vector3 at = center; + + if (count > 1 && attempt < 3) + { + UnityEngine.Vector2 jitter = UnityEngine.Random.insideUnitCircle * (float)spread; + at += new UnityEngine.Vector3(jitter.x, 0f, jitter.y); + at.y = TerrainMeta.HeightMap.GetHeight(at); + } + + spawned = npc.Call("RunicNpc_Spawn", at, profile, owner, spawnOverrides) as BasePlayer; + } + + if (spawned == null) + { + foreach (WorldEntry e in made) + npc.Call("RunicNpc_Despawn", ulong.Parse(e.Id, System.Globalization.CultureInfo.InvariantCulture)); + + EmitWorldError(reqId, "refused", + $"RunicNPC would not spawn '{profile}' there (off the navmesh, a cap, or the navmesh still building; the server log says which)"); + return; + } + + string orderRefusal = ApplyNpcOrders(npc, spawned, orders); + + if (orderRefusal != null) + { + npc.Call("RunicNpc_Despawn", spawned.net.ID.Value); + + foreach (WorldEntry e in made) + npc.Call("RunicNpc_Despawn", ulong.Parse(e.Id, System.Globalization.CultureInfo.InvariantCulture)); + + EmitWorldError(reqId, "refused", $"RunicNPC would not give '{profile}' its orders: {orderRefusal}"); + return; + } + + UnityEngine.Vector3 put = spawned.transform.position; + made.Add(new WorldEntry + { + Id = spawned.net.ID.Value.ToString(System.Globalization.CultureInfo.InvariantCulture), + Kind = WorldKindNpc, + Prefab = RunicNpcPrefix + profile, + RunId = runId, + Key = key, + X = put.x, + Y = put.y, + Z = put.z, + BootId = _bootId, + WipeId = _wipeId, + TakenMs = NowMs() + }); + } + + foreach (WorldEntry e in made) + _world[e.Id] = e; + + SaveWorld(); + + _lastWorldSummary = $"placed {count} of RunicNPC profile {profile} for run {runId}"; + Puts($"world: {_lastWorldSummary}"); + EmitWorldPlaced(reqId, made, false); + } + + /// A registry entry's prefab for a RunicNPC profile: runicnpc:<profile>. + private const string RunicNpcPrefix = "runicnpc:"; + + // ---- an event's NPCs: escort, ally and tether (runicnpc stage 5, D269, D270, D272) ---- + + /// What a placing step tells its NPCs to do besides their profile. Every part is optional. + private sealed class NpcOrders + { + /// D269: the player they escort. + public BasePlayer Escort; + + /// D270: clan (the game's clan id) or player (a Steam id: that player and their team). + public string AllyKind; + public string AllyId; + + /// D272: the zone of this run that holds the step's point. + public string Tether; + + public bool Any => Escort != null || AllyKind != null || Tether != null; + } + + /// + /// Main thread. Reads escort (a Steam id), ally ({kind: clan|player, id}) and + /// tether (true: the run's own zone around the point) from a world.place. False with + /// the reason when one cannot be honoured, so the step is refused before anything spawns. + /// + private bool ReadNpcOrders(Dictionary cmd, string runId, UnityEngine.Vector3 center, + out NpcOrders orders, out string reason, out string message) + { + orders = new NpcOrders(); + reason = null; + message = null; + + string escort = StringField(cmd, "escort"); + + if (!string.IsNullOrEmpty(escort)) + { + ulong steamId; + + if (!ulong.TryParse(escort, out steamId)) + { + reason = "malformed"; + message = $"an escort is a player's Steam id, not '{escort}'"; + return false; + } + + BasePlayer player = BasePlayer.FindByID(steamId); + + if (player == null || !player.IsConnected || player.IsDead()) + { + reason = "escort-offline"; + message = $"the player {steamId} is not on the server, so there is nobody to escort"; + return false; + } + + orders.Escort = player; + } + + object allyValue; + var ally = cmd.TryGetValue("ally", out allyValue) ? allyValue as Newtonsoft.Json.Linq.JObject : null; + + if (ally != null) + { + orders.AllyKind = (string)ally["kind"]; + orders.AllyId = (string)ally["id"]; + + if ((orders.AllyKind != "clan" && orders.AllyKind != "player") || string.IsNullOrEmpty(orders.AllyId)) + { + reason = "malformed"; + message = "an ally is {kind: clan|player, id}"; + return false; + } + } + + object tetherValue; + + if (cmd.TryGetValue("tether", out tetherValue) && tetherValue is bool && (bool)tetherValue) + { + orders.Tether = RunZoneAt(runId, center); + + if (orders.Tether == null) + { + reason = "no-zone"; + message = "no zone this event made holds that point, so there is nothing to keep the NPCs inside"; + return false; + } + } + + if (orders.Any && RunicNpcApi() < 4) + { + reason = "runicnpc-old"; + message = $"escort, ally and tether need RunicNPC's API 4, and it answers {RunicNpcApi()}"; + return false; + } + + return true; + } + + /// Main thread. The zone a run made that holds a point, or null. + private string RunZoneAt(string runId, UnityEngine.Vector3 at) + { + Plugin zm = plugins.Find("ZoneManager"); + + if (zm == null) + return null; + + foreach (WorldEntry e in _world.Values) + { + if (e.Kind != WorldKindZone || e.RunId != runId || ZoneExpired(e)) + continue; + + object inside = zm.Call("IsPositionInZone", e.Id, at); + + if (inside is bool && (bool)inside) + return e.Id; + } + + return null; + } + + /// Main thread. Gives one spawned NPC its orders; null, or RunicNPC's reason for refusing one. + private static string ApplyNpcOrders(Plugin npc, BasePlayer spawned, NpcOrders orders) + { + if (!orders.Any) + return null; + + ulong netId = spawned.net.ID.Value; + string refusal = null; + + if (orders.Escort != null) + refusal = npc.Call("RunicNpc_Escort", netId, (BaseEntity)orders.Escort) as string; + + if (refusal == null && orders.AllyKind != null) + refusal = npc.Call("RunicNpc_Ally", netId, orders.AllyKind, orders.AllyId) as string; + + if (refusal == null && orders.Tether != null) + refusal = npc.Call("RunicNpc_Tether", netId, orders.Tether) as string; + + return refusal; + } + + /// + /// Main thread. Removes a run's RunicNPC NPC through RunicNPC, so it hears of it (and raises + /// its own despawn hook); anything else, or RunicNPC gone, is killed as any entity is. + /// + private bool KillWorldNpc(WorldEntry e) + { + Plugin npc = RunicNpcPlugin(); + ulong netId; + + if (npc != null && e.Prefab != null && e.Prefab.StartsWith(RunicNpcPrefix, StringComparison.Ordinal) + && ulong.TryParse(e.Id, out netId)) + { + object removed = npc.Call("RunicNpc_Despawn", netId); + + if (removed is int && (int)removed > 0) + return true; + } + + return KillEntity(e.Id); + } + + /// RunicNPC as the bridge sees it: loaded, its API, and the last command. + [ConsoleCommand("rg.npc")] + private void CmdNpc(ConsoleSystem.Arg arg) + { + if (arg.Connection != null && !arg.IsAdmin) + return; + + Plugin npc = RunicNpcPlugin(); + arg.ReplyWith(npc == null + ? "RunicNPC: not loaded. npc.* commands and every NPC an event places answer runicnpc-missing (it is required)." + : $"RunicNPC {npc.Version}, API {RunicNpcApi()} (this bridge needs {RunicNpcApiNeeded}). last: {_lastNpcSummary ?? "none yet"}"); + } + // ── Kits ────────────────────────────────────────────────────────────────── private Plugin KitsPlugin() @@ -8324,7 +11507,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; @@ -8818,10 +12001,35 @@ namespace Oxide.Plugins return new Dictionary { { "betterChat", IntegrationBody(BetterChatPlugin()) }, - { "popupNotifications", IntegrationBody(PopupPlugin()) } + { "popupNotifications", IntegrationBody(PopupPlugin()) }, + { "zoneDomes", ZoneDomesBody() }, + { "runicNpc", RunicNpcBody() } }; } + /// + /// ZoneDomes and the domes helper (D194), live on every status frame like the other + /// optional mods: the site offers a dome only where both are here and patched. + /// + private Dictionary ZoneDomesBody() + { + Dictionary body = IntegrationBody(ZoneDomesPlugin()); + var helper = new Dictionary { { "state", ZoneDomesPlugin() == null && DomesHelperPlugin() != null ? "no-zonedomes" : DomesHelperState() } }; + Plugin domes = DomesHelperPlugin(); + + if (domes != null) + { + helper["version"] = domes.Version.ToString(); + + if (domes.Call("StateReason") is string reason) + helper["reason"] = reason; + } + + body["helper"] = helper; + body["ready"] = DomesReady(); + return body; + } + /// A version only for a plugin that is loaded. Absent, not empty. private static Dictionary IntegrationBody(Plugin plugin) { @@ -9881,6 +13089,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; @@ -9892,6 +13120,73 @@ namespace Oxide.Plugins return e == null || e.IsDestroyed || e.transform == null; } + /// + /// Main thread. RunicNPC's NPCs keyed by net id as text (a ledger entry's id), from + /// `RunicNpc_List`; empty when RunicNPC is not loaded or answers anything else. + /// + private Dictionary> RunicNpcsForMap() + { + var byId = new Dictionary>(); + Plugin npc = RunicNpcPlugin(); + + if (npc == null) + return byId; + + var listed = npc.Call("RunicNpc_List", (string)null) as List>; + + if (listed == null) + return byId; + + foreach (Dictionary d in listed) + { + object id; + + if (d != null && d.TryGetValue("netId", out id) && id != null) + byId[id.ToString()] = d; + } + + return byId; + } + + /// + /// A RunicNPC NPC's name, and for a boss `boss` and its health as a fraction. The health is + /// for the event page's progress line; the site's map does not show it (D303). + /// + private static void MapNpcFields(Dictionary row, Dictionary npc) + { + object value; + + if (npc.TryGetValue("name", out value) && value is string) + row["name"] = value; + + if (!npc.TryGetValue("boss", out value) || value == null) + return; + + row["boss"] = true; + + object health, max; + + if (npc.TryGetValue("health", out health) && npc.TryGetValue("maxHealth", out max)) + { + double h = Convert.ToDouble(health), m = Convert.ToDouble(max); + + if (m > 0) + row["health"] = Math.Round(Math.Max(0, Math.Min(1, h / m)), 3); + } + } + + /// Where one of RunicNPC's NPCs stands, from its `RunicNpc_List` row. + private static Dictionary MapNpcAt(Dictionary npc) + { + object value; + var p = npc.TryGetValue("position", out value) ? value as Dictionary : null; + + if (p == null) + return At(UnityEngine.Vector3.zero); + + return At(new UnityEngine.Vector3(Convert.ToSingle(p["x"]), 0f, Convert.ToSingle(p["z"]))); + } + private static Dictionary At(UnityEngine.Vector3 p) { return new Dictionary @@ -9956,6 +13251,10 @@ namespace Oxide.Plugins var events = new List(); + // RunicNPC's own NPCs by net id, for their names, bosses and a boss's adds (runicnpc + // stage 8, D302, D303). Empty without RunicNPC. + Dictionary> runic = RunicNpcsForMap(); + foreach (WorldEntry w in _world.Values) { Dictionary row; @@ -9978,6 +13277,17 @@ namespace Oxide.Plugins row = At(found.transform.position); row["prefab"] = w.Prefab; + + // The step that placed it, so a step's NPCs still standing can be counted. + row["key"] = w.Key; + + Dictionary npc; + + if (runic.TryGetValue(w.Id, out npc)) + { + MapNpcFields(row, npc); + runic.Remove(w.Id); + } } row["kind"] = w.Kind; @@ -9985,6 +13295,37 @@ namespace Oxide.Plugins events.Add(row); } + // What is left of RunicNPC's list was placed by no step. A boss's adds are owned by its + // run (`run:`) and join the events layer under it; any other boss is in the open + // world, as cargo or a helicopter is. A placement's ordinary NPCs are not on the map. + foreach (Dictionary npc in runic.Values) + { + object value; + string owner = npc.TryGetValue("owner", out value) ? value as string : null; + bool boss = npc.TryGetValue("boss", out value) && value != null; + Dictionary row; + + if (owner != null && owner.StartsWith("run:", StringComparison.Ordinal)) + { + row = MapNpcAt(npc); + MapNpcFields(row, npc); + row["kind"] = WorldKindNpc; + row["runId"] = owner.Substring(4); + row["add"] = true; + events.Add(row); + } + else if (boss) + { + row = MapNpcAt(npc); + row["kind"] = "boss"; + + if (npc.TryGetValue("name", out value) && value is string) + row["name"] = value; + + world.Add(row); + } + } + var players = new List(); foreach (BasePlayer p in BasePlayer.activePlayerList) @@ -10208,19 +13549,6 @@ namespace Oxide.Plugins return value == null ? null : value.Trim().ToLowerInvariant(); } - private static HashSet Bucket(Dictionary> map, string key) - { - HashSet bucket; - - if (!map.TryGetValue(key, out bucket)) - { - bucket = new HashSet(); - map[key] = bucket; - } - - return bucket; - } - // ---- diagnostics ---- /// @@ -10260,8 +13588,9 @@ namespace Oxide.Plugins arg.ReplyWith( $"protocol={ProtocolVersion} registered={(registered != null ? registered.Length : 0)} " + $"groups={(groups != null ? groups.Length : 0)} " + - $"running={(_permJob != null)}" + Environment.NewLine + - $"last sync: {_lastPermSummary}"); + $"running={(_permJob != null)} inventoryBuilding={(_inventoryJob != null)}" + Environment.NewLine + + $"last sync: {_lastPermSummary}" + Environment.NewLine + + $"last inventory: {_lastInventorySummary}"); } /// @@ -10279,11 +13608,21 @@ namespace Oxide.Plugins private void CmdConfig(ConsoleSystem.Arg arg) { string root = ConfigRoot(); + ConfigWriteJob job = _configJob; + string waiting = "no"; + + if (job != null) + { + double seconds = (DateTime.UtcNow - job.PhaseStarted).TotalSeconds; + waiting = $"{job.Reload} ({job.Phase.ToString().ToLowerInvariant()}, " + + $"{seconds:0}s of {ConfigReloadCeilingSeconds:0}s)"; + } arg.ReplyWith( $"protocol={ProtocolVersion} framework={(IsCarbon() ? "carbon" : "oxide")} " + $"root={(string.IsNullOrEmpty(root) ? "(none)" : root)}" + Environment.NewLine + - $"waiting={(_configJob != null ? _configJob.Reload : "no")}" + Environment.NewLine + + $"log={NewestLogFile() ?? "(none: a failed reload waits out the ceiling)"}" + Environment.NewLine + + $"waiting={waiting}" + Environment.NewLine + $"last write: {_lastConfigSummary}"); } diff --git a/overlay/oxide/plugins/RunicGatewayDomes.cs b/overlay/oxide/plugins/RunicGatewayDomes.cs new file mode 100644 index 0000000..5bba137 --- /dev/null +++ b/overlay/oxide/plugins/RunicGatewayDomes.cs @@ -0,0 +1,261 @@ +// Runic Gateway Domes — a helper for ZoneDomes, shipped beside the Runic Gateway bridge. +// Copyright (C) Runic Gateway contributors. GPL-3.0-or-later. +// +// ONE JOB (docs/modules/rust/PLAN_REDESIGNS.md §3.2, D168, D194): +// +// ZoneDomes draws a dome over a ZoneManager zone, and the bridge asks it to for an event zone +// whose step wants one. Its API was written for a person at a keyboard: `AddNewDome` and +// `RemoveExistingDome` both end by telling the player who asked how it went, with no check that +// there was one. Called by a plugin with no player, the dome is made and saved, and THEN the call +// throws — so the caller cannot tell a made dome from a failed one (plan §0.5, found on the rig). +// +// So this helper patches ZoneDomes with Harmony, the same way the zone helper patches ZoneManager +// (D181): +// +// * **A message with no player is not sent.** A prefix on ZoneDomes' `TranslateMessage(BasePlayer, +// string, string)` skips it when the player is null. Nothing else about the call changes, so +// `AddNewDome` returns its true or false normally. +// * **It says when ZoneDomes has finished starting.** ZoneDomes' own start (`InitializeDomes`) +// drops every dome whose zone does not exist yet and draws every one that does. A dome added +// BEFORE that is drawn a second time, and the first set of spheres stays in the world with +// nothing holding it. A postfix there raises `OnRgDomesReady`, and the bridge puts its domes +// back only after it. +// +// It is optional. Without it the bridge offers no dome and says why; the zones themselves are +// unaffected. `rgd.status` in the server console prints its state. + +using System; +using System.Reflection; +using HarmonyLib; +using Oxide.Core; +using Oxide.Core.Plugins; + +namespace Oxide.Plugins +{ + [Info("Runic Gateway Domes", "Runic Gateway", "0.1.0")] + [Description("Helper for Runic Gateway: ZoneDomes can be called without a player, and says when it has started.")] + public class RunicGatewayDomes : RustPlugin + { + private const string HarmonyId = "runicgateway.domes"; + + /// The instance the static patches report to. Null while unloaded. + private static RunicGatewayDomes _instance; + + private Harmony _harmony; + private MethodInfo _message; + private MethodInfo _initialize; + + /// patched, unsupported (ZoneDomes is here but not the shape patched), or no-zonedomes. + private string _state = "no-zonedomes"; + private string _reason; + + /// Whether ZoneDomes has finished its own start since it was patched. Main thread. + private bool _ready; + + /// Set once the server is up, at this plugin's own OnServerInitialized. + private bool _serverUp; + + // Patched as EARLY as possible — at load, or the moment ZoneDomes loads — so that at a real + // server start the patch is in before ZoneDomes' `OnServerInitialized` runs its start. + + private void Loaded() + { + _instance = this; + Patch(); + } + + private void OnServerInitialized(bool initial) + { + _serverUp = true; + + // Loaded into a running server: ZoneDomes, if it is here, started long ago. + if (!initial) + MarkReadyNextTick(); + } + + private void Unload() + { + Unpatch(); + _instance = null; + } + + private void OnPluginLoaded(Plugin plugin) + { + if (plugin == null || plugin.Name != "ZoneDomes") + return; + + Unpatch(); + Patch(); + + // A ZoneDomes loaded into a running server has run its start by now: the framework + // calls a hot-loaded plugin's `OnServerInitialized` before it tells anybody else it + // loaded. The tick is margin, and costs nothing. + if (_serverUp) + MarkReadyNextTick(); + } + + private void OnPluginUnloaded(Plugin plugin) + { + if (plugin != null && plugin.Name == "ZoneDomes") + { + Unpatch(); + _state = "no-zonedomes"; + _reason = null; + } + } + + // ── The API the bridge calls ───────────────────────────────────────────── + + /// patched, unsupported or no-zonedomes. + private string State() => _state; + + /// Why the state is not patched, or null. + private string StateReason() => _reason; + + /// Whether ZoneDomes is patched and past its own start, so a dome may be added. + private bool Ready() => _state == "patched" && _ready; + + /// The helper's own version, for hello. + private string HelperVersion() => Version.ToString(); + + // ── Patching ───────────────────────────────────────────────────────────── + + private void Patch() + { + _ready = false; + Plugin zoneDomes = plugins.Find("ZoneDomes"); + + if (zoneDomes == null) + { + _state = "no-zonedomes"; + _reason = null; + return; + } + + const BindingFlags any = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + Type type = zoneDomes.GetType(); + + _message = type.GetMethod("TranslateMessage", any, null, new[] { typeof(BasePlayer), typeof(string), typeof(string) }, null); + _initialize = type.GetMethod("InitializeDomes", any, null, Type.EmptyTypes, null); + + string missing = + _message == null ? "TranslateMessage(BasePlayer, string, string)" : + _initialize == null ? "InitializeDomes()" : + type.GetMethod("AddNewDome", any) == null ? "AddNewDome" : + type.GetMethod("RemoveExistingDome", any) == null ? "RemoveExistingDome" : + null; + + if (missing != null) + { + Fail($"this ZoneDomes ({zoneDomes.Version}) has no {missing}"); + return; + } + + try + { + _harmony = new Harmony(HarmonyId); + _harmony.Patch(_message, prefix: new HarmonyMethod( + typeof(RunicGatewayDomes).GetMethod(nameof(BeforeMessage), BindingFlags.Static | BindingFlags.NonPublic))); + _harmony.Patch(_initialize, postfix: new HarmonyMethod( + typeof(RunicGatewayDomes).GetMethod(nameof(AfterInitialize), BindingFlags.Static | BindingFlags.NonPublic))); + } + catch (Exception ex) + { + Fail($"Harmony could not patch ZoneDomes: {ex.Message}"); + return; + } + + _state = "patched"; + _reason = null; + Puts($"patched ZoneDomes {zoneDomes.Version}: its API can be called without a player"); + } + + private void Unpatch() + { + if (_harmony != null) + { + try + { + if (_message != null) + _harmony.Unpatch(_message, HarmonyPatchType.Prefix, HarmonyId); + + if (_initialize != null) + _harmony.Unpatch(_initialize, HarmonyPatchType.Postfix, HarmonyId); + } + catch (Exception ex) + { + PrintWarning($"could not remove the ZoneDomes patches: {ex.Message}"); + } + } + + _harmony = null; + _message = null; + _initialize = null; + _ready = false; + } + + private void Fail(string reason) + { + Unpatch(); + _state = "unsupported"; + _reason = reason; + PrintWarning($"{reason}. Runic Gateway will not offer a dome over its zones on this server."); + } + + private void MarkReadyNextTick() + { + NextTick(() => + { + if (_state != "patched" || _ready) + return; + + _ready = true; + Interface.CallHook("OnRgDomesReady"); + }); + } + + // ── The patches ────────────────────────────────────────────────────────── + + /// + /// Harmony prefix on ZoneDomes.TranslateMessage(BasePlayer, string, string). + /// Returning false skips the message; a real player still gets it. + /// + private static bool BeforeMessage(BasePlayer player) + { + return player != null; + } + + /// + /// Harmony postfix on ZoneDomes.InitializeDomes. Anything thrown here would surface + /// inside ZoneDomes, so nothing is allowed to. + /// + private static void AfterInitialize() + { + try + { + RunicGatewayDomes self = _instance; + + if (self == null || self._state != "patched") + return; + + self._ready = true; + Interface.CallHook("OnRgDomesReady"); + } + catch (Exception) + { + // Never ZoneDomes' problem. + } + } + + // ── Console ────────────────────────────────────────────────────────────── + + [ConsoleCommand("rgd.status")] + private void StatusCommand(ConsoleSystem.Arg arg) + { + if (arg.Connection != null && !arg.IsAdmin) + return; + + arg.ReplyWith($"Runic Gateway Domes {Version}: {_state}{(_state == "patched" ? (_ready ? ", ready" : ", waiting for ZoneDomes to start") : "")}{(_reason != null ? $" ({_reason})" : "")}"); + } + } +} diff --git a/overlay/oxide/plugins/RunicGatewayZones.cs b/overlay/oxide/plugins/RunicGatewayZones.cs new file mode 100644 index 0000000..ed28130 --- /dev/null +++ b/overlay/oxide/plugins/RunicGatewayZones.cs @@ -0,0 +1,376 @@ +// Runic Gateway Zones — a helper for ZoneManager, shipped beside the Runic Gateway bridge. +// Copyright (C) Runic Gateway contributors. GPL-3.0-or-later. +// +// ITS FIRST JOB (docs/modules/rust/PLAN_FIXES.md F12, D168, D181, D182): +// +// ZoneManager counts a player as inside a zone only when the zone's trigger fires on ENTRY. A +// zone created, updated or loaded around somebody who is already standing there never fires it, +// so ZoneManager says nobody is inside — and its flags, its enter message and `OnEnterZone` never +// happen for them. The first player walk found it twice: after a restart the org lead woke up +// eighteen metres inside an event zone the bridge had just re-created, and ZoneManager's +// `IsPlayerInZone` said no; and a zone opened around a player did the same. Every participant of +// an event that survives a restart scores nothing until they walk out and back in. +// +// ZoneManager has no public way to ask it to look again: the routine that does (`UpdatePlayerZones`) +// is private and runs only for a player who is connecting. So this helper patches ZoneManager with +// Harmony, which is the org lead's decision (D181): +// +// * **The patch point is `Zone.InitializeZone`**, the one method a zone passes through when it is +// created (`CreateOrUpdateZone`), updated (`Zone.Reset`) or loaded from ZoneManager's own data +// file at startup. A postfix there sees every one of them. +// * **It does not decide who is inside.** One tick later — when ZoneManager has finished +// registering the zone — it asks ZoneManager's own public `IsPositionInZone` for each connected +// player, and enters those it names through ZoneManager's own `OnPlayerEnterZone`. So ZoneManager's +// flags, eject and kill rules, enter message and `OnEnterZone` all apply exactly as if the +// player had walked in. A player ZoneManager already counts is skipped, and ZoneManager refuses +// a second entry anyway, so a trigger that does fire after all changes nothing. +// * **It fixes every zone on the server**, not only the bridge's. That is deliberate: the fault is +// ZoneManager's, and it is the same fault for an arena another plugin made. +// +// A SECOND JOB (RunicNPC stage 9d walk, the org lead's call, 2026-10-07): +// +// ZoneManager subscribes the hooks that enforce its flags (`OnEntityTakeDamage` for PvpGod and the +// other damage flags, `OnEntityBuilt` for NoBuild, and so on) only for the flags some zone had when +// ZoneManager loaded, or when a flag is toggled by command. A zone made later through its API — +// which is every zone an event opens — never re-subscribes them, so on a server with no such zone +// already, an event's PvpGod or NoBuild zone enforces nothing: the 9d walk shot a player dead and +// built a foundation inside one. The same postfix that sees every zone made therefore also has +// ZoneManager run its own `UpdateHookSubscriptions` on the next tick. ZoneManager decides what to +// subscribe; this only asks it to look. +// +// It is optional. The bridge never depends on it to load: without it — deleted, or loaded but +// unable to find what it patches after a ZoneManager update — the bridge scores the zones it made +// by `IsPositionInZone` instead (D182), and reports this helper's state at hello so the site can +// say what is missing. `rgz.status` in the server console prints the same state. + +using System; +using System.Collections.Generic; +using System.Reflection; +using HarmonyLib; +using Oxide.Core.Plugins; +using UnityEngine; + +namespace Oxide.Plugins +{ + [Info("Runic Gateway Zones", "Runic Gateway", "0.2.0")] + [Description("Helper for Runic Gateway: ZoneManager counts players already inside a zone when it is created, updated or loaded, and enforces a new zone's flags.")] + public class RunicGatewayZones : RustPlugin + { + private const string HarmonyId = "runicgateway.zones"; + + /// The instance the static postfix reports to. Null while unloaded. + private static RunicGatewayZones _instance; + + private Harmony _harmony; + private MethodInfo _patched; + + // Reflected once per ZoneManager load. A ZoneManager reload is a new assembly, so all of it + // is looked up again then. + private Plugin _zoneManager; + private MethodInfo _enter; + private MethodInfo _getZone; + private FieldInfo _definition; + private PropertyInfo _id; + + /// ZoneManager's private UpdateHookSubscriptions, or null when this ZoneManager has none (the second job is then off). + private MethodInfo _updateHooks; + + /// + /// What the bridge reads through : patched, unsupported + /// (ZoneManager is here but not the shape this helper patches), or no-zonemanager. + /// + private string _state = "no-zonemanager"; + private string _reason; + + /// Zones initialised this tick, swept once on the next. Main thread. + private readonly Dictionary _pending = new Dictionary(); + private bool _sweepQueued; + + private void OnServerInitialized() + { + _instance = this; + Patch(); + } + + private void Unload() + { + Unpatch(); + _instance = null; + } + + private void OnPluginLoaded(Plugin plugin) + { + if (plugin != null && plugin.Name == "ZoneManager") + { + Unpatch(); + Patch(); + } + } + + private void OnPluginUnloaded(Plugin plugin) + { + if (plugin != null && plugin.Name == "ZoneManager") + { + Unpatch(); + _state = "no-zonemanager"; + _reason = null; + } + } + + // ── The API the bridge calls ───────────────────────────────────────────── + + /// patched, unsupported or no-zonemanager. + private string State() => _state; + + /// Why the state is not patched, or null. + private string StateReason() => _reason; + + /// The helper's own version, for hello. + private string HelperVersion() => Version.ToString(); + + // ── Patching ───────────────────────────────────────────────────────────── + + private void Patch() + { + _zoneManager = plugins.Find("ZoneManager"); + + if (_zoneManager == null) + { + _state = "no-zonemanager"; + _reason = null; + return; + } + + const BindingFlags any = BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic; + + Type zmType = _zoneManager.GetType(); + Type zoneType = zmType.GetNestedType("Zone", BindingFlags.Public | BindingFlags.NonPublic); + MethodInfo initialize = zoneType?.GetMethod("InitializeZone", any); + + _getZone = zmType.GetMethod("GetZoneByID", any, null, new[] { typeof(string) }, null); + _updateHooks = zmType.GetMethod("UpdateHookSubscriptions", any, null, Type.EmptyTypes, null); + _definition = zoneType?.GetField("definition", any); + _id = _definition?.FieldType.GetProperty("Id", any); + _enter = zoneType == null + ? null + : zmType.GetMethod("OnPlayerEnterZone", any, null, new[] { typeof(BasePlayer), zoneType }, null); + + string missing = + zoneType == null ? "ZoneManager.Zone" : + initialize == null ? "Zone.InitializeZone" : + _definition == null || _id == null ? "Zone.definition.Id" : + _enter == null ? "ZoneManager.OnPlayerEnterZone(BasePlayer, Zone)" : + _getZone == null ? "ZoneManager.GetZoneByID(string)" : + null; + + if (missing != null) + { + Fail($"this ZoneManager ({_zoneManager.Version}) has no {missing}"); + return; + } + + try + { + _harmony = new Harmony(HarmonyId); + _harmony.Patch(initialize, postfix: new HarmonyMethod( + typeof(RunicGatewayZones).GetMethod(nameof(AfterInitialize), BindingFlags.Static | BindingFlags.NonPublic))); + _patched = initialize; + } + catch (Exception ex) + { + Fail($"Harmony could not patch Zone.InitializeZone: {ex.Message}"); + return; + } + + _state = "patched"; + _reason = null; + Puts($"patched ZoneManager {_zoneManager.Version}: a zone takes in the players already inside it" + + (_updateHooks != null ? ", and a new zone's flags are enforced" : "")); + if (_updateHooks == null) + PrintWarning($"this ZoneManager ({_zoneManager.Version}) has no UpdateHookSubscriptions: a zone made after it loaded may not enforce its flags until ZoneManager reloads"); + + // The zones ZoneManager loaded before this helper did were initialised unwatched — + // which, at a server start, is all of them. Sweep them once now. + RefreshHooks(); + SweepAll(); + } + + private void Unpatch() + { + if (_harmony != null && _patched != null) + { + try + { + _harmony.Unpatch(_patched, HarmonyPatchType.Postfix, HarmonyId); + } + catch (Exception ex) + { + PrintWarning($"could not remove the ZoneManager patch: {ex.Message}"); + } + } + + _harmony = null; + _patched = null; + _pending.Clear(); + } + + private void Fail(string reason) + { + Unpatch(); + _state = "unsupported"; + _reason = reason; + PrintWarning($"{reason}. Runic Gateway scores its zones by position instead; ZoneManager's own flags will not apply to somebody already inside a new zone."); + } + + // ── The patch ──────────────────────────────────────────────────────────── + + /// + /// Harmony postfix on ZoneManager.Zone.InitializeZone. Static, as Harmony needs; + /// it only records the zone. Anything thrown here would surface inside ZoneManager, so + /// nothing is allowed to. + /// + private static void AfterInitialize(object __instance) + { + try + { + _instance?.Queue(__instance); + } + catch (Exception) + { + // Never ZoneManager's problem. + } + } + + private void Queue(object zone) + { + object definition = _definition.GetValue(zone); + string id = definition == null ? null : _id.GetValue(definition) as string; + + if (string.IsNullOrEmpty(id)) + return; + + // The newest instance wins: a zone re-created in the same tick is one sweep. + _pending[id] = zone; + + if (_sweepQueued) + return; + + _sweepQueued = true; + + // A tick later, not now. `CreateOrUpdateZone` initialises a zone once with an empty + // definition and again after the arguments are applied, and ZoneManager's startup load + // registers the zone in its table only AFTER initialising it — so `IsPositionInZone` + // answers correctly on the next tick and not before. + NextTick(SweepPending); + } + + private void SweepPending() + { + _sweepQueued = false; + + if (_state != "patched" || _pending.Count == 0) + { + _pending.Clear(); + return; + } + + var batch = new List>(_pending); + _pending.Clear(); + + // The second job first: the hooks that enforce the new zones' flags, then the players. + RefreshHooks(); + + foreach (KeyValuePair entry in batch) + Sweep(entry.Key, entry.Value); + } + + /// + /// Main thread. Has ZoneManager subscribe the hooks its zones' flags now need. ZoneManager + /// only adds subscriptions here, and subscribing a hook twice is a no-op. + /// + private void RefreshHooks() + { + if (_updateHooks == null || _zoneManager == null) + return; + + try + { + _updateHooks.Invoke(_zoneManager, null); + } + catch (Exception ex) + { + PrintWarning($"could not have ZoneManager refresh its hooks: {(ex.InnerException ?? ex).Message}"); + } + } + + private void SweepAll() + { + var ids = _zoneManager.Call("GetZoneIDs") as string[]; + + if (ids == null) + return; + + foreach (string id in ids) + Sweep(id, null); + } + + /// + /// Main thread. Enters every connected player ZoneManager says is inside the zone and + /// does not yet count. is the instance to enter them into, or null + /// to look it up (the startup sweep). + /// + private void Sweep(string id, object zone) + { + Plugin zm = _zoneManager; + + if (zm == null || zm.Call("CheckZoneID", id) == null) + return; + + zone = zone ?? _getZone.Invoke(zm, new object[] { id }); + + if (zone == null || (zone is UnityEngine.Object unity && unity == null)) + return; + + int entered = 0; + + // A copy: entering a player can eject or kill them, which changes the live list. + // `activePlayerList` is Facepunch's ListHashSet, which has no ToArray. + foreach (BasePlayer player in new List(BasePlayer.activePlayerList)) + { + if (player == null || player.IsNpc || player.transform == null) + continue; + + if (zm.Call("IsPlayerInZone", id, player) is bool already && already) + continue; + + if (!(zm.Call("IsPositionInZone", id, player.transform.position) is bool inside) || !inside) + continue; + + try + { + _enter.Invoke(zm, new[] { (object)player, zone }); + entered++; + } + catch (Exception ex) + { + PrintWarning($"could not enter {player.displayName} into zone {id}: {(ex.InnerException ?? ex).Message}"); + } + } + + if (entered > 0) + Puts($"zone {id}: {entered} player(s) already inside were counted in"); + } + + // ── Console ────────────────────────────────────────────────────────────── + + [ConsoleCommand("rgz.status")] + private void StatusCommand(ConsoleSystem.Arg arg) + { + if (arg.Connection != null && !arg.IsAdmin) + return; + + arg.ReplyWith($"Runic Gateway Zones {Version}: {_state}{(_reason != null ? $" ({_reason})" : "")}" + + $"; new zones' flags {(_updateHooks != null ? "enforced" : "NOT refreshed (no UpdateHookSubscriptions)")}"); + } + } +} diff --git a/scripts/checkPlugin.js b/scripts/checkPlugin.js index d7ac002..bf4e078 100644 --- a/scripts/checkPlugin.js +++ b/scripts/checkPlugin.js @@ -198,6 +198,14 @@ function check(source, toml) { // 5. The two declaration sites this repository owns must agree. const inCode = /ProtocolVersion\s*=\s*(\d+)\s*;/.exec(source) + // runicnpc stage 4: the RunicNPC API the bridge needs, declared twice like the protocol. + const npcToml = /^\s*runicnpc_api\s*=\s*(\d+)\s*$/m.exec(toml) + const npcCode = /const\s+int\s+RunicNpcApiNeeded\s*=\s*(\d+)\s*;/.exec(source) + if (npcCode && !npcToml) problems.push('the plugin needs a RunicNPC API (RunicNpcApiNeeded) and overlay.toml declares no runicnpc_api') + if (npcCode && npcToml && npcCode[1] !== npcToml[1]) { + problems.push(`the plugin needs RunicNPC API ${npcCode[1]} and overlay.toml declares runicnpc_api = ${npcToml[1]}. The bundle picks a RunicNPC by the second, so they must agree.`) + } + const inToml = /^\s*protocol\s*=\s*(\d+)\s*$/m.exec(toml) if (!inCode) problems.push('could not read ProtocolVersion from the plugin source')