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