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') +})