From adb9df6a2ea38c1918fda448c3429e55044bbd2d Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 15 Oct 2026 00:39:00 -0500 Subject: [PATCH] =?UTF-8?q?feat(bridge):=20Rust's=20own=20world=20events?= =?UTF-8?q?=20on=20protocol=2014=20(PLAN=5FREDESIGNS=20=C2=A714.3.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 1 of the §14.3 integration plan: the patrol helicopter, Bradley, the cargo ship, the Chinook, airdrops, locked crates, the Travelling Vendor, the satellite and the Deep Sea, with no plugin (D349). - `world.event`, one frame kind whose `event` names which of 24 types (PROTOCOL.md §20.5). The type is in `event`, never `type`: `type` is the envelope's, and the rig's sidecar dropped every world event as untyped when the plan's example put it there. Nothing is said before the world loads or while the server quits. Positions are x/z; the site works out the grid. - Credit (D372, D379): a damage table for the patrol helicopter, the Chinook and Bradley; `heli.downed`, `chinook.downed` and `bradley.destroyed` carry every candidate (blow, last, most, damagers) and the plugin credits nobody. D174's `CreditVehicle` and the tally's apcKills/heliKills are gone. - Locked crates told positively (oil rigs, cargo, ghost ship, a Chinook drop joined at the crate's landing); anything else says nothing. A Bradley's `where` comes from the game's spawners, not `RoadSpawned` (which defaults to true). `airdrop.called` comes from the cargo plane's spawn: `supply.call` raised neither OnAirdrop nor OnCargoPlaneSignaled. - The tally gains cratesHacked and dropsLooted (Safecracker, Scavenger, D356). - `world.call` (D354, D367, D378): one of a schedule's TriggeredEvents at random, without Trigger (which would reset its clock); satellite.random for the satellite. A position for the airdrop (InitDropPosition) and the patrol helicopter (SetInitialDestination) only, both set before the spawn, as the stage-1 spike measured (0-1 m and 10-54 m). Kept; the key is remembered seven days in world-calls.json; EventsMaxWorldCalls (default and ceiling 10) per run. - Hello/status `console.plugins` (D363): the bridge runs its own rg.probe through ConsoleSystem.Run from a timer. Carbon 2.0.262 refuses plugin commands only inside a console command (or a NextTick one queued), which is how §14.2's spike tested; from a timer, and from the link reader's NextTick, it finds them. Reads true on both rigs. - checkPlugin.js: the probe must name a [ConsoleCommand] the plugin declares. Compiled and loaded on both rigs (Rust 25902425, Oxide 2.0.7830, Carbon 2.0.262); heli.arrived, cargo.arrived and airdrop.called read back from each rig's sidecar store. Rigs restored. 18 check tests pass. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- README.md | 5 + overlay.toml | 5 +- overlay/oxide/plugins/RunicGateway.cs | 1555 ++++++++++++++++++++++++- scripts/checkPlugin.js | 27 +- scripts/checkPlugin.test.js | 54 +- 5 files changed, 1578 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index 1b63818..faf0d7e 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,11 @@ backpack from the site, a player can see their own, and an event can lend a bigg the bridge only ever reads a backpack, and the bigger size is one of Backpacks' own `backpacks.size.N` permissions, pushed like any other grant. +Rust's own world events need no plugin at all. The patrol helicopter, Bradley, the cargo ship, the +Chinook, airdrops, locked crates, the Travelling Vendor, the satellite and the Deep Sea reach the +site's feed and its event triggers on every server, and an event can call one of them (`world.call`). +What a call starts is kept when the event ends; `EventsMaxWorldCalls` caps how many one run may call. + ## Diagnosing it ``` diff --git a/overlay.toml b/overlay.toml index 33cd1eb..568138a 100644 --- a/overlay.toml +++ b/overlay.toml @@ -38,7 +38,10 @@ # messages a `perm.sync` carries as `notices`; and Economics (PROTOCOL.md §20.3): # `economics.pay`, `economics.balances`, `economics.adjust`, the tally's # `balance` and the `economics.wiped` event; and Backpacks (PROTOCOL.md §20.4): -# `backpacks.read` and hello's `integrations.backpacks.sizes`. +# `backpacks.read` and hello's `integrations.backpacks.sizes`; and Rust's own world +# events (PROTOCOL.md §20.5): `world.event` with its `event`, `world.call`, hello's +# `console`, and the tally's `cratesHacked` and `dropsLooted` in place of `apcKills` +# and `heliKills`. protocol = 14 # ── Oxide compatibility ────────────────────────────────────────────────────── diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index e89531f..989d3e3 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -63,7 +63,11 @@ namespace Oxide.Plugins /// Economics (§20.3, PLAN_REDESIGNS.md §11.2): economics.pay, /// economics.balances, economics.adjust, the tally's balance and the /// economics.wiped event; and Backpacks (§20.4, PLAN_REDESIGNS.md §11.3): - /// backpacks.read and hello's integrations.backpacks.sizes. + /// backpacks.read and hello's integrations.backpacks.sizes; and Rust's own + /// world events (§20.5, PLAN_REDESIGNS.md §14.3.1): the world.event kind and its + /// 24 types, world.call, hello's console.plugins, and the tally's + /// cratesHacked and dropsLooted, where apcKills and + /// heliKills are no longer sent. /// /// private const int ProtocolVersion = 14; @@ -261,7 +265,13 @@ namespace Oxide.Plugins "OnRunicNpcDied", "OnRunicNpcHealth", "OnRunicNpcPlacementChanged", "OnRunicNpcBossSpawned", "OnRunicNpcBossPhase", "OnRunicNpcBossDied", "OnPlayerSleepEnded", - "OnEconomicsBalanceUpdated", "OnEconomicsDataWiped" + "OnEconomicsBalanceUpdated", "OnEconomicsDataWiped", + // Protocol 14, Rust's own world events (§20.5). + "OnPatrolHelicopterKill", "OnHelicopterRetire", "OnBradleyApcInitialize", + "OnCargoShipHarborApproach", "OnCargoShipHarborArrived", "OnCargoShipHarborLeave", "OnCargoShipEgress", + "OnHelicopterDropCrate", "OnCrateLanded", "CanHackCrate", "OnCrateHack", "OnCrateHackEnd", "OnLootEntity", + "OnCargoPlaneSignaled", "OnAirdrop", "OnSupplyDropDropped", "OnSupplyDropLanded", + "OnDeepSeaOpened", "OnDeepSeaClosed" }; private void Init() @@ -444,6 +454,13 @@ namespace Oxide.Plugins /// The longest line an event or a news post may say in chat, in characters. public int EventsMaxChat = ChatHardMax; + /// + /// The most world events one run may call on this server (protocol 14, D354): patrol + /// helicopters, cargo ships, airdrops and the rest, each counted. Lowerable, like the + /// bounds above; is the ceiling. + /// + public int EventsMaxWorldCalls = WorldCallsHardMax; + /// /// The most offline sleepers one map answer carries (protocol 11). Over it, the layer /// answers the first this many and says it was cut short. @@ -526,12 +543,13 @@ namespace Oxide.Plugins _config.EventsMaxRecipients = Math.Max(0, Math.Min(_config.EventsMaxRecipients, RecipientsHardMax)); _config.EventsMaxTallies = Math.Max(0, Math.Min(_config.EventsMaxTallies, TallyHardMax)); _config.EventsMaxChat = Math.Max(0, Math.Min(_config.EventsMaxChat, ChatHardMax)); + _config.EventsMaxWorldCalls = Math.Max(0, Math.Min(_config.EventsMaxWorldCalls, WorldCallsHardMax)); _config.MapMaxSleepers = Math.Max(0, _config.MapMaxSleepers); _config.MapMaxBases = Math.Max(0, _config.MapMaxBases); if (Config["EventsEnabled"] == null || Config["EventsMaxCrates"] == null || Config["EventsMaxNpcs"] == null || Config["EventsMaxRecipients"] == null || Config["EventsMaxTallies"] == null || Config["EventsMaxChat"] == null || - Config["MapMaxSleepers"] == null || Config["MapMaxBases"] == null) + Config["EventsMaxWorldCalls"] == null || Config["MapMaxSleepers"] == null || Config["MapMaxBases"] == null) Config.WriteObject(_config, true); } @@ -545,6 +563,7 @@ namespace Oxide.Plugins LoadLeases(); LoadWorld(); LoadRewards(); + LoadWorldCalls(); } // ---- the outbound queue ---- @@ -1037,6 +1056,12 @@ namespace Oxide.Plugins HandleWorldOwned(cmd, reqId); break; + case "world.call": + // Protocol 14 (PROTOCOL.md §20.5, D354). One of the game's own world events, + // now: kept, never reverted. + HandleWorldCall(cmd, reqId); + break; + case "tally.open": // Protocol 10 (PLAN.md §29). Who takes part in a run, counted by the game. HandleTallyOpen(cmd, reqId); @@ -1327,6 +1352,9 @@ namespace Oxide.Plugins // a title or a popup is not showing before anybody goes looking (§33.2). status["integrations"] = IntegrationsBody(); + // Protocol 14 (D363): whether another plugin's console command can be run from here. + status["console"] = ConsoleBody(); + return status; } @@ -1596,6 +1624,10 @@ namespace Oxide.Plugins // The map's sets are seeded here for the same reason: a hotload hears no spawns. SeedMapSets(); + // Protocol 14: the console probe (D363) and the world events' sweep. Before hello, which + // carries what the probe found. + StartWorldEvents(); + // The titles this plugin last held, if they are this wipe's (D144), and then the getter // handed to BetterChat, if it is here. When it loads later, or reloads and forgets us, // OnPluginLoaded does this again (§33.1). @@ -1654,6 +1686,7 @@ namespace Oxide.Plugins // this plugin holds. _tallies.Clear(); _connectedAt.Clear(); + ClearWorldEvents(); // Nor is anything an event placed: the old map, and everything on it, is gone. DropWorldFromOtherWipes(); @@ -1877,10 +1910,11 @@ namespace Oxide.Plugins 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). + // crash's HitInfo names no player (§5.4). Protocol 14: the death is a world event that + // carries every candidate, and the site credits Rotorbane and Tankbreaker (D372). if (IsVehicleKill(entity)) { - CreditVehicle(entity, attacker); + WorldVehicleDied(entity, attacker); return; } @@ -1991,13 +2025,6 @@ namespace Oxide.Plugins // `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) { @@ -2007,7 +2034,9 @@ namespace Oxide.Plugins /// /// 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. + /// vehicles is remembered — who dealt it and how much — for the credit block its death + /// carries (protocol 14, D372). A helicopter that flies off the map is never killed; + /// the world events' sweep forgets its row. /// private void OnEntityTakeDamage(BaseCombatEntity entity, HitInfo info) { @@ -2020,49 +2049,7 @@ namespace Oxide.Plugins 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++; + NoteVehicleHit(entity, player, info); } /// @@ -2867,8 +2854,15 @@ namespace Oxide.Plugins 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; + + // APC and helicopter kills left the tally at protocol 14: they are counted by the site + // from `world.event`'s credit block, by the server's own rule (D372). + + /// Protocol 14 (D356): locked crates this player started hacking, once per crate (Safecracker). + public int CratesHacked; + + /// Protocol 14 (D356): airdrops this player looted first (Scavenger). + public int DropsLooted; public readonly Dictionary Crafted = new Dictionary(); /// Stage 4 (D247): kills of RunicNPC's NPCs, by profile name. Also in . @@ -2936,7 +2930,7 @@ namespace Oxide.Plugins { return Gathered.Count == 0 && NpcKills == 0 && Structures == 0 && AnimalKills == 0 && WeaponKills.Count == 0 && Headshots == 0 - && BestPvpM <= 0f && BestPveM <= 0f && ApcKills == 0 && HeliKills == 0 + && BestPvpM <= 0f && BestPveM <= 0f && CratesHacked == 0 && DropsLooted == 0 && Crafted.Count == 0 && Built == 0 && Repaired == 0 && Healed == 0 && Rockets == 0 && Explosives == 0 && Missions == 0 && NpcProfileKills.Count == 0 && !HasBalance; @@ -3037,10 +3031,10 @@ namespace Oxide.Plugins 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.CratesHacked > 0) + frame["cratesHacked"] = tally.CratesHacked; + if (tally.DropsLooted > 0) + frame["dropsLooted"] = tally.DropsLooted; if (tally.Crafted.Count > 0) frame["crafted"] = tally.Crafted; if (tally.Built > 0) @@ -8855,6 +8849,7 @@ namespace Oxide.Plugins reply["eventsEnabled"] = _config != null && _config.EventsEnabled; reply["maxCrates"] = _config != null ? _config.EventsMaxCrates : WorldHardMaxCrates; reply["maxNpcs"] = _config != null ? _config.EventsMaxNpcs : WorldHardMaxNpcs; + reply["maxWorldCalls"] = WorldCallsLimit(); reply["zoneManager"] = ZoneManagerPlugin() != null; if (reqId != null) @@ -9780,7 +9775,9 @@ namespace Oxide.Plugins arg.ReplyWith( $"protocol={ProtocolVersion} eventsEnabled={(_config != null && _config.EventsEnabled)} " + $"zones={zones} crates={crates} npcs={npcs} zoneManager={(ZoneManagerPlugin() != null)} " + - $"wipeId={(_wipeId ?? "none")}" + Environment.NewLine + + $"wipeId={(_wipeId ?? "none")} calls={_worldCalls.Count} consolePlugins={_consolePlugins} " + + $"credits={_vehicleCredit.Count} chinookCrates={_chinookCrates.Count} said={_worldEventsSaid} " + + $"worldEvents={(WorldEventsLive() ? "live" : $"quiet (ready={_worldReady} quitting={Rust.Application.isQuitting} loadingSave={Rust.Application.isLoadingSave})")}" + Environment.NewLine + $"last: {_lastWorldSummary}"); } @@ -14423,6 +14420,9 @@ namespace Oxide.Plugins _mapWorld.Add((BaseEntity)entity); else if (IsMapBase(entity)) _mapBases.Add((BaseEntity)entity); + + // Protocol 14: the world events a spawn says (§20.5). Its own type tests, all cheap. + WorldEventSpawned(entity); } private void OnEntityKill(BaseNetworkable entity) @@ -14436,6 +14436,8 @@ namespace Oxide.Plugins if (!_mapWorld.Remove(e)) _mapBases.Remove(e); + + WorldEventKilled(entity); } /// @@ -14930,6 +14932,1431 @@ namespace Oxide.Plugins return value == null ? null : value.Trim().ToLowerInvariant(); } + // ---- Rust's own world events (protocol 14, PROTOCOL.md §20.5; PLAN_REDESIGNS §14.3.1) ---- + // + // The patrol helicopter, Bradley, the cargo ship, the Chinook, airdrops, locked crates, the + // Travelling Vendor, the satellite and the Deep Sea, as one `world.event` frame whose + // `event` says which (D349, D353). Every hook here is void and never vetoes (rule 1), the + // `Can` one included. What the test phase (§14.2.1) and stage 1's spike found shapes all of it: + // + // * **Nothing is said until the world is loaded.** A booting server spawns the cargo ship + // and the vendor it saved, and that is not either of them arriving. + // * **A crate is Rust's own only when where it is can be told positively:** an oil rig's or + // a ghost ship's prefab, a cargo ship for a parent, or a Chinook seen dropping it. Anything + // else is `other` and says nothing — Guarded Crate's crates are ordinary locked crates — + // and a crate one of our own steps placed is skipped by the world ledger. + // * **A Bradley is Launch Site's when the game's spawner holds it, a road one when the road + // event's list does.** `RoadSpawned` cannot say: it defaults to true, so any plugin's + // Bradley would read as a road one. A Bradley neither holds is `other`; its spawn is not + // news, but its death still counts toward Tankbreaker, as it did under D174. + // * **Who downed a helicopter, a Chinook or a Bradley is the site's to decide** (D372, + // D379). The plugin keeps a damage table for those three classes and sends every + // candidate; it credits nobody itself. + // * **The joins are memory only** — plane to thrower, crate to hacker, looted once — keyed + // by the entity, dropped when it goes, and swept every ten minutes. Nothing is written + // to disk: a reload forgets a crate's hacker, which costs one feed line its name. + + private const string WorldEventKind = "world.event"; + + /// How often the joins below are swept of entities the world no longer holds. + private const float WorldEventSweepSeconds = 600f; + + /// The most damagers one credit block names, by damage. A helicopter fight is rarely a crowd. + private const int CreditMaxDamagers = 32; + + /// How long a CanHackCrate may wait for the OnCrateHack it belongs to. + private const float HackJoinSeconds = 2f; + + /// + /// How long after a Chinook opens its door the crate under it may land and still be its + /// drop. The crate falls from the Chinook's height, which takes well under a minute. + /// + private const float ChinookDropJoinSeconds = 120f; + + /// How far, across the ground, a landed crate may be from the Chinook that dropped it. + private const float ChinookDropJoinMetres = 40f; + + /// A Chinook that spawns this soon after an oil rig's hack is that rig's reinforcement. + private const float RigReinforcementSeconds = 10f; + + /// Road Bradleys spawned this close together are one line: the game sends twelve at once. + private const float RoadBradleyBatchSeconds = 1f; + + /// One player, as a world event names them. + private sealed class WorldWho + { + public ulong Id; + public string Name; + + /// Time.realtimeSinceStartup when it was seen, for joins that must be close in time. + public float At; + } + + /// + /// What hurt one patrol helicopter, Chinook or Bradley. Kept until it dies or the sweep + /// finds it gone, which is how a helicopter that flies off the map is forgotten. + /// + private sealed class VehicleCredit + { + public readonly Dictionary Damage = new Dictionary(); + public readonly Dictionary Names = new Dictionary(); + + /// The last player to hurt it. + public ulong Last; + + /// + /// The killing blow, when the game named one before the death: a patrol helicopter's + /// crippling hit (OnPatrolHelicopterKill), which comes seconds before a crash + /// that names nobody. + /// + public ulong Blow; + } + + private readonly Dictionary _vehicleCredit = new Dictionary(); + + // Airdrops: who threw the signal, by plane (a plane may not have a net id when the game first + // names it), then by drop. A schedule's plane has a null caller. + private readonly Dictionary _planeCaller = new Dictionary(); + private readonly Dictionary _planeDropPoint = new Dictionary(); + private readonly HashSet _planeAnnounced = new HashSet(); + private readonly Dictionary _dropCaller = new Dictionary(); + private readonly HashSet _dropLanded = new HashSet(); + private readonly HashSet _dropLooted = new HashSet(); + + // Locked crates: the last player to try a hack, by crate; the crates whose hack has been + // said; those looted; and the crates a Chinook was seen dropping. + private readonly Dictionary _crateHacker = new Dictionary(); + private readonly HashSet _crateHackSaid = new HashSet(); + private readonly HashSet _crateLooted = new HashSet(); + private readonly HashSet _chinookCrates = new HashSet(); + private readonly List> _chinookDrops = new List>(); + + /// Where each Bradley came from, decided at its spawn, by net id. + private readonly Dictionary _bradleyWhere = new Dictionary(); + + private int _roadBradleysPending; + private Timer _roadBradleysFlush; + + /// Time.realtimeSinceStartup of the last hack started on an oil rig, or a long time ago. + private float _rigHackAt = -1000f; + + private Timer _worldEventSweep; + + /// + /// Whether a plugin's console command can be run through ConsoleSystem.Run on this + /// server (D363). Measured at every load by running this plugin's own rg.probe; + /// Carbon 2.0.262 answers "not found" to every plugin command that way (§14.2.1), and the + /// steps that drive another plugin through its console command are refused while it does. + /// + private bool _consolePlugins; + + /// Set by rg.probe while the probe runs. + private bool _consoleProbeHit; + + private static System.Reflection.FieldInfo _roadBradleysList; + + private static bool InWorld(BaseNetworkable entity) + { + return entity != null && !entity.IsDestroyed; + } + + private static WorldWho WhoOf(BasePlayer player) + { + if (player == null || player.IsNpc) + return null; + + return new WorldWho { Id = (ulong)player.userID, Name = player.displayName, At = UnityEngine.Time.realtimeSinceStartup }; + } + + /// + /// Whether world events may be said now: the world is loaded, and the server is not on its + /// way down — a shutdown is not the vendor leaving. + /// + private bool WorldEventsLive() + { + return _worldReady && !Rust.Application.isQuitting && !Rust.Application.isLoadingSave; + } + + /// World events said since this load, for rg.world. + private int _worldEventsSaid; + + private Dictionary WorldEventFrame(string type, BaseEntity at) + { + _worldEventsSaid++; + // `event`, never `type`: `type` is the envelope's, and the sidecar files on it. The + // plan's own example wrote the world event's type there, and the sidecar dropped + // every one as untyped (stage 1) — protocol 13's `world.expired` lesson again. + Dictionary frame = Frame(WorldEventKind, "event"); + frame["event"] = type; + + if (at != null && at.transform != null) + PutPosition(frame, at.transform.position); + + return frame; + } + + /// The site computes the grid from these, as the map does, so the grid has one source. + private static void PutPosition(Dictionary frame, UnityEngine.Vector3 at) + { + frame["x"] = Math.Round((double)at.x, 1); + frame["z"] = Math.Round((double)at.z, 1); + } + + private static void PutWho(Dictionary frame, WorldWho who) + { + if (who == null) + return; + + frame["steamId"] = who.Id.ToString(System.Globalization.CultureInfo.InvariantCulture); + + if (!string.IsNullOrEmpty(who.Name)) + frame["name"] = who.Name; + } + + /// + /// The monument nearest a point whose kind starts with (any, when + /// empty), onto the frame as field (its kind, the world-verb vocabulary) and + /// fieldName (the game's English name for it). Nothing when the map has none. + /// + private static void PutMonument(Dictionary frame, string field, UnityEngine.Vector3 at, string prefix) + { + string kind; + MonumentInfo near = NearestMonument(at, prefix, out kind); + + if (near == null) + return; + + frame[field] = kind; + string phrase = near.displayPhrase != null ? near.displayPhrase.english : null; + + if (!string.IsNullOrEmpty(phrase)) + frame[field + "Name"] = phrase; + } + + /// How far apart two points are across the ground, ignoring height. + private static float Distance2D(UnityEngine.Vector3 a, UnityEngine.Vector3 b) + { + float dx = a.x - b.x; + float dz = a.z - b.z; + return (float)Math.Sqrt(dx * dx + dz * dz); + } + + private static MonumentInfo NearestMonument(UnityEngine.Vector3 at, string prefix, out string kind) + { + kind = null; + MonumentInfo best = null; + float bestDistance = float.MaxValue; + List all = TerrainMeta.Path != null ? TerrainMeta.Path.Monuments : null; + + if (all == null) + return null; + + foreach (MonumentInfo m in all) + { + if (m == null || string.IsNullOrEmpty(m.name)) + continue; + + string k = Path.GetFileNameWithoutExtension(m.name).ToLowerInvariant(); + + if (!string.IsNullOrEmpty(prefix) && !k.StartsWith(prefix, StringComparison.Ordinal)) + continue; + + float d = Distance2D(m.transform.position, at); + + if (d < bestDistance) + { + bestDistance = d; + best = m; + kind = k; + } + } + + return best; + } + + /// Main thread, at every load once the world is there: the console probe, and the sweep. + private void StartWorldEvents() + { + // From a timer, not from here: this hook runs INSIDE a console command when an operator + // types `c.reload RunicGateway`, and Carbon 2.0.262 answers "not found" to every plugin + // command run from inside one — and from a NextTick queued by one. Run from a timer, or + // from a NextTick the link's reader queues (every website command's path), it finds + // them (stage 1, 2026-10-15). The probe measures the context the steps run in. + timer.Once(1f, RunConsoleProbe); + + if (_worldEventSweep == null) + _worldEventSweep = timer.Every(WorldEventSweepSeconds, SweepWorldEvents); + } + + /// + /// D363: can a plugin's console command be run from code here? Our own is the test, + /// because it is the one command this plugin knows is registered. Quiet, so a server where + /// it is not found does not print the refusal at every load. Hello is said again when the + /// answer changed, so the site does not wait a minute for it. + /// + private void RunConsoleProbe() + { + _consoleProbeHit = false; + + try + { + ConsoleSystem.Run(ConsoleSystem.Option.Server.Quiet(), "rg.probe"); + } + catch (Exception ex) + { + Puts($"could not run the console probe: {Flatten(ex.Message)}"); + } + + bool before = _consolePlugins; + _consolePlugins = _consoleProbeHit; + + if (!_consolePlugins) + Puts("console probe: this framework does not run a plugin's console command from code; steps that need one are refused (D363)"); + + if (before != _consolePlugins && _connected) + SendHello(); + } + + /// + /// The probe's target. Only the server itself may run it; a player typing it in F1 changes + /// nothing, because the flag is read only while the probe is running. + /// + [ConsoleCommand("rg.probe")] + private void CmdProbe(ConsoleSystem.Arg arg) + { + if (arg.Connection != null) + return; + + _consoleProbeHit = true; + } + + /// Hello's and status's console: what the probe found at this load. + private Dictionary ConsoleBody() + { + return new Dictionary { { "plugins", _consolePlugins } }; + } + + /// Every ten minutes: what the world no longer holds is forgotten (bounded state). + private void SweepWorldEvents() + { + SweepIds(_vehicleCredit); + SweepIds(_dropCaller); + SweepIds(_dropLanded); + SweepIds(_dropLooted); + SweepIds(_crateHacker); + SweepIds(_crateHackSaid); + SweepIds(_crateLooted); + SweepIds(_chinookCrates); + SweepIds(_bradleyWhere); + + var planes = new List(); + + foreach (BaseEntity plane in _planeCaller.Keys) + if (plane == null || plane.IsDestroyed) + planes.Add(plane); + + foreach (BaseEntity plane in _planeAnnounced) + if (plane == null || plane.IsDestroyed) + planes.Add(plane); + + foreach (BaseEntity plane in planes) + ForgetPlane(plane); + + float now = UnityEngine.Time.realtimeSinceStartup; + _chinookDrops.RemoveAll(d => now - d.Value > ChinookDropJoinSeconds); + } + + private static void SweepIds(HashSet ids) + { + ids.RemoveWhere(id => BaseNetworkable.serverEntities.Find(new NetworkableId(id)) == null); + } + + private static void SweepIds(Dictionary byId) + { + var gone = new List(); + + foreach (ulong id in byId.Keys) + if (BaseNetworkable.serverEntities.Find(new NetworkableId(id)) == null) + gone.Add(id); + + foreach (ulong id in gone) + byId.Remove(id); + } + + private void ForgetPlane(BaseEntity plane) + { + _planeCaller.Remove(plane); + _planeDropPoint.Remove(plane); + _planeAnnounced.Remove(plane); + } + + /// A wipe: no entity of the old map is coming back, so nothing joined to one is kept. + private void ClearWorldEvents() + { + _vehicleCredit.Clear(); + _planeCaller.Clear(); + _planeDropPoint.Clear(); + _planeAnnounced.Clear(); + _dropCaller.Clear(); + _dropLanded.Clear(); + _dropLooted.Clear(); + _crateHacker.Clear(); + _crateHackSaid.Clear(); + _crateLooted.Clear(); + _chinookCrates.Clear(); + _chinookDrops.Clear(); + _bradleyWhere.Clear(); + } + + /// + /// From OnEntitySpawned, which runs for every spawn on the server: the type tests + /// come first and are all this costs for anything that is not one of these. + /// + private void WorldEventSpawned(BaseNetworkable entity) + { + if (entity is PatrolHelicopter) + { + if (WorldEventsLive()) + EmitFrame(WorldEventFrame("heli.arrived", (BaseEntity)entity)); + } + else if (entity is CargoShip) + { + if (WorldEventsLive()) + EmitFrame(WorldEventFrame("cargo.arrived", (BaseEntity)entity)); + } + else if (entity is CH47HelicopterAIController) + { + if (WorldEventsLive()) + ChinookSpawned((CH47HelicopterAIController)entity); + } + else if (entity is TravellingVendor) + { + if (WorldEventsLive()) + EmitFrame(WorldEventFrame("vendor.arrived", (BaseEntity)entity)); + } + else if (entity is CargoPlane) + { + if (WorldEventsLive()) + AnnounceAirdropSoon((BaseEntity)entity); + } + else if (entity is SatelliteCrashRemains) + { + // The satellite has no hook of its own; its remains appear at the impact (§14.2.1). + if (WorldEventsLive()) + EmitFrame(WorldEventFrame("satellite.crashed", (BaseEntity)entity)); + } + } + + /// From OnEntityKill. The vendor leaving is the only kill that is news. + private void WorldEventKilled(BaseNetworkable entity) + { + if (entity is TravellingVendor) + { + if (WorldEventsLive()) + EmitFrame(WorldEventFrame("vendor.left", (BaseEntity)entity)); + } + else if (entity is CargoPlane) + { + ForgetPlane((BaseEntity)entity); + } + } + + /// + /// A Chinook, a second later: by then an oil rig's reinforcement has its landing target, + /// and it is told by the crate hack it answers rather than said as a Chinook arriving. + /// + private void ChinookSpawned(CH47HelicopterAIController chinook) + { + timer.Once(1f, () => + { + if (!InWorld(chinook) || !WorldEventsLive()) + return; + + if (chinook.ShouldLand() || UnityEngine.Time.realtimeSinceStartup - _rigHackAt < RigReinforcementSeconds) + return; + + EmitFrame(WorldEventFrame("chinook.arrived", chinook)); + }); + } + + // ── The helicopter, the Chinook and the Bradley: credit (D372, D379) ────────────────── + + /// Main thread. One hit on one of the three, from OnEntityTakeDamage. + private void NoteVehicleHit(BaseCombatEntity entity, BasePlayer player, HitInfo info) + { + VehicleCredit credit = CreditFor(entity); + ulong id = (ulong)player.userID; + float damage = info.damageTypes != null ? info.damageTypes.Total() : 0f; + float had; + + credit.Damage.TryGetValue(id, out had); + credit.Damage[id] = had + Math.Max(0f, damage); + credit.Names[id] = player.displayName; + credit.Last = id; + } + + private VehicleCredit CreditFor(BaseEntity entity) + { + VehicleCredit credit; + ulong id = entity.net.ID.Value; + + if (!_vehicleCredit.TryGetValue(id, out credit)) + { + credit = new VehicleCredit(); + _vehicleCredit[id] = credit; + } + + return credit; + } + + /// + /// Fires twice for one helicopter (§14.2.1): at the crippling hit, naming the player, and + /// at the crash, naming nobody. The first player it names is the killing blow. + /// + private void OnPatrolHelicopterKill(PatrolHelicopter heli, HitInfo info) + { + MarkHook("OnPatrolHelicopterKill"); + + BasePlayer player = info != null ? info.InitiatorPlayer : null; + + if (heli == null || heli.net == null || player == null || player.IsNpc) + return; + + VehicleCredit credit = CreditFor(heli); + + if (credit.Blow == 0UL) + { + credit.Blow = (ulong)player.userID; + credit.Names[credit.Blow] = player.displayName; + } + } + + /// + /// From OnEntityDeath: a patrol helicopter, a Chinook or a Bradley died. The frame + /// carries every candidate for the credit and the site applies the server's rule to the + /// line and to Rotorbane / Tankbreaker alike, so the two never disagree (D372). + /// + private void WorldVehicleDied(BaseCombatEntity entity, BasePlayer attacker) + { + VehicleCredit credit = null; + + if (entity.net != null && _vehicleCredit.TryGetValue(entity.net.ID.Value, out credit)) + _vehicleCredit.Remove(entity.net.ID.Value); + + if (!_worldReady) + return; + + Dictionary frame; + + if (entity is BradleyAPC) + { + frame = WorldEventFrame("bradley.destroyed", entity); + frame["where"] = BradleyWhere((BradleyAPC)entity) ?? "other"; + + if (entity.net != null) + _bradleyWhere.Remove(entity.net.ID.Value); + } + else if (entity is PatrolHelicopter) + { + frame = WorldEventFrame("heli.downed", entity); + } + else + { + frame = WorldEventFrame("chinook.downed", entity); + } + + PutCredit(frame, credit, attacker != null && !attacker.IsNpc ? attacker : null); + EmitFrame(frame); + } + + /// + /// credit {blow, last, most, damagers} and names. Each of the three is absent + /// when nobody qualifies; a death nobody touched carries an empty damagers. + /// + private static void PutCredit(Dictionary frame, VehicleCredit credit, BasePlayer killer) + { + var body = new Dictionary(); + var names = new Dictionary(); + var rows = new List>(); + + if (credit != null) + rows.AddRange(credit.Damage); + + rows.Sort((a, b) => b.Value.CompareTo(a.Value)); + + ulong blow = credit != null ? credit.Blow : 0UL; + + // A Bradley's and a Chinook's death names its killer outright; a helicopter's crash + // does not, and its blow was kept at the crippling hit. + if (blow == 0UL && killer != null) + blow = (ulong)killer.userID; + + if (blow != 0UL) + body["blow"] = Id(blow); + + if (credit != null && credit.Last != 0UL) + body["last"] = Id(credit.Last); + + if (rows.Count > 0) + body["most"] = Id(rows[0].Key); + + var damagers = new List(); + + for (int i = 0; i < rows.Count && i < CreditMaxDamagers; i++) + { + damagers.Add(new Dictionary + { + { "steamId", Id(rows[i].Key) }, + { "damage", Math.Round((double)rows[i].Value, 1) } + }); + } + + body["damagers"] = damagers; + + if (credit != null) + foreach (KeyValuePair n in credit.Names) + names[Id(n.Key)] = n.Value; + + if (killer != null && blow == (ulong)killer.userID) + names[Id(blow)] = killer.displayName; + + frame["credit"] = body; + + if (names.Count > 0) + frame["names"] = names; + } + + private static string Id(ulong id) + { + return id.ToString(System.Globalization.CultureInfo.InvariantCulture); + } + + // ── The patrol helicopter leaving ─────────────────────────────────────────────────────── + + private void OnHelicopterRetire(PatrolHelicopterAI ai) + { + MarkHook("OnHelicopterRetire"); + + if (ai != null && ai.helicopterBase != null && WorldEventsLive()) + EmitFrame(WorldEventFrame("heli.left", ai.helicopterBase)); + } + + // ── Bradley ───────────────────────────────────────────────────────────────────────────── + + /// + /// Next tick, because both spawners record the Bradley only after this hook has run inside + /// its spawn. Launch Site's is one line; road Bradleys are gathered for a second and said + /// as one line with a count. + /// + private void OnBradleyApcInitialize(BradleyAPC apc) + { + MarkHook("OnBradleyApcInitialize"); + + if (apc == null || !WorldEventsLive()) + return; + + NextTick(() => + { + if (!InWorld(apc)) + return; + + string where = BradleyWhere(apc); + + if (where == "launch_site") + { + Dictionary frame = WorldEventFrame("bradley.spawned", null); + frame["where"] = where; + frame["count"] = 1; + EmitFrame(frame); + } + else if (where == "road") + { + _roadBradleysPending++; + + if (_roadBradleysFlush == null) + _roadBradleysFlush = timer.Once(RoadBradleyBatchSeconds, FlushRoadBradleys); + } + }); + } + + private void FlushRoadBradleys() + { + _roadBradleysFlush = null; + int count = _roadBradleysPending; + _roadBradleysPending = 0; + + if (count <= 0 || !WorldEventsLive()) + return; + + Dictionary frame = WorldEventFrame("bradley.spawned", null); + frame["where"] = "road"; + frame["count"] = count; + EmitFrame(frame); + } + + /// launch_site, road, or null for a Bradley neither of the game's spawners holds. + private string BradleyWhere(BradleyAPC apc) + { + string where; + + if (apc.net != null && _bradleyWhere.TryGetValue(apc.net.ID.Value, out where)) + return where; + + where = null; + + if (BradleySpawner.singleton != null && BradleySpawner.singleton.spawned == apc) + where = "launch_site"; + else if (InRoadBradleys(apc)) + where = "road"; + + if (where != null && apc.net != null) + _bradleyWhere[apc.net.ID.Value] = where; + + return where; + } + + /// Whether the road event holds this Bradley in its own list, which is private. + private static bool InRoadBradleys(BradleyAPC apc) + { + if (_roadBradleysList == null) + _roadBradleysList = typeof(RoadBradleys).GetField("spawnedAPCs", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic); + + if (_roadBradleysList == null) + return false; + + foreach (RoadBradleys road in UnityEngine.Object.FindObjectsOfType()) + { + var list = _roadBradleysList.GetValue(road) as List; + + if (list != null && list.Contains(apc)) + return true; + } + + return false; + } + + // ── The cargo ship ────────────────────────────────────────────────────────────────────── + + private void OnCargoShipHarborApproach(CargoShip ship, object harbor = null) + { + MarkHook("OnCargoShipHarborApproach"); + EmitCargo("cargo.docking", ship); + } + + private void OnCargoShipHarborArrived(CargoShip ship) + { + MarkHook("OnCargoShipHarborArrived"); + EmitCargo("cargo.docked", ship); + } + + private void OnCargoShipHarborLeave(CargoShip ship) + { + MarkHook("OnCargoShipHarborLeave"); + EmitCargo("cargo.undocked", ship); + } + + /// The ship turning for the edge of the map, radiation on: it has left. The kill follows when it is gone (stage 1's spike). + private void OnCargoShipEgress(CargoShip ship) + { + MarkHook("OnCargoShipEgress"); + + if (ship != null && WorldEventsLive()) + EmitFrame(WorldEventFrame("cargo.left", ship)); + } + + /// The three harbour types name the nearest harbour. + private void EmitCargo(string type, CargoShip ship) + { + if (ship == null || !WorldEventsLive()) + return; + + Dictionary frame = WorldEventFrame(type, ship); + PutMonument(frame, "harbour", ship.transform.position, "harbor"); + EmitFrame(frame); + } + + // ── The Chinook's drop ────────────────────────────────────────────────────────────────── + + /// + /// The door opening over a drop zone. The crate is made a moment later with no net id + /// yet, so it is joined to this drop when it lands, by place and time. + /// + private void OnHelicopterDropCrate(CH47HelicopterAIController chinook) + { + MarkHook("OnHelicopterDropCrate"); + + if (chinook == null || chinook.transform == null) + return; + + float now = UnityEngine.Time.realtimeSinceStartup; + _chinookDrops.RemoveAll(d => now - d.Value > ChinookDropJoinSeconds); + _chinookDrops.Add(new KeyValuePair(chinook.transform.position, now)); + } + + private void OnCrateLanded(HackableLockedCrate crate) + { + MarkHook("OnCrateLanded"); + + if (crate == null || crate.net == null || _chinookDrops.Count == 0) + return; + + ulong id = crate.net.ID.Value; + + if (_chinookCrates.Contains(id)) + return; + + float now = UnityEngine.Time.realtimeSinceStartup; + UnityEngine.Vector3 at = crate.transform.position; + int found = _chinookDrops.FindIndex(d => now - d.Value <= ChinookDropJoinSeconds + && Distance2D(d.Key, at) <= ChinookDropJoinMetres); + + if (found < 0) + return; + + _chinookDrops.RemoveAt(found); + _chinookCrates.Add(id); + + if (!WorldEventsLive() || InWorldLedger(id)) + return; + + Dictionary frame = WorldEventFrame("chinook.dropped", crate); + PutMonument(frame, "monument", at, null); + EmitFrame(frame); + } + + // ── Locked crates ─────────────────────────────────────────────────────────────────────── + + /// A crate one of our own steps placed. It is the event's, not Rust's. + private bool InWorldLedger(ulong id) + { + return _world.ContainsKey(id.ToString(System.Globalization.CultureInfo.InvariantCulture)); + } + + /// + /// Where a locked crate is, told positively: oilrig_large, oilrig_small, + /// cargo, ghostship, chinook, or other for anything else. + /// + private string CrateWhere(HackableLockedCrate crate) + { + if (crate.net != null && InWorldLedger(crate.net.ID.Value)) + return "other"; + + string prefab = crate.ShortPrefabName ?? string.Empty; + + if (prefab.EndsWith("_oilrig", StringComparison.Ordinal)) + { + string kind; + NearestMonument(crate.transform.position, "oilrig", out kind); + + if (kind == "oilrig_1") + return "oilrig_large"; + if (kind == "oilrig_2") + return "oilrig_small"; + return "other"; + } + + if (prefab.EndsWith("_ghostship", StringComparison.Ordinal)) + return "ghostship"; + + if (crate.GetParentEntity() is CargoShip) + return "cargo"; + + if (crate.net != null && _chinookCrates.Contains(crate.net.ID.Value)) + return "chinook"; + + return "other"; + } + + /// Who is trying. The hack itself is OnCrateHack, which names nobody, a moment later. + private void CanHackCrate(BasePlayer player, HackableLockedCrate crate) + { + MarkHook("CanHackCrate"); + + WorldWho who = WhoOf(player); + + if (who != null && crate != null && crate.net != null) + _crateHacker[crate.net.ID.Value] = who; + } + + /// + /// A hack started: said once per crate, and counted once for its starter (Safecracker, + /// D356). On an oil rig this is also the scientists being called, at once. + /// + private void OnCrateHack(HackableLockedCrate crate) + { + MarkHook("OnCrateHack"); + + if (crate == null || crate.net == null) + return; + + string where = CrateWhere(crate); + + if (where.StartsWith("oilrig", StringComparison.Ordinal)) + _rigHackAt = UnityEngine.Time.realtimeSinceStartup; + + ulong id = crate.net.ID.Value; + + if (where == "other" || !_crateHackSaid.Add(id)) + return; + + WorldWho who; + _crateHacker.TryGetValue(id, out who); + + if (who != null && UnityEngine.Time.realtimeSinceStartup - who.At > HackJoinSeconds) + who = null; + + if (who != null) + { + BasePlayer player = BasePlayer.FindByID(who.Id) ?? BasePlayer.FindSleeping(who.Id); + + if (player != null) + TallyFor(player).CratesHacked++; + } + else + { + _crateHacker.Remove(id); + } + + if (!WorldEventsLive()) + return; + + Dictionary frame = WorldEventFrame("crate.hack.started", crate); + frame["where"] = where; + PutWho(frame, who); + EmitFrame(frame); + } + + private void OnCrateHackEnd(HackableLockedCrate crate) + { + MarkHook("OnCrateHackEnd"); + + if (crate == null || crate.net == null || !WorldEventsLive()) + return; + + string where = CrateWhere(crate); + + if (where == "other") + return; + + WorldWho who; + _crateHacker.TryGetValue(crate.net.ID.Value, out who); + + Dictionary frame = WorldEventFrame("crate.unlocked", crate); + frame["where"] = where; + PutWho(frame, who); + EmitFrame(frame); + } + + /// + /// The first loot of an airdrop (Scavenger, D356) and of an unlocked crate. Fires for every + /// container anybody opens, so the type test is first. + /// + private void OnLootEntity(BasePlayer player, BaseEntity entity) + { + MarkHook("OnLootEntity"); + + if (!(entity is SupplyDrop || entity is HackableLockedCrate) || entity.net == null || player == null || player.IsNpc) + return; + + ulong id = entity.net.ID.Value; + + if (InWorldLedger(id)) + return; + + if (entity is SupplyDrop) + { + if (!_dropLooted.Add(id)) + return; + + TallyFor(player).DropsLooted++; + + if (!WorldEventsLive()) + return; + + Dictionary drop = WorldEventFrame("airdrop.looted", entity); + PutWho(drop, WhoOf(player)); + EmitFrame(drop); + return; + } + + var crate = (HackableLockedCrate)entity; + + if (!crate.IsFullyHacked() || _crateLooted.Contains(id)) + return; + + string where = CrateWhere(crate); + + if (where == "other") + return; + + _crateLooted.Add(id); + + if (!WorldEventsLive()) + return; + + Dictionary frame = WorldEventFrame("crate.looted", crate); + frame["where"] = where; + PutWho(frame, WhoOf(player)); + EmitFrame(frame); + } + + // ── Airdrops ──────────────────────────────────────────────────────────────────────────── + + /// A supply signal: its thrower is the caller, and the drop point is where it lies. + private void OnCargoPlaneSignaled(BaseEntity plane, SupplySignal signal) + { + MarkHook("OnCargoPlaneSignaled"); + + if (plane == null || signal == null) + return; + + _planeCaller[plane] = WhoOf(signal.creatorEntity as BasePlayer); + _planeDropPoint[plane] = signal.transform.position; + } + + /// The schedule's airdrop: nobody called it. Only its drop point is kept; the plane's spawn says the line. + private void OnAirdrop(CargoPlane plane, UnityEngine.Vector3 at = default(UnityEngine.Vector3)) + { + MarkHook("OnAirdrop"); + + if (plane != null && !_planeDropPoint.ContainsKey(plane) && at != UnityEngine.Vector3.zero) + _planeDropPoint[plane] = at; + } + + private static System.Reflection.FieldInfo _planeDropPositionField; + + /// + /// A cargo plane's spawn, next tick, says airdrop.called: once per plane, by every + /// route there is. The console's supply.call raised neither hook above (stage 1), + /// so the spawn is the one thing every airdrop has; the hooks only say who and where, + /// and next tick is after a signal's hook has named its thrower. + /// + private void AnnounceAirdropSoon(BaseEntity plane) + { + NextTick(() => + { + if (plane == null || plane.IsDestroyed || !_planeAnnounced.Add(plane) || !WorldEventsLive()) + return; + + WorldWho who; + _planeCaller.TryGetValue(plane, out who); + + Dictionary frame = WorldEventFrame("airdrop.called", null); + UnityEngine.Vector3 at; + + if (_planeDropPoint.TryGetValue(plane, out at) || TryPlaneDropPoint(plane, out at)) + PutPosition(frame, at); + + PutWho(frame, who); + EmitFrame(frame); + }); + } + + /// The plane's own drop point, which is private to it: read by reflection, as Carbon leaves it private. + private static bool TryPlaneDropPoint(BaseEntity plane, out UnityEngine.Vector3 at) + { + at = UnityEngine.Vector3.zero; + + if (_planeDropPositionField == null) + _planeDropPositionField = typeof(CargoPlane).GetField("dropPosition", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic); + + if (_planeDropPositionField == null || !(plane is CargoPlane)) + return false; + + object value = _planeDropPositionField.GetValue(plane); + + if (!(value is UnityEngine.Vector3)) + return false; + + at = (UnityEngine.Vector3)value; + return at != UnityEngine.Vector3.zero; + } + + private void OnSupplyDropDropped(BaseEntity drop, CargoPlane plane) + { + MarkHook("OnSupplyDropDropped"); + + if (drop == null || drop.net == null || plane == null) + return; + + WorldWho who; + + if (_planeCaller.TryGetValue(plane, out who) && who != null) + _dropCaller[drop.net.ID.Value] = who; + } + + /// Said once per drop: the hook fired twice for one drop in the test phase. + private void OnSupplyDropLanded(SupplyDrop drop) + { + MarkHook("OnSupplyDropLanded"); + + if (drop == null || drop.net == null || InWorldLedger(drop.net.ID.Value) || !_dropLanded.Add(drop.net.ID.Value)) + return; + + if (!WorldEventsLive()) + return; + + WorldWho who; + _dropCaller.TryGetValue(drop.net.ID.Value, out who); + + Dictionary frame = WorldEventFrame("airdrop.landed", drop); + PutWho(frame, who); + EmitFrame(frame); + } + + // ── The Deep Sea (off the map: no position) ───────────────────────────────────────────── + + private void OnDeepSeaOpened(DeepSeaManager manager) + { + MarkHook("OnDeepSeaOpened"); + + if (WorldEventsLive()) + EmitFrame(WorldEventFrame("deepsea.opened", null)); + } + + private void OnDeepSeaClosed(DeepSeaManager manager) + { + MarkHook("OnDeepSeaClosed"); + + if (WorldEventsLive()) + EmitFrame(WorldEventFrame("deepsea.closed", null)); + } + + // ---- calling a world event (protocol 14, PROTOCOL.md §20.5; D354, D367, D378) ---- + // + // `world.call` runs one of the game's own scheduled events now, the way the schedule does: + // one of its `TriggeredEvent`s' `RunEvent`, without `Trigger`, which would also reset the + // schedule's own clock. No console command is involved, so it works the same on Carbon. + // + // **Kept.** Nothing a call starts is removed at teardown — a cargo ship or a drop may + // already hold players or their loot (D354) — so there is no registry of what a call made, + // only of the call: its key, for a retry, and its run, for the per-run ceiling. + // + // **A position only where the game has a route to one** (stage 1's spike): the airdrop's + // drop point (`InitDropPosition` before it spawns) and the patrol helicopter's first + // destination (`SetInitialDestination` before it spawns, after which it patrols as usual). + // The Chinook drops only at the map's drop zones, and the one route to a chosen zone is + // the Airfield terminal's shared state, so it takes no position (D378). + + /// The events a call may run: the schedules' keys, and the satellite. + private static readonly string[] WorldCallEvents = + { + "patrol_heli", "cargo", "chinook", "airdrop", "travelling_vendor", "bradley_road", "f15", "satellite" + }; + + /// The most of one event one call runs. The game runs the cargo ship, the vendor and road Bradleys one at a time. + private const int WorldCallHardMaxCount = 5; + + /// The ceiling on calls per run. EventsMaxWorldCalls may lower it, never raise it. + private const int WorldCallsHardMax = 10; + + /// How long a call's key is remembered: seven days, as a payment's is. + private const long WorldCallMemoryMs = 7L * 24L * 60L * 60L * 1000L; + + /// One call, kept for its key and its run. Persisted as it is. + private sealed class WorldCall + { + public string Key; + public string RunId; + public string Event; + public int Called; + public long AtMs; + } + + private Dictionary _worldCalls = new Dictionary(StringComparer.Ordinal); + + private static bool WorldCallPlaceable(string ev) + { + return ev == "airdrop" || ev == "patrol_heli"; + } + + private static int WorldCallMaxCount(string ev) + { + return ev == "cargo" || ev == "travelling_vendor" || ev == "bradley_road" ? 1 : WorldCallHardMaxCount; + } + + private string WorldCallsPath() + { + return Path.Combine(Interface.Oxide.DataDirectory, Name, "world-calls.json"); + } + + private void SaveWorldCalls() + { + WriteDataFile(WorldCallsPath(), new List(_worldCalls.Values), "world calls"); + } + + private void LoadWorldCalls() + { + _worldCalls = new Dictionary(StringComparer.Ordinal); + List calls = ReadDataFile(WorldCallsPath(), "world calls"); + + if (calls != null) + foreach (WorldCall c in calls) + if (c != null && !string.IsNullOrEmpty(c.Key)) + _worldCalls[c.Key] = c; + + ForgetOldWorldCalls(); + } + + private void ForgetOldWorldCalls() + { + long now = NowMs(); + var stale = new List(); + + foreach (KeyValuePair kv in _worldCalls) + if (now >= kv.Value.AtMs + WorldCallMemoryMs) + stale.Add(kv.Key); + + foreach (string key in stale) + _worldCalls.Remove(key); + + if (stale.Count > 0) + SaveWorldCalls(); + } + + private int WorldCallsLimit() + { + return _config != null ? _config.EventsMaxWorldCalls : WorldCallsHardMax; + } + + /// Main thread. Run one of the game's world events now, for a run. + private void HandleWorldCall(Dictionary cmd, string reqId) + { + if (!WorldWritesAllowed(reqId)) + return; + + string key = StringField(cmd, "key"); + string runId = StringField(cmd, "runId"); + + if (string.IsNullOrEmpty(key) || key.Length > PaymentKeyMax || string.IsNullOrEmpty(runId)) + { + EmitWorldError(reqId, "malformed", "a call is made for a run, under a key of at most 128 characters"); + return; + } + + ForgetOldWorldCalls(); + + WorldCall seen; + + if (_worldCalls.TryGetValue(key, out seen)) + { + EmitWorldCalled(reqId, seen, true); + return; + } + + string ev = (StringField(cmd, "event") ?? string.Empty).Trim().ToLowerInvariant(); + + if (Array.IndexOf(WorldCallEvents, ev) < 0) + { + EmitWorldError(reqId, "bad-event", $"'{ev}' is not a world event this server calls ({string.Join(", ", WorldCallEvents)})"); + return; + } + + int count = cmd.ContainsKey("count") ? IntField(cmd, "count") : 1; + + if (count < 1 || count > WorldCallMaxCount(ev)) + { + EmitWorldError(reqId, "bad-count", $"'{ev}' is called 1 to {WorldCallMaxCount(ev)} at a time, not {count}"); + return; + } + + bool placed = !string.IsNullOrEmpty(StringField(cmd, "monument")) || cmd.ContainsKey("x") || cmd.ContainsKey("z"); + UnityEngine.Vector3 at = UnityEngine.Vector3.zero; + + if (placed) + { + if (!WorldCallPlaceable(ev)) + { + EmitWorldError(reqId, "not-placeable", $"'{ev}' goes where the game puts it; only an airdrop and a patrol helicopter take a position"); + return; + } + + string reason, refusal; + + if (!ResolveWorldPoint(cmd, out at, out reason, out refusal)) + { + EmitWorldError(reqId, reason, refusal); + return; + } + } + + int used = 0; + + foreach (WorldCall c in _worldCalls.Values) + if (c.RunId == runId) + used += c.Called; + + if (used + count > WorldCallsLimit()) + { + EmitWorldError(reqId, "too-many", $"a run calls at most {WorldCallsLimit()} world events on this server, and this one has called {used}"); + return; + } + + int called = 0; + string failure = null; + + for (int i = 0; i < count; i++) + { + if (!RunWorldCall(ev, placed ? (UnityEngine.Vector3?)at : null, out failure)) + break; + + called++; + } + + if (called == 0) + { + EmitWorldError(reqId, "refused", failure ?? $"the game did not start '{ev}'"); + return; + } + + var call = new WorldCall { Key = key, RunId = runId, Event = ev, Called = called, AtMs = NowMs() }; + _worldCalls[key] = call; + SaveWorldCalls(); + + _lastWorldSummary = $"called {called} {ev} for run {runId}" + (placed ? $" at {Grid(at) ?? "?"}" : string.Empty); + Puts($"world: {_lastWorldSummary}"); + EmitWorldCalled(reqId, call, false); + } + + private void EmitWorldCalled(string reqId, WorldCall call, bool repeat) + { + Dictionary frame = Frame("world.called", "reply"); + frame["key"] = call.Key; + frame["event"] = call.Event; + frame["repeat"] = repeat; + frame["called"] = call.Called; + + if (reqId != null) + frame["reqId"] = reqId; + + EmitFrame(frame); + } + + /// The game's own schedule for an event, enabled or not: an operator who switched one off may still call it. + private static EventSchedule ScheduleFor(string key) + { + foreach (EventSchedule s in UnityEngine.Object.FindObjectsOfType()) + if (s != null && string.Equals(s.Key, key, StringComparison.OrdinalIgnoreCase)) + return s; + + return null; + } + + /// Main thread. One run of one event; false, with why, when the game has nothing to run. + private bool RunWorldCall(string ev, UnityEngine.Vector3? at, out string failure) + { + failure = null; + + if (ev == "satellite") + { + // The satellite is not on the schedule; its own console command crashes one at a + // random spot. A native command, which Carbon runs from code (§14.2.1). + ConsoleSystem.Run(ConsoleSystem.Option.Server.Quiet(), "satellite.random"); + return true; + } + + EventSchedule schedule = ScheduleFor(ev); + TriggeredEvent[] events = schedule != null ? schedule.GetComponents() : null; + + if (events == null || events.Length == 0) + { + failure = $"this map has no '{ev}' event"; + return false; + } + + if (at.HasValue) + return SpawnPlaced(ev, events, at.Value, out failure); + + // As the schedule picks: one of its events at random. + TriggeredEvent picked = events[UnityEngine.Random.Range(0, events.Length)]; + + try + { + picked.RunEvent(); + } + catch (Exception ex) + { + failure = $"the game could not start '{ev}': {Flatten(ex.Message)}"; + return false; + } + + return true; + } + + /// + /// The airdrop or the patrol helicopter at a point: the schedule's own prefab, spawned the + /// way TriggeredEventPrefab.RunEvent does it, with the position set before the spawn. + /// + private bool SpawnPlaced(string ev, TriggeredEvent[] events, UnityEngine.Vector3 at, out string failure) + { + failure = null; + TriggeredEventPrefab source = null; + + foreach (TriggeredEvent e in events) + { + source = e as TriggeredEventPrefab; + + if (source != null && source.targetPrefab != null && source.targetPrefab.isValid) + break; + + source = null; + } + + if (source == null) + { + failure = $"'{ev}' has no prefab to place on this map"; + return false; + } + + BaseEntity entity = GameManager.server.CreateEntity(source.targetPrefab.resourcePath); + + if (entity == null) + { + failure = $"the game could not create '{ev}'"; + return false; + } + + entity.SendMessage("TriggeredEventSpawn", UnityEngine.SendMessageOptions.DontRequireReceiver); + + var plane = entity as CargoPlane; + var heli = entity as PatrolHelicopter; + + if (plane != null) + { + plane.InitDropPosition(at); + } + else if (heli != null) + { + PatrolHelicopterAI ai = heli.GetComponent(); + + if (ai == null) + { + entity.Kill(); + failure = "the patrol helicopter has no AI to send"; + return false; + } + + at.y = TerrainMeta.HeightMap.GetHeight(at) + 10f; + ai.SetInitialDestination(at, 0.25f); + } + else + { + entity.Kill(); + failure = $"'{ev}' cannot be placed"; + return false; + } + + entity.Spawn(); + entity.SendMessage("TriggeredEventPostSpawn", UnityEngine.SendMessageOptions.DontRequireReceiver); + + if (source.shouldBroadcastSpawn) + BasePlayer.Server_SendWorldNotificationToAllActivePlayers(source.notificationType, entity.transform.position); + + return true; + } + // ---- diagnostics ---- /// diff --git a/scripts/checkPlugin.js b/scripts/checkPlugin.js index bf4e078..3d5049d 100644 --- a/scripts/checkPlugin.js +++ b/scripts/checkPlugin.js @@ -113,6 +113,16 @@ function readChatCommands(source) { return found } +/** Every `[ConsoleCommand("x")]` the plugin declares. */ +function readConsoleCommands(source) { + return [...source.matchAll(/\[ConsoleCommand\("([^"]+)"\)\]/g)].map((m) => m[1]) +} + +/** The `rg.*` commands the plugin runs on itself through `ConsoleSystem.Run`. */ +function readOwnConsoleRuns(source) { + return [...source.matchAll(/ConsoleSystem\.Run\([^,]+,\s*"(rg\.[\w.]+)"/g)].map((m) => m[1]) +} + function readMethods(source) { const found = [] let m @@ -196,7 +206,20 @@ function check(source, toml) { seen.add(cmd.command) } - // 5. The two declaration sites this repository owns must agree. + // 5. Protocol 14 (D363): the console probe runs one of this plugin's own commands + // through `ConsoleSystem.Run` to learn whether the framework runs a plugin's + // command from code. A probe naming a command the plugin does not declare + // answers "no" on every framework, which reads exactly like Carbon's bug. + for (const name of readOwnConsoleRuns(source)) { + if (!readConsoleCommands(source).includes(name)) { + problems.push( + `ConsoleSystem.Run runs "${name}", but the plugin declares no [ConsoleCommand("${name}")]: ` + + 'the console probe would report false on every framework (D363).' + ) + } + } + + // 6. 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) @@ -242,6 +265,6 @@ function main() { ) } -module.exports = { check, readExpectedHooks, readMethods, readChatCommands, HOOK_NAME } +module.exports = { check, readExpectedHooks, readMethods, readChatCommands, readConsoleCommands, readOwnConsoleRuns, HOOK_NAME } if (require.main === module) main() diff --git a/scripts/checkPlugin.test.js b/scripts/checkPlugin.test.js index ebb8eff..ee4f6f9 100644 --- a/scripts/checkPlugin.test.js +++ b/scripts/checkPlugin.test.js @@ -16,7 +16,7 @@ const assert = require('node:assert') const fs = require('node:fs') const path = require('node:path') -const { check, readExpectedHooks, readMethods, readChatCommands, HOOK_NAME } = require('./checkPlugin') +const { check, readExpectedHooks, readMethods, readChatCommands, readConsoleCommands, readOwnConsoleRuns, HOOK_NAME } = require('./checkPlugin') /** A minimal plugin that passes, as the baseline every case below deviates from. */ function source({ expected = ['OnPlayerDeath'], methods, version = 2 } = {}) { @@ -210,3 +210,55 @@ test('the chat-command parser reads the real plugin, rather than quietly matchin assert.ok(names.has('link'), '/link was not found by the chat-command parser') assert.ok(names.has('unlink'), '/unlink was not found by the chat-command parser') }) + +// ── The console probe (protocol 14, D363) ────────────────────────────────── +// +// The plugin runs its own `rg.probe` through `ConsoleSystem.Run` to learn whether +// the framework runs a plugin's command from code. A probe that names a command +// the plugin does not declare answers false everywhere, and false is exactly what +// Carbon's bug looks like, so nobody would ever notice. + +/** A plugin that runs `ran` on itself and declares `declared`. */ +function withProbe(ran, declared) { + return source({ + expected: [], + methods: ` private void StartWorldEvents() + { + ConsoleSystem.Run(ConsoleSystem.Option.Server.Quiet(), "${ran}"); + } + + [ConsoleCommand("${declared}")] + private void CmdProbe(ConsoleSystem.Arg arg) + { + }`, + }) +} + +test('a probe that runs a command the plugin declares passes', () => { + assert.deepEqual(check(withProbe('rg.probe', 'rg.probe'), toml()), []) +}) + +test('a probe that runs a command the plugin does not declare is caught', () => { + const problems = check(withProbe('rg.probe', 'rg.prob'), toml()) + assert.equal(problems.length, 1) + assert.match(problems[0], /ConsoleSystem\.Run runs "rg\.probe", but the plugin declares no \[ConsoleCommand\("rg\.probe"\)\]/) +}) + +test('a native command run through the console is not the probe, and is left alone', () => { + const methods = ` private void Call() + { + ConsoleSystem.Run(ConsoleSystem.Option.Server.Quiet(), "satellite.random"); + }` + assert.deepEqual(check(source({ expected: [], methods }), toml()), []) +}) + +test('the probe parsers read the real plugin, rather than quietly matching nothing', () => { + const real = fs.readFileSync( + path.resolve(__dirname, '..', 'overlay', 'oxide', 'plugins', 'RunicGateway.cs'), + 'utf8' + ) + + assert.ok(readOwnConsoleRuns(real).includes('rg.probe'), 'the console probe was not found by the parser') + assert.ok(readConsoleCommands(real).includes('rg.probe'), 'rg.probe was not found among the console commands') + assert.ok(readConsoleCommands(real).includes('rg.hooks'), 'rg.hooks was not found among the console commands') +}) -- 2.49.1