diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 6799076..48ccce1 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -30,7 +30,7 @@ # # 2. A FRAMEWORK-NEUTRAL LAYOUT. The tarball holds one directory, # `runicgateway-rust-plugin/`, with `RunicGateway.cs`, its helpers -# (`RunicGatewayZones.cs`, `RunicGatewayDomes.cs`) and `manifest.json` at its top. Every `.cs` in it is listed in the +# (`RunicGatewayZones.cs`, `RunicGatewayDomes.cs`, `RunicGatewayRaids.cs`) and `manifest.json` at its top. Every `.cs` in it is listed in the # manifest's `files`, and the installer and the egg place every one of them. It does NOT mirror `oxide/plugins/`: the same file goes to # `oxide/plugins/` or `carbon/plugins/`, and the installer and the egg each # decide which (§34.2.1). The fixed, unversioned prefix is so a reader never @@ -41,7 +41,8 @@ # committed file keeps its placeholder; the manifest records the commit. # Only the bridge is stamped. A helper (docs/modules/rust/PLAN_FIXES.md D168, # D182 — today `RunicGatewayZones.cs` for ZoneManager and `RunicGatewayDomes.cs` -# for ZoneDomes, PLAN_REDESIGNS D194) keeps its own +# for ZoneDomes, PLAN_REDESIGNS D194, and `RunicGatewayRaids.cs` for RaidableBases, +# D204) keeps its own # version: it does one job, changes rarely, and the bridge reads its version at # hello to know which job it does. # @@ -81,7 +82,7 @@ env: PLUGIN: overlay/oxide/plugins/RunicGateway.cs # The helpers shipped beside the bridge (D168, D182). Optional at runtime — # the bridge loads without them — and installed by default. - HELPERS: overlay/oxide/plugins/RunicGatewayZones.cs overlay/oxide/plugins/RunicGatewayDomes.cs + HELPERS: overlay/oxide/plugins/RunicGatewayZones.cs overlay/oxide/plugins/RunicGatewayDomes.cs overlay/oxide/plugins/RunicGatewayRaids.cs SEED_VERSION: "0.1.0" INSTALLER_REPO: RunicGateway/installer diff --git a/README.md b/README.md index 5ba7364..ba7a598 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ unauthenticated command channel on the network. overlay/oxide/plugins/RunicGateway.cs → /oxide/plugins/RunicGateway.cs overlay/oxide/plugins/RunicGatewayZones.cs → /oxide/plugins/RunicGatewayZones.cs overlay/oxide/plugins/RunicGatewayDomes.cs → /oxide/plugins/RunicGatewayDomes.cs +overlay/oxide/plugins/RunicGatewayRaids.cs → /oxide/plugins/RunicGatewayRaids.cs ``` `RunicGatewayZones.cs` is the **ZoneManager helper** (docs/modules/rust/PLAN_FIXES.md D181, D182). ZoneManager @@ -52,6 +53,17 @@ helper patches the first with Harmony and tells the bridge when ZoneDomes has st domes back without drawing any twice. It is optional: without it the site offers no dome, and says which piece is missing. `rgd.status` in the server console says whether it patched. +`RunicGatewayRaids.cs` is the **RaidableBases helper** (docs/modules/rust/PLAN_REDESIGNS.md §11.4, D204–D207, +D387). RaidableBases' API spawns nothing and despawns nothing, so an event's base cannot be the event's from the +outside. The helper reaches its internals by reflection and patches its spawn queue, `OpenEvent` and each base's +`BypassUseOwners()` with Harmony: it tags the base a run asked for at birth, applies that spawn's own PvP, NPCs, +despawn time and lock on a copy of its profile, places one at a spot after checking it, despawns that one base at +the run's end, and reloads RaidableBases' profiles when the site edits them. It serves both editions, free and +paid, and reads every difficulty, profile and base name from the installed plugin. It is optional: without it the +bridge still reports raid bases on the feed and the map, and a step can still ask RaidableBases for a base where it +picks, but that base despawns on its own timer, and the site says so. `rgr.status` in the server console says +whether it patched, which edition's spawn call it found, and whether the plugin's spawn grid is built. + Oxide compiles and loads it on the write, and writes `oxide/config/RunicGateway.json` on first load: ```json @@ -101,6 +113,68 @@ Clans on the website come from Rust's **own** clan system, not from the uMod `Cl is optional. The two share no data: a server running the plugin has two unrelated clan systems, and only the game's own clans become Teams on the site. +Wulf's `Economics` is optional too, and not in `overlay.toml`. Where it is loaded the site can pay +coins from an event, show balances and a richest column, and let staff adjust a balance; where it is +not, each of those says "Economics is not installed on this server". + +WhiteThunder's `Backpacks` is optional in the same way. Where it is loaded staff can open a player's +backpack from the site, a player can see their own, and an event can lend a bigger one for its run; +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. + +nivex's `TruePVE` is optional in the same way. Where it is loaded an event's zone can turn off PvP +protection inside it, or use one of the operator's own TruePVE rulesets by name; where it is not, the +zone step says "TruePVE is not installed on this server". The bridge only ever adds and removes the +mappings of zones it opened, and removes each one with its zone, even when TruePVE was away at the time. + +HunterZ's `Dynamic PVP` (which needs ZoneManager and TruePVE) is optional too. Where it is loaded, the +PvP zones it opens around a downed helicopter, a Bradley, cargo and the rest reach the site's feed and +its map. The bridge only reads them: it never makes, maps or erases one of Dynamic PVP's zones. + +nivex's `Dangerous Treasures` is optional as well. Where it is loaded, its treasure events reach the +site's feed, its map and the *Treasure Hunter* title, and an event can open one (`dt.start`) through +DT's own `dtevent` console command; DT picks the spot and despawns its own chest. For about ten minutes +after DT loads it is still finding its spots, and a start then is answered "not ready yet". A framework +that cannot run another plugin's console command from code (`rg.world` says `consolePlugins=False`) +cannot open one. + +Bazz3l's `Guarded Crate` is optional too, and needs `NpcSpawn` (a free Codefling download, not shipped +here) for its guards. Where both are loaded, a guarded crate landing, being cleared and timing out +reach the site's feed, its map and the *Warden* title, and an event can place one (`gc.place`) at a +monument or a point through Guarded Crate's own `gcrate position` console command. A placed crate is +kept when the event ends: Guarded Crate has no way to end one event alone. Without NpcSpawn a placement +is refused, because the crate would land unguarded; and, as for Dangerous Treasures, a framework with +`consolePlugins=False` cannot place one. + +nivex's `RaidableBases` is optional, free or paid, and needs `CopyPaste` to paste its bases. Where it is +loaded, a base appearing, being raided, bought or locked to a player reach the site's feed, its map's own +*Raid bases* layer and the *Warlord* title, and an event can spawn one (`raid.spawn`). With +`RunicGatewayRaids.cs` (above) the base is the event's: at a spot or where RaidableBases picks, with its own +settings, despawned when the event ends; and the site can edit RaidableBases' profiles from its configuration +page, reloading them under watch. For up to ten minutes after RaidableBases loads it builds its spawn grid and +refuses every spawn, and a spawn then is answered "not ready". Every difficulty, profile and base name is read +from the installed plugin. + +nivex's `ZLevels Remastered` is optional, and the bridge only says whether it is loaded. Its levels are +never read or sent. Its XP boosts are its own permissions (`zlevelsremastered.vip1` to `vip3`), so an +event lends one as it lends any other permission, and it takes effect at once. + +An event can also run console commands an admin typed (`command.run`): once, as a step, or once for each +recipient of a reward who is online, with `{steamid}` and `{name}` filled in (the name as one quoted +argument). That is how a plugin with no support of its own here is used from an event, for example +RaidableBases' `rbe`. There is no allowlist: the site's switchboard is the switch, and `EventsEnabled` +here is the server's. Each request runs at most once: its key is written to +`data/RunicGateway/commands-run.json` before anything runs, and is kept for seven days. A step's +teardown commands are kept with its key too, so the site can have them run even for a step that failed +part-way or whose answer was lost (`teardownOf`). A command the +server does not know stops the request there as `unknown-command`. On a framework with +`consolePlugins=False`, that is every plugin's command. + +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 55d57c2..568138a 100644 --- a/overlay.toml +++ b/overlay.toml @@ -26,15 +26,23 @@ # the same change that alters the emitters, exactly as the sidecar bumps # PROTOCOL_VERSION and the module bumps its own constant. # -# Current: 13 — the transport, the read path, identity, the permission mirror, +# Current: 14 — the transport, the read path, identity, the permission mirror, # configuration from the site, first-party clans, a raid frame that names who # lives there, the event leases, the world verbs events place and give back, and # the rewards: a run's participation tally, kit credits and chat (PROTOCOL.md §16), # the map: its picture and everything that moves on it (PROTOCOL.md §17), and the # optional mods: BetterChat titles and group styles, and popups (PROTOCOL.md §18), # and the first player walk's fixes (PROTOCOL.md §19) — opened by a configuration -# save that answers at once and reports its reload as a `config.outcome` frame. -protocol = 13 +# save that answers at once and reports its reload as a `config.outcome` frame, +# and the step editor (PROTOCOL.md §20): `kits.uses`, and the one-time reward +# 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`; 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 4217de3..d69bd1a 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -56,8 +56,34 @@ namespace Oxide.Plugins /// PROTOCOL_VERSION in the sidecar, and module.json in Module-Rust. The /// overlay's overlay.toml carries this same number so the installer can refuse /// to pair a sidecar and an overlay that disagree. + /// + /// + /// 14 (PROTOCOL.md §20, the module's PLAN_REDESIGNS.md §2): kits.uses, and the + /// one-time reward messages perm.sync carries as notices (D333); and + /// 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; 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; and TruePVE (§20.6, PLAN_REDESIGNS.md §14.3.2): + /// world.zone's truePve and the integrations.truePve it needs; and + /// Dynamic PVP (§20.7, PLAN_REDESIGNS.md §14.3.3): pvp.zone.created and + /// pvp.zone.deleted, map.live's pvp layer and + /// integrations.dynamicPvp; and Dangerous Treasures (§20.8, PLAN_REDESIGNS.md + /// §14.3.4): dt.started, dt.won and dt.ended, dt.start, + /// map.live's treasure rows, the tally's treasuresWon and + /// integrations.dangerousTreasures; and Guarded Crate (§20.9, PLAN_REDESIGNS.md + /// §14.3.5): gc.started, gc.ended and gc.failed, gc.place, + /// map.live's guarded rows, the tally's guardedWon and + /// integrations.guardedCrate with NpcSpawn beside it; and ZLevels Remastered + /// (§20.10, PLAN_REDESIGNS.md §14.3.6): integrations.zLevels, and nothing more; + /// and console commands as event steps (§20.11, PLAN_REDESIGNS.md §16): + /// command.run, answered command.ran or command.error. + /// /// - private const int ProtocolVersion = 13; + private const int ProtocolVersion = 14; /// /// Loopback reconnects are cheap, so the ceiling is low. A sidecar restart should cost @@ -250,7 +276,24 @@ namespace Oxide.Plugins "OnExplosiveThrown", "OnExplosiveDropped", "OnMissionSucceeded", "OnEnterZone", "OnExitZone", "OnRgDomesReady", "OnRunicNpcDied", "OnRunicNpcHealth", "OnRunicNpcPlacementChanged", - "OnRunicNpcBossSpawned", "OnRunicNpcBossPhase", "OnRunicNpcBossDied" + "OnRunicNpcBossSpawned", "OnRunicNpcBossPhase", "OnRunicNpcBossDied", + "OnPlayerSleepEnded", + "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", + // Protocol 14, Dynamic PVP's zones (§20.7). + "OnCreatedDynamicPVP", "OnDeletedDynamicPVP", + // Protocol 14, Dangerous Treasures (§20.8). + "OnDangerousEventStarted", "OnDangerousEventWon", "OnDangerousEventEnded", + // Protocol 14, Guarded Crate (§20.9). + "OnGuardedCrateEventStart", "OnGuardedCrateEventEnded", "OnGuardedCrateEventFailed", + // Protocol 14, RaidableBases (§20.12). + "OnRaidableBaseStarted", "OnRaidableBaseEnded", "OnRaidableBaseCompleted", + "OnRaidableBasePurchased", "OnRaidableBaseLocked" }; private void Init() @@ -433,6 +476,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. @@ -515,12 +565,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); } @@ -532,8 +583,14 @@ namespace Oxide.Plugins // lease command can arrive before the world has finished loading, and it must find // the holds already in place rather than have them loaded over it afterwards. LoadLeases(); + LoadTruePvePending(); LoadWorld(); LoadRewards(); + LoadWorldCalls(); + LoadDtStarts(); + LoadGcPlacements(); + LoadRaidStarts(); + LoadCommandRecords(); } // ---- the outbound queue ---- @@ -1026,6 +1083,41 @@ 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 "dt.start": + // Protocol 14 (PROTOCOL.md §20.8, D365). One Dangerous Treasures event, opened + // through DT's own console command; DT picks the spot and despawns its own. + HandleDtStart(cmd, reqId); + break; + + case "gc.place": + // Protocol 14 (PROTOCOL.md §20.9, D364, D373). One guarded crate, placed through + // Guarded Crate's own console command; kept when the run ends. + HandleGcPlace(cmd, reqId); + break; + + case "raid.spawn": + // Protocol 14 (PROTOCOL.md §20.12, D203-D208, D387). One RaidableBases base for a + // run: the plugin's pick or an admin's spot, despawned at the run's end. + HandleRaidSpawn(cmd, reqId); + break; + + case "raid.check": + // Protocol 14 (PROTOCOL.md §20.12, D208, D388, D389). A spot's checks, spawning nothing. + HandleRaidCheck(cmd, reqId); + break; + + case "command.run": + // Protocol 14 (PROTOCOL.md §20.11, D382-D384). Console commands a step's author + // typed: once, or once per online recipient; at most once per key. + HandleCommandRun(cmd, reqId); + break; + case "tally.open": // Protocol 10 (PLAN.md §29). Who takes part in a run, counted by the game. HandleTallyOpen(cmd, reqId); @@ -1043,6 +1135,33 @@ namespace Oxide.Plugins HandleKitsList(reqId); break; + case "kits.uses": + // Protocol 14 (the module's PLAN_REDESIGNS.md §2.6). How many of the given + // players have used a kit up, for the warning beside a kit weekend. + HandleKitsUses(cmd, reqId); + break; + + case "economics.pay": + // Protocol 14 (PROTOCOL.md §20.3, the module's PLAN_REDESIGNS.md §11.2). Coins + // from an event step, kept once paid (D335). + HandleEconomicsPay(cmd, reqId); + break; + + case "economics.balances": + HandleEconomicsBalances(cmd, reqId); + break; + + case "economics.adjust": + // Staff, from a player's admin page: deposit, withdraw or set. + HandleEconomicsAdjust(cmd, reqId); + break; + + case "backpacks.read": + // Protocol 14 (PROTOCOL.md §20.4, the module's PLAN_REDESIGNS.md §11.3). One + // player's backpack, read and never written (R2). + HandleBackpacksRead(cmd, reqId); + break; + case "chat.say": HandleChatSay(cmd, reqId); break; @@ -1289,6 +1408,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; } @@ -1555,8 +1677,21 @@ namespace Oxide.Plugins DropWorldFromOtherWipes(); RecreateZones("load"); + // Mappings an erase left while TruePVE was away (§14.3.2). When TruePVE loads after + // this, its own OnPluginLoaded does it. + FlushTruePvePending("load"); + // The map's sets are seeded here for the same reason: a hotload hears no spawns. SeedMapSets(); + SeedPvpZones("load"); + SeedTreasures("load"); + NoteDtSeen(DangerousTreasuresPlugin() != null); + SeedGuardedCrates("load"); + SeedRaids("load"); + + // 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, @@ -1616,6 +1751,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(); @@ -1641,6 +1777,10 @@ namespace Oxide.Plugins frame["steamId"] = SteamIdOf(player); frame["name"] = player.displayName; EmitFrame(frame); + + // A balance carried over from last wipe is known at once, without waiting for it to + // move (D336). Nothing when Economics is not loaded. + NoteBalance(player); } private void OnPlayerDisconnected(BasePlayer player, string reason) @@ -1835,10 +1975,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; } @@ -1949,13 +2090,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) { @@ -1965,7 +2099,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) { @@ -1978,49 +2114,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); } /// @@ -2825,8 +2919,24 @@ 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; + + /// Protocol 14 (D375): Dangerous Treasures chests this player emptied (Treasure Hunter). + public int TreasuresWon; + + /// Protocol 14 (D375): guarded crates this player cleared, by killing the last guard (Warden). + public int GuardedWon; + + /// Protocol 14 (§11.4, D202): RaidableBases bases this player completed as a raider (Warlord). + public int RaidsCompleted; public readonly Dictionary Crafted = new Dictionary(); /// Stage 4 (D247): kills of RunicNPC's NPCs, by profile name. Also in . @@ -2838,6 +2948,14 @@ namespace Oxide.Plugins public int Explosives; public int Missions; + /// + /// Protocol 14 (§11.2.2): the player's Economics balance, a value and not a change, + /// like the bests. Sent only when : it moved, or was read at + /// connect, since the last flush. + /// + public double Balance; + public bool HasBalance; + public void Gather(string shortname, int amount) { Add(Gathered, shortname, amount); @@ -2886,9 +3004,10 @@ 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 - && Crafted.Count == 0 && Built == 0 && Repaired == 0 && Healed == 0 - && Rockets == 0 && Explosives == 0 && Missions == 0 && NpcProfileKills.Count == 0; + && BestPvpM <= 0f && BestPveM <= 0f && CratesHacked == 0 && DropsLooted == 0 && TreasuresWon == 0 && GuardedWon == 0 + && RaidsCompleted == 0 && Crafted.Count == 0 && Built == 0 && Repaired == 0 && Healed == 0 + && Rockets == 0 && Explosives == 0 && Missions == 0 && NpcProfileKills.Count == 0 + && !HasBalance; } } @@ -2906,6 +3025,23 @@ namespace Oxide.Plugins return tally; } + /// + /// The tally of a player who need not be online — an Economics balance reaches an offline + /// player too. It carries no name, and the frame it flushes has none. + /// + private Tally TallyForId(ulong userId) + { + Tally tally; + + if (!_tallies.TryGetValue(userId, out tally)) + { + tally = new Tally(); + _tallies[userId] = tally; + } + + return tally; + } + /// /// Emits one player.tally per player with anything to report, and clears what it /// emitted. @@ -2969,10 +3105,16 @@ 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.TreasuresWon > 0) + frame["treasuresWon"] = tally.TreasuresWon; + if (tally.GuardedWon > 0) + frame["guardedWon"] = tally.GuardedWon; + if (tally.RaidsCompleted > 0) + frame["raidsCompleted"] = tally.RaidsCompleted; if (tally.Crafted.Count > 0) frame["crafted"] = tally.Crafted; if (tally.Built > 0) @@ -2990,6 +3132,10 @@ namespace Oxide.Plugins if (tally.NpcProfileKills.Count > 0) frame["npcProfileKills"] = tally.NpcProfileKills; + // Protocol 14: the current balance, a value the site stores rather than sums. + if (tally.HasBalance) + frame["balance"] = Math.Round(tally.Balance, 2); + EmitFrame(frame); } @@ -3609,6 +3755,15 @@ namespace Oxide.Plugins public bool CreditsWaiting; public readonly List CreditsUnresolved = new List(); + /// + /// The site's untold reward messages for this server (protocol 14, D333), or null when + /// the sync carried none — an older website, whose silence must not cancel anything. + /// + public List Notices; + + /// The keys of already told, for the report. + public readonly List NoticesTold = new List(); + /// /// Whether BetterChat was loaded when this sync was compiled (protocol 12). When it /// was not, no style op is compiled and the report says loaded: false and @@ -4394,6 +4549,38 @@ namespace Oxide.Plugins } } + // ── The reward messages (protocol 14, D333) ──────────────────────── + // + // "You earned kit vip", said once to each player, now or when they next wake up. The + // site's WHOLE untold set, like the credits: a notice it stops sending is one it has + // been told about, or one whose reward was withdrawn before anybody heard of it. + if (cmd.ContainsKey("notices")) + { + job.Notices = new List(); + + foreach (Dictionary row in Rows(cmd, "notices")) + { + string key = StringField(row, "key"); + string steamId = StringField(row, "steamId"); + string message = Flatten(StringField(row, "message") ?? ""); + ulong id; + + if (string.IsNullOrEmpty(key) || key.Length > NoticeKeyMax || !ulong.TryParse(steamId, out id) || message.Length == 0) + continue; + + if (job.Notices.Count >= NoticesMax) + break; + + job.Notices.Add(new RewardNotice + { + Key = key, + SteamId = steamId, + Message = message.Length > MaxChat() ? message.Substring(0, MaxChat()) : message, + Delivery = StringField(row, "delivery") == DeliveryPopup ? DeliveryPopup : DeliveryChat + }); + } + } + if (job.Ops.Count > PermMaxOps) { EmitPermError(reqId, "too-large"); @@ -4470,6 +4657,20 @@ namespace Oxide.Plugins } } + if (job.Notices != null) + { + try + { + ApplyNotices(job); + } + catch (Exception ex) + { + // The grants have landed and must still be reported; an untold notice is told + // by the next sync, or when its player wakes. + Puts($"could not tell reward notices: {Flatten(ex.Message)}"); + } + } + SendPermReport(job); } @@ -4817,6 +5018,12 @@ namespace Oxide.Plugins report["creditsWaiting"] = job.CreditsWaiting; } + // Protocol 14 (D333). Every notice of this sync's set that its player has been told — + // at this sync or before it — so the site marks it told and stops sending it. Present + // only when the sync carried `notices`, like the credit counts. + if (job.Notices != null) + report["noticesTold"] = job.NoticesTold; + // Protocol 12. Always present, so a site can tell "BetterChat is absent" from "this // plugin predates styles" — the second has no `chat` at all. var chat = new Dictionary { { "loaded", job.ChatLoaded } }; @@ -5028,11 +5235,44 @@ namespace Oxide.Plugins if (plugin != null && plugin != this && plugin.Name == "ZoneManager") NextTick(() => RecreateZones("ZoneManager reloaded")); + // TruePVE has read its table by now (it does in Init), so what an erase left while it + // was away can go (§14.3.2). + if (IsTruePve(plugin)) + NextTick(() => FlushTruePvePending("TruePVE loaded")); + // Hello carries ZoneManager's flag list and both helpers' states, so a change to any // of the four is said again rather than waiting for the next connect (§3.1, §3.2). - if (IsZonePlugin(plugin) || IsRunicNpc(plugin)) + // Economics too: hello carries its starting balance, which the richest column needs; + // and Backpacks, whose sizes are what a Bigger backpack step offers; and TruePVE, which a + // zone step's PvP rules are refused without (§14.3.2); and Dynamic PVP (§14.3.3). + // Guarded Crate and NpcSpawn too: a placement is refused without either (D364). And + // ZLevels, which the servers page names (§14.3.6). + if (IsZonePlugin(plugin) || IsRunicNpc(plugin) || IsEconomics(plugin) || IsBackpacks(plugin) || IsTruePve(plugin) || IsDynamicPvp(plugin) || IsDangerousTreasures(plugin) || IsGuardedCrate(plugin) || IsZLevels(plugin) || IsRaidPlugin(plugin)) NextTick(ResendHello); + // Dynamic PVP raises its creates as it opens its zones, which at a load are its + // monuments'; seeding once it has finished starting catches any it opened before us. + if (IsDynamicPvp(plugin)) + NextTick(() => SeedPvpZones("Dynamic PVP loaded")); + + // Its spawn grid is built from now, so a start that finds no spot is "not ready" for a + // while (D365); and any chest a reload kept is found again. + if (IsDangerousTreasures(plugin)) + { + NoteDtSeen(true); + NextTick(() => SeedTreasures("Dangerous Treasures loaded")); + } + + // RaidableBases' live bases, found again: a reload of it keeps none, but a hotload of + // this bridge hears no starts (§20.12). + if (plugin != null && plugin != this && plugin.Name == "RaidableBases") + NextTick(() => SeedRaids("RaidableBases loaded")); + + // Economics loaded after players connected: what they hold now is not otherwise known + // until it moves (D336). + if (IsEconomics(plugin)) + NextTick(NoteOnlineBalances); + // BetterChat keeps third-party titles on its INSTANCE, so a reloaded BetterChat has // forgotten our getter. Registering again is the only way back (§33.1). if (plugin != null && plugin != this && plugin.Name == "BetterChat") @@ -5055,9 +5295,29 @@ namespace Oxide.Plugins if (plugin != null && plugin.Name == "BetterChat") _titlesRegistered = false; - if (IsZonePlugin(plugin) || IsRunicNpc(plugin)) + if (IsZonePlugin(plugin) || IsRunicNpc(plugin) || IsEconomics(plugin) || IsBackpacks(plugin) || IsTruePve(plugin) || IsDynamicPvp(plugin) || IsDangerousTreasures(plugin) || IsGuardedCrate(plugin) || IsZLevels(plugin) || IsRaidPlugin(plugin)) NextTick(ResendHello); + // Guarded Crate's Unload ended every event WITHOUT a hook. + if (plugin != null && plugin != this && plugin.Name == "GuardedCrate") + _guardedCrates.Clear(); + + // Its Unload deleted every zone, each with its hook; whatever is left was missed. + if (IsDynamicPvp(plugin)) + _pvpZones.Clear(); + + // Its Unload ended every chest, each with its hook. + if (IsDangerousTreasures(plugin)) + { + _treasures.Clear(); + NoteDtSeen(false); + } + + // Its Unload despawned every base, each with its hooks; a run's base still on its way + // is gone with the queue (§20.12). + if (plugin != null && plugin != this && plugin.Name == "RaidableBases") + RaidableBasesGone(); + ConfigWriteJob job = _configJob; if (job == null || plugin == null) @@ -5299,6 +5559,15 @@ namespace Oxide.Plugins /// The log lines that showed the failure, or the tail read when the ceiling passed. public string RollbackLog; + + /// + /// RaidableBases' profiles rather than a plugin's config (D205): reloaded by the raids + /// helper as rb.reloadprofiles does, and judged by whether each profile loaded. + /// + public bool Profiles; + + /// The helper's finished reloads when this phase's reload was asked for; one more is this one. + public int ReloadsBefore; } private ConfigWriteJob _configJob; @@ -5324,6 +5593,7 @@ namespace Oxide.Plugins try { WalkConfig(root, root, 0, files, ref truncated); + ListRaidProfiles(files, ref truncated); } catch (Exception ex) { @@ -5664,6 +5934,34 @@ namespace Oxide.Plugins }); } + // RaidableBases' profiles reload through its own command, not a plugin reload (D205), so + // a write is all profiles or none, and refused before anything lands when the reload + // could not be asked for or watched. + int profileFiles = 0; + + foreach (StagedConfigWrite file in staged) + { + if (IsRaidProfilePath(file.RelPath)) + profileFiles++; + } + + if (profileFiles > 0 && profileFiles < staged.Count) + { + EmitConfigError(reqId, "mixed", "RaidableBases' profiles reload on their own; save them apart from other files"); + return; + } + + if (profileFiles > 0) + { + string refusal = RaidProfileWriteRefusal(); + + if (refusal != null) + { + EmitConfigError(reqId, "profiles-unavailable", refusal); + return; + } + } + foreach (StagedConfigWrite file in staged) { try @@ -5682,6 +5980,12 @@ namespace Oxide.Plugins } } + if (profileFiles > 0) + { + StartRaidProfileJob(reqId, staged); + return; + } + // This plugin never reloads itself (§21.3), so a change to its own settings would // otherwise wait for somebody's next manual reload — and `EventsEnabled` is the first // of them an operator is MEANT to change from the website (protocol 8, D76). It is @@ -5803,6 +6107,12 @@ namespace Oxide.Plugins return; } + if (job.Profiles) + { + RaidProfileWatchTick(job); + return; + } + string failure = ReadConfigFailure(job); if (failure != null && IsCompileStallOf(failure, job.Reload)) @@ -6311,6 +6621,415 @@ namespace Oxide.Plugins } } + // ---- RaidableBases' profiles in the editor (PLAN_REDESIGNS §11.4.3, D205) ---- + // + // The editor never walks a plugin's data directory, which holds live state and the + // permission stores. **One folder is the named exception**: RaidableBases keeps its profiles + // (every base's NPCs, loot rules, timers and protection) in `data/RaidableBases/Profiles/`, + // and an operator tunes them there. They travel under a virtual prefix so no path from the + // config tree can ever name them, and they get the editor's usual guarantees: version + // checked, backed up, written, reloaded under watch, and rolled back if the profile does not + // load. The reload is `rb.reloadprofiles`'s, asked of the raids helper, and "loaded" is the + // helper finding the profile in the plugin's own table after the reload finished. + + private const string RaidProfilePrefix = "@data/RaidableBases/Profiles/"; + + private static bool IsRaidProfilePath(string rel) + { + return rel != null && rel.StartsWith(RaidProfilePrefix, StringComparison.Ordinal); + } + + private static string RaidProfilesDir() + { + try + { + return Path.Combine(Path.Combine(Interface.Oxide.DataDirectory, "RaidableBases"), "Profiles"); + } + catch (Exception) + { + return null; + } + } + + /// A profile file's path on this host: one .json directly in the profiles folder, never a link. + private static bool ResolveRaidProfilePath(string rel, out string full, out string refusal) + { + full = null; + refusal = null; + string name = rel.Substring(RaidProfilePrefix.Length); + + if (name.Length == 0 || name.IndexOf('/') >= 0 || name.IndexOf('\\') >= 0 || name.IndexOf(':') >= 0 + || name.IndexOf('\0') >= 0 || name.IndexOf("..", StringComparison.Ordinal) >= 0 + || !name.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) + { + refusal = "a RaidableBases profile is one .json file directly in its profiles folder"; + return false; + } + + string dir = RaidProfilesDir(); + + if (string.IsNullOrEmpty(dir)) + { + refusal = "this framework reports no data directory"; + return false; + } + + try + { + string canonicalDir = Path.GetFullPath(dir); + string candidate = Path.GetFullPath(Path.Combine(canonicalDir, name)); + + if (!string.Equals(Path.GetDirectoryName(candidate), canonicalDir.TrimEnd(Path.DirectorySeparatorChar), StringComparison.OrdinalIgnoreCase)) + { + refusal = "a path may not step outside RaidableBases' profiles folder"; + return false; + } + + if (File.Exists(candidate) && (new FileInfo(candidate).Attributes & FileAttributes.ReparsePoint) != 0) + { + refusal = "a link is not followed"; + return false; + } + + full = candidate; + return true; + } + catch (Exception ex) + { + refusal = ex.Message; + return false; + } + } + + /// Main thread. The profile files, as config rows under the virtual prefix. Directory entries only, like the walk. + private static void ListRaidProfiles(List into, ref bool truncated) + { + string dir = RaidProfilesDir(); + + if (string.IsNullOrEmpty(dir) || !Directory.Exists(dir)) + return; + + string[] entries = Directory.GetFiles(dir, "*.json"); + Array.Sort(entries, StringComparer.OrdinalIgnoreCase); + + foreach (string path in entries) + { + if (into.Count >= ConfigMaxFiles) + { + truncated = true; + return; + } + + FileInfo info; + + try + { + info = new FileInfo(path); + } + catch (Exception) + { + continue; + } + + // RaidableBases skips its own placeholder, and so does the editor. + if ((info.Attributes & FileAttributes.ReparsePoint) != 0 || info.Name.IndexOf("_empty", StringComparison.OrdinalIgnoreCase) >= 0) + continue; + + var row = new Dictionary + { + { "path", RaidProfilePrefix + info.Name }, + { "bytes", info.Length }, + { "modified", ToUnixMs(info.LastWriteTimeUtc) }, + { "plugin", "RaidableBases" }, + { "profile", true } + }; + + if (info.Length > ConfigMaxFileBytes) + { + row["editable"] = false; + row["reason"] = "larger than this bridge will carry"; + } + else + { + row["editable"] = true; + } + + into.Add(row); + } + } + + /// Why a profile write cannot be reloaded and watched now, or null. + private string RaidProfileWriteRefusal() + { + if (RaidableBasesPlugin() == null) + return "RaidableBases is not loaded, so its profiles cannot be reloaded"; + + if (!RaidsHelperPatched()) + return RaidsHelperState() == "missing" + ? "a profile is saved through RunicGatewayRaids.cs, which reloads it and checks it loaded, and it is not installed" + : $"RunicGatewayRaids.cs could not patch this RaidableBases ({RaidsHelperState()}), so a profile cannot be reloaded and checked"; + + if (!(RaidsHelperPlugin().Call("GridReady") is bool ready) || !ready) + return "RaidableBases is still building its spawn grid (up to ten minutes after it loads) and refuses to reload its profiles until it has"; + + return null; + } + + /// Main thread. The files are on disk: ask for the reload, answer at once, watch. + private void StartRaidProfileJob(string reqId, List staged) + { + Plugin helper = RaidsHelperPlugin(); + + var job = new ConfigWriteJob + { + WriteId = Guid.NewGuid().ToString("N"), + Reload = "RaidableBases", + Staged = staged, + Phase = ConfigPhase.Edit, + Started = DateTime.UtcNow, + PhaseStarted = DateTime.UtcNow, + Profiles = true, + ReloadsBefore = helper.Call("ReloadsFinished") is int done ? done : 0 + }; + + // RaidableBases' own words on a profile it cannot read are logged during the reload, + // so the log is read from here when it is rolled back (W9). + MarkLogPosition(job); + + if (!(helper.Call("ReloadProfiles") is bool asked) || !asked) + { + RestoreConfig(staged); + EmitConfigError(reqId, "reload-failed", "RaidableBases would not reload its profiles now (its grid, or a reload already running)"); + return; + } + + _configJob = job; + job.Watch = timer.Every(ConfigLogPollSeconds, ConfigWatchTick); + SendConfigReport(reqId, staged, false, false, null, null, job); + } + + /// + /// Main thread, once a second while a profile reload is pending. Settled when the helper + /// says the reload finished, by whether every written profile is in the plugin's table; + /// a profile that is not is rolled back and the old one reloaded. + /// + private void RaidProfileWatchTick(ConfigWriteJob job) + { + Plugin helper = RaidsHelperPlugin(); + + if (helper == null || RaidableBasesPlugin() == null) + { + if (job.Phase == ConfigPhase.Edit) + { + RestoreConfig(job.Staged); + SettleConfigJob(job, false, true, false, "RaidableBases or its helper unloaded before the profiles finished reloading; the files are back", null); + } + else + { + SettleConfigJob(job, false, true, false, $"{job.RollbackReason}; RaidableBases or its helper unloaded before the restored profiles reloaded", job.RollbackLog); + } + + return; + } + + int finished = helper.Call("ReloadsFinished") is int n ? n : 0; + + if (finished > job.ReloadsBefore) + { + var failed = new List(); + + foreach (StagedConfigWrite file in job.Staged) + { + string name = Path.GetFileNameWithoutExtension(file.FullPath); + + if (helper.Call("ProfileState", name) as string != "loaded") + failed.Add(name); + } + + if (job.Phase == ConfigPhase.Edit) + { + if (failed.Count == 0) + SettleConfigJob(job, true, false, null, null, null); + else + RollBackRaidProfiles(job, $"RaidableBases did not load profile {string.Join(", ", failed.ToArray())}", RaidProfileErrors(job, failed)); + } + else + { + SettleConfigJob(job, false, true, failed.Count == 0, + failed.Count == 0 ? job.RollbackReason : $"{job.RollbackReason}; the restored profile did not load either", + job.RollbackLog); + } + + return; + } + + if ((DateTime.UtcNow - job.PhaseStarted).TotalSeconds < ConfigReloadCeilingSeconds) + return; + + if (job.Phase == ConfigPhase.Edit) + RollBackRaidProfiles(job, $"RaidableBases' profile reload did not finish within {ConfigReloadCeilingSeconds:0}s", RaidProfileErrors(job, null)); + else + SettleConfigJob(job, false, true, false, $"{job.RollbackReason}; the restored profiles did not finish reloading either", job.RollbackLog); + } + + private void RollBackRaidProfiles(ConfigWriteJob job, string reason, string log) + { + RestoreConfig(job.Staged); + + job.Phase = ConfigPhase.Restore; + job.PhaseStarted = DateTime.UtcNow; + job.RollbackReason = reason; + job.RollbackLog = log; + + Plugin helper = RaidsHelperPlugin(); + job.ReloadsBefore = helper != null && helper.Call("ReloadsFinished") is int done ? done : 0; + + if (helper == null || !(helper.Call("ReloadProfiles") is bool asked) || !asked) + SettleConfigJob(job, false, true, false, $"{reason}; the files are back, but RaidableBases would not reload them", log); + } + + /// The most lines of one of RaidableBases' entries a rollback carries: its file, the exception, its message. + private const int RaidProfileErrorLinesMax = 6; + + /// + /// A log line that begins an entry: Oxide's 06:50 [Info] or Carbon's + /// [2026.10.21 18:45:45] [INFO] . Anything else continues the entry above it. + /// + private static readonly System.Text.RegularExpressions.Regex LogEntryStart = + new System.Text.RegularExpressions.Regex(@"^(\d{2}:\d{2} |\[\d{4}\.\d{2}\.\d{2} \d{2}:\d{2}:\d{2}\] )\[", System.Text.RegularExpressions.RegexOptions.CultureInvariant); + + /// + /// Main thread. What RaidableBases itself logged since the profile write began (W9), for + /// a rollback: an unreadable profile is one entry, the file's path and then the exception + /// (Could not convert string to integer: not a number. Path 'Difficulty Level', line 3), + /// and that much is carried, without the stack. With , only the + /// entries naming one of those profiles; with none of those, or none asked for, every + /// entry of its own since the write. Null when it said nothing: the reason line stands + /// alone rather than borrowing the server log's tail, which is other plugins' lines. + /// + private string RaidProfileErrors(ConfigWriteJob job, List failed) + { + string text = LogSinceMark(job); + + if (string.IsNullOrEmpty(text)) + return null; + + Plugin rb = RaidableBasesPlugin(); + var tags = new List { "[RaidableBases]" }; + + if (rb != null && !string.IsNullOrEmpty(rb.Title)) + tags.Add("[" + rb.Title + "]"); + + var named = new StringBuilder(); + var all = new StringBuilder(); + StringBuilder entry = null; + bool namesFailed = false; + int kept = 0; + + Action close = () => + { + if (entry == null) + return; + + all.Append(entry); + + if (namesFailed) + named.Append(entry); + + entry = null; + }; + + foreach (string raw in text.Split('\n')) + { + string line = raw.TrimEnd('\r'); + + if (LogEntryStart.IsMatch(line)) + { + close(); + + if (!tags.Exists(t => line.IndexOf(t, StringComparison.OrdinalIgnoreCase) >= 0)) + continue; + + entry = new StringBuilder(line).Append('\n'); + kept = 1; + namesFailed = failed != null && failed.Exists(n => line.IndexOf(n + ".json", StringComparison.OrdinalIgnoreCase) >= 0); + continue; + } + + // The stack is the plugin's, not the operator's: the message above it is the error. + if (entry == null || kept >= RaidProfileErrorLinesMax || line.TrimStart().StartsWith("at ", StringComparison.Ordinal)) + { + if (entry != null) + kept = RaidProfileErrorLinesMax; + + continue; + } + + if (line.Trim().Length == 0) + continue; + + entry.Append(line).Append('\n'); + kept++; + } + + close(); + + string found = (named.Length > 0 ? named : all).ToString().TrimEnd('\n'); + + if (found.Length == 0) + return null; + + return found.Length > ConfigLogTailBytes ? found.Substring(0, ConfigLogTailBytes) : found; + } + + /// + /// Main thread. The log written since , at most + /// ConfigLogPollMaxBytes, without moving the job's own reading place. A log that + /// rotated since is read on from its mark and then from the new file's start. + /// + private string LogSinceMark(ConfigWriteJob job) + { + var text = new StringBuilder(); + + try + { + string newest = NewestLogFile(); + + if (!string.IsNullOrEmpty(job.LogPath) && File.Exists(job.LogPath)) + text.Append(ReadLogFrom(job.LogPath, job.LogOffset, ConfigLogPollMaxBytes)); + + if (newest != null && !string.Equals(newest, job.LogPath, StringComparison.OrdinalIgnoreCase) && text.Length < ConfigLogPollMaxBytes) + text.Append(ReadLogFrom(newest, 0, ConfigLogPollMaxBytes - text.Length)); + } + catch (Exception) + { + // A log that cannot be read leaves the rollback its reason line. + } + + return text.ToString(); + } + + private static string ReadLogFrom(string path, long offset, int max) + { + using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + { + // A log shorter than the mark was truncated: read it from the top. + if (stream.Length < offset) + offset = 0; + + int take = (int)Math.Min(stream.Length - offset, max); + + if (take <= 0) + return string.Empty; + + stream.Seek(offset, SeekOrigin.Begin); + var buffer = new byte[take]; + int read = stream.Read(buffer, 0, take); + + return Encoding.UTF8.GetString(buffer, 0, read); + } + } + /// /// Main thread. If a write touched this plugin's own config, take the one setting that /// is safe to change live — EventsEnabled — from what was written. Returns @@ -6622,6 +7341,10 @@ namespace Oxide.Plugins return false; } + // The one named exception to "never the data directory" (D205): RaidableBases' profiles. + if (IsRaidProfilePath(rel)) + return ResolveRaidProfilePath(rel, out full, out refusal); + if (rel.IndexOf('\0') >= 0 || rel.IndexOf("..", StringComparison.Ordinal) >= 0) { refusal = "a path may not step outside the configuration directory"; @@ -6720,6 +7443,9 @@ namespace Oxide.Plugins /// private static string PluginGuess(string rel) { + if (IsRaidProfilePath(rel)) + return "RaidableBases"; + int slash = rel.IndexOf('/'); return slash > 0 @@ -7952,9 +8678,22 @@ namespace Oxide.Plugins public int DomeType; public int DomeStack; + /// + /// The TruePVE mapping this zone was given (PLAN_REDESIGNS §14.3.2): exclude or a + /// ruleset's name, or null for none. Kept so the path that erases the zone removes the + /// mapping too (D362), and touches nothing else in TruePVE's table. + /// + public string TruePve; + /// When a zone is erased without being asked, on this host's clock (D96). Zero for an entity. public long DeadlineMs; + /// A raid base's difficulty name, the plugin's own (§20.12, D386). Null for anything else. + public string Difficulty; + + /// Whether a raid base has opened. Until it does it is on its way, and the position is the spot asked for or zero. + public bool Started; + public string BootId; public string WipeId; public long TakenMs; @@ -8391,6 +9130,2742 @@ namespace Oxide.Plugins NextTick(() => RestoreDomes("ZoneDomes started")); } + // ---- TruePVE in the zone step (PLAN_REDESIGNS §14.3.2, D362, D368) ---- + // + // A zone may ask TruePVE to leave it alone (`exclude`) or to use one of its rulesets. The + // §14.2 test found the three facts this is built on: + // + // * `AddOrUpdateMapping(zoneId, name)` answers false for a ruleset TruePVE does not have, + // and nothing lists the names, so the call is the check (D368). + // * TruePVE keeps its mappings in its own data file, across its reloads and a restart, and + // prunes a mapping whose zone is gone only when the zone id is a number. Ours are `rg-…`, + // so the path that erases one of our zones removes its mapping (D362). + // * It reads that file in `Init`, so by `OnPluginLoaded` the table is there to change. + // + // An erase while TruePVE is not loaded keeps the id in `truepve.json` and removes it when + // TruePVE comes back. Only ids this plugin mapped are ever removed. + + private const string TruePveExclude = "exclude"; + + /// The longest ruleset name a step may send. + private const int TruePveRulesetMax = 64; + + /// Zone ids whose mapping is still to remove, because TruePVE was not loaded. Main thread only. + private HashSet _truePvePending = new HashSet(StringComparer.Ordinal); + + private Plugin TruePvePlugin() + { + return plugins.Find("TruePVE"); + } + + private bool IsTruePve(Plugin plugin) + { + return plugin != null && plugin != this && plugin.Name == "TruePVE"; + } + + /// + /// A zone's truePve: {"exclude": true} or {"ruleset": "name"}, exactly one. + /// Null with true when the command has none; false with a reason when it is malformed. + /// + private static bool ReadTruePve(Dictionary cmd, out string mapping, out string refusal) + { + mapping = null; + refusal = null; + object raw; + + if (!cmd.TryGetValue("truePve", out raw) || raw == null) + return true; + + var o = raw as Newtonsoft.Json.Linq.JObject; + Newtonsoft.Json.Linq.JToken exclude = o != null ? o["exclude"] : null; + Newtonsoft.Json.Linq.JToken ruleset = o != null ? o["ruleset"] : null; + bool hasExclude = exclude != null && exclude.Type != Newtonsoft.Json.Linq.JTokenType.Null; + bool hasRuleset = ruleset != null && ruleset.Type != Newtonsoft.Json.Linq.JTokenType.Null; + + if (o == null || hasExclude == hasRuleset) + { + refusal = "a zone's truePve is {\"exclude\": true} or {\"ruleset\": \"name\"}, exactly one"; + return false; + } + + if (hasExclude) + { + if (exclude.Type != Newtonsoft.Json.Linq.JTokenType.Boolean || !(bool)exclude) + { + refusal = "a zone's truePve exclude is true, or left out"; + return false; + } + + mapping = TruePveExclude; + return true; + } + + string name = ruleset.Type == Newtonsoft.Json.Linq.JTokenType.String ? ((string)ruleset).Trim() : null; + + if (string.IsNullOrEmpty(name) || name.Length > TruePveRulesetMax) + { + refusal = $"a TruePVE ruleset is named by 1 to {TruePveRulesetMax} characters"; + return false; + } + + mapping = name; + return true; + } + + /// + /// Main thread. Removes the TruePVE mapping of a zone this plugin is erasing, if it gave the + /// zone one. Called before the zone goes, on every path that erases one (D362). + /// + private void UnmapTruePve(WorldEntry e) + { + if (e == null || e.Kind != WorldKindZone || string.IsNullOrEmpty(e.TruePve)) + return; + + Plugin tp = TruePvePlugin(); + + if (tp != null) + { + // False is "not in the table", which is what this was for. + tp.Call("RemoveMapping", e.Id); + return; + } + + if (_truePvePending.Add(e.Id)) + { + SaveTruePvePending(); + Puts($"world: TruePVE is not loaded; {e.Id}'s mapping is removed when it is"); + } + } + + /// Main thread. Removes every mapping an erase left while TruePVE was away. + private void FlushTruePvePending(string why) + { + Plugin tp = TruePvePlugin(); + + if (tp == null || _truePvePending.Count == 0) + return; + + int removed = 0; + + foreach (string id in _truePvePending) + { + object gone = tp.Call("RemoveMapping", id); + + if (gone is bool && (bool)gone) + removed++; + } + + int count = _truePvePending.Count; + _truePvePending.Clear(); + SaveTruePvePending(); + + _lastWorldSummary = $"TruePVE after {why}: {removed} of {count} left-over mapping(s) removed"; + Puts($"world: {_lastWorldSummary}"); + } + + private string TruePvePendingPath() + { + return Path.Combine(Interface.Oxide.DataDirectory, Name, "truepve.json"); + } + + private void SaveTruePvePending() + { + try + { + string path = TruePvePendingPath(); + + if (_truePvePending.Count == 0) + { + if (File.Exists(path)) + File.Delete(path); + + return; + } + + Directory.CreateDirectory(Path.GetDirectoryName(path)); + File.WriteAllText(path, JsonConvert.SerializeObject(new List(_truePvePending), Formatting.Indented), Utf8NoBom); + } + catch (Exception ex) + { + Puts($"could not save the TruePVE leftovers: {Flatten(ex.Message)}"); + } + } + + private void LoadTruePvePending() + { + _truePvePending = new HashSet(StringComparer.Ordinal); + + try + { + string path = TruePvePendingPath(); + + if (!File.Exists(path)) + return; + + List ids = JsonConvert.DeserializeObject>(File.ReadAllText(path)); + + foreach (string id in ids ?? new List()) + { + if (!string.IsNullOrEmpty(id)) + _truePvePending.Add(id); + } + } + catch (Exception ex) + { + Puts($"could not read the TruePVE leftovers: {Flatten(ex.Message)}"); + } + } + + // ---- Dynamic PVP's zones (PLAN_REDESIGNS §14.3.3) ---- + // + // HunterZ's Dynamic PVP opens its own PvP zones around a downed helicopter, a Bradley, a + // locked crate, an airdrop, cargo and the rest. They are its own: the bridge never makes, + // maps or erases one, and only says that they are there, for the feed and the map. The + // §14.2 test and the plugin's own source (5.2.0) shape it: + // + // * `OnCreatedDynamicPVP(zoneId, eventName, position, duration)` and + // `OnDeletedDynamicPVP(zoneId, eventName)` are raised for every zone, a reload's and an + // unload's included. Its ids are always numeric, so never one of our `rg-…`. + // * Neither hook carries the zone's size, and a zone is a sphere (`Radius`) or a cube + // (`Size`, cargo's), so both are read from ZoneManager. Cargo's zone is parented to the + // ship, so where a zone is comes from its transform, never from where it was made. + // * `AllDynamicPVPZones()` and `GetEventName(zoneId)` say which zones are live, so the + // set is seeded from Dynamic PVP itself at load: a hotload of the bridge hears no creates. + // * `OnPlayerEnterPVP` / `ExitPVP` are not subscribed: they would say who stands where. + + private const string PvpZoneCreatedKind = "pvp.zone.created"; + private const string PvpZoneDeletedKind = "pvp.zone.deleted"; + + /// One of Dynamic PVP's live zones: its event's name, and how long it was given. + private sealed class PvpZone + { + public string Event; + + /// Seconds, or -1 for "as long as its event" (cargo, a monument's). + public int DurationSec; + } + + /// Dynamic PVP's live zones, by its zone id. Main thread only. + private readonly Dictionary _pvpZones = new Dictionary(StringComparer.Ordinal); + + private Plugin DynamicPvpPlugin() + { + return plugins.Find("DynamicPVP"); + } + + private bool IsDynamicPvp(Plugin plugin) + { + return plugin != null && plugin != this && plugin.Name == "DynamicPVP"; + } + + private void OnCreatedDynamicPVP(string zoneId, string eventName, UnityEngine.Vector3 position, float duration) + { + MarkHook("OnCreatedDynamicPVP"); + + if (string.IsNullOrEmpty(zoneId)) + return; + + var zone = new PvpZone { Event = eventName ?? "", DurationSec = duration > 0f ? (int)Math.Round(duration) : -1 }; + _pvpZones[zoneId] = zone; + + // A boot or a reload of Dynamic PVP opens every monument's zone at once, and that is + // not news; the map has them all the same. + if (!WorldEventsLive()) + return; + + Dictionary frame = Frame(PvpZoneCreatedKind, "event"); + frame["zoneId"] = zoneId; + frame["event"] = zone.Event; + + UnityEngine.Vector3 at; + float radius; + string shape; + + if (!PvpZoneShape(zoneId, out at, out radius, out shape)) + at = position; + + PutPosition(frame, at); + frame["radius"] = Math.Round(radius, 1); + frame["shape"] = shape; + frame["durationSec"] = zone.DurationSec; + EmitFrame(frame); + } + + private void OnDeletedDynamicPVP(string zoneId, string eventName) + { + MarkHook("OnDeletedDynamicPVP"); + + if (string.IsNullOrEmpty(zoneId) || !_pvpZones.Remove(zoneId)) + return; + + // A shutdown deletes every zone, and that is not news. Dynamic PVP's own unload does too, + // and is said: the zones really have closed, and the line is staff's. + if (!WorldEventsLive()) + return; + + Dictionary frame = Frame(PvpZoneDeletedKind, "event"); + frame["zoneId"] = zoneId; + frame["event"] = eventName ?? ""; + EmitFrame(frame); + } + + /// + /// Main thread. Where one of Dynamic PVP's zones is now, and how big: a sphere's radius, + /// or for a cube half its longer side across the ground, so the map can draw a ring. + /// False when ZoneManager does not hold it. + /// + private bool PvpZoneShape(string zoneId, out UnityEngine.Vector3 at, out float radius, out string shape) + { + at = UnityEngine.Vector3.zero; + radius = 0f; + shape = "sphere"; + + Plugin zm = ZoneManagerPlugin(); + var zone = zm != null ? zm.Call("GetZoneByID", zoneId) as UnityEngine.Component : null; + + if (zone == null || zone.transform == null) + return false; + + at = zone.transform.position; + object r = zm.Call("GetZoneRadius", zoneId); + + if (r is float && (float)r > 0f) + { + radius = (float)r; + return true; + } + + object s = zm.Call("GetZoneSize", zoneId); + + if (s is UnityEngine.Vector3) + { + var size = (UnityEngine.Vector3)s; + radius = Math.Max(size.x, size.z) / 2f; + shape = "cube"; + } + + return true; + } + + /// Main thread. The live zones from Dynamic PVP itself, replacing what the hooks kept. + private void SeedPvpZones(string why) + { + _pvpZones.Clear(); + Plugin dpvp = DynamicPvpPlugin(); + + if (dpvp == null) + return; + + var ids = dpvp.Call("AllDynamicPVPZones") as string[]; + + if (ids == null) + return; + + foreach (string id in ids) + { + if (string.IsNullOrEmpty(id)) + continue; + + // How long it was given is not asked for; the map does not need it. + _pvpZones[id] = new PvpZone { Event = dpvp.Call("GetEventName", id) as string ?? "", DurationSec = -1 }; + } + + if (_pvpZones.Count > 0) + Puts($"map: {_pvpZones.Count} Dynamic PVP zone(s) after {why}"); + } + + /// Main thread. The map's pvp layer: every live zone ZoneManager still holds, where it is now. + private List PvpZoneRows() + { + var rows = new List(); + List gone = null; + + foreach (KeyValuePair z in _pvpZones) + { + UnityEngine.Vector3 at; + float radius; + string shape; + + if (!PvpZoneShape(z.Key, out at, out radius, out shape)) + { + // Erased without its hook (ZoneManager reloaded); Dynamic PVP's own state is + // its business, and the map shows only what is there. + if (gone == null) + gone = new List(); + + gone.Add(z.Key); + continue; + } + + Dictionary row = At(at); + row["zoneId"] = z.Key; + row["event"] = z.Value.Event; + row["radius"] = Math.Round(radius, 1); + row["shape"] = shape; + rows.Add(row); + } + + if (gone != null) + { + foreach (string id in gone) + _pvpZones.Remove(id); + } + + return rows; + } + + // ---- Dangerous Treasures (PLAN_REDESIGNS §14.3.4, D365) ---- + // + // nivex's Dangerous Treasures opens a guarded, burning treasure chest somewhere on the map, + // with its own NPCs, fire and marker. The bridge says when one opens, is won and ends, for + // the feed, the map and the tally (Treasure Hunter), and opens one for a run's step through + // DT's own console command. DT's source (3.0.0) shapes it: + // + // * `OnDangerousEventStarted(Vector3)` is raised INSIDE the call that opens the chest (the + // chest's radius setter runs its `Awaken`), so a start our `dtevent` caused is heard + // before `ConsoleSystem.Run` returns. That is the key's join, and it is exact: nothing + // else can open a chest between the two on the main thread. + // * `OnDangerousEventWon(looter, invaders)` when the chest is emptied, then + // `OnDangerousEventEnded(Vector3)` as it goes. The win does not say which chest, so it is + // placed at the running chest nearest the looter. DT's unload ends every chest, each + // with its hook. + // * A start that opened nothing is told only by DT's reply, which DT's lang file words. So + // "it started" is the hook, never the text; the reply is compared with DT's own "Manual + // Event Failed" message, read from its lang, to know a start that found no spot. DT + // builds its spawn grid for about ten minutes after it loads and fails every request + // meanwhile (§14.2.1), so inside fifteen minutes of DT's load that is `dt-not-ready` + // (D365), and after it `dt-no-spot`. + // * DT's console command is a config value; this plugin runs its default, `dtevent`. An + // operator who renamed it gets the refusal with whatever the console answered. + + private const string DtStartedKind = "dt.started"; + private const string DtWonKind = "dt.won"; + private const string DtEndedKind = "dt.ended"; + + /// DT's console command, as its config ships it. + private const string DtCommand = "dtevent"; + + /// How long after DT loads a start that found no spot is "not ready" rather than "no spot" (D365). + private const float DtWarmUpSeconds = 15f * 60f; + + /// How far from a running chest a winner may stand and still be placed at it. + private const float DtWinReach = 100f; + + /// The most invaders a win names; the rest are counted in invaderCount. + private const int DtInvadersMax = 32; + + /// Every DT chest is a large wooden box (`box.wooden.large`), for the seed at load. + private const uint DtChestPrefabId = 2206646561u; + + /// How long a start's key is remembered: seven days, as a payment's is. + private const long DtStartMemoryMs = 7L * 24L * 60L * 60L * 1000L; + + /// One running chest: where it is, and the step's key when a step opened it. + private sealed class Treasure + { + public UnityEngine.Vector3 At; + public string Key; + } + + /// The running chests, oldest first. Main thread only. + private readonly List _treasures = new List(); + + /// One start a step asked for, kept for its key as a world call is. Persisted as it is. + private sealed class DtStart + { + public string Key; + public int Level; + public float X; + public float Z; + public long AtMs; + } + + private Dictionary _dtStarts = new Dictionary(StringComparer.Ordinal); + + /// The key of the `dtevent` this plugin is running now, for the start hook. Null otherwise. + private string _dtStarting; + + /// The chest the start hook heard while our `dtevent` ran. + private Treasure _dtOpened; + + /// Time.realtimeSinceStartup when this plugin first saw DT loaded; negative while it is not. + private float _dtSeenAt = -1f; + + private Plugin DangerousTreasuresPlugin() + { + return plugins.Find("DangerousTreasures"); + } + + private bool IsDangerousTreasures(Plugin plugin) + { + return plugin != null && plugin != this && plugin.Name == "DangerousTreasures"; + } + + /// + /// Main thread. DT's load as this plugin saw it: DT's own, or this plugin's when DT was + /// already there, which can only make the warm-up window start late, never early. + /// + private void NoteDtSeen(bool loaded) + { + _dtSeenAt = loaded ? UnityEngine.Time.realtimeSinceStartup : -1f; + } + + private bool DtWarmingUp() + { + return _dtSeenAt >= 0f && UnityEngine.Time.realtimeSinceStartup - _dtSeenAt < DtWarmUpSeconds; + } + + private void OnDangerousEventStarted(UnityEngine.Vector3 at) + { + MarkHook("OnDangerousEventStarted"); + + var treasure = new Treasure { At = at, Key = _dtStarting }; + _treasures.Add(treasure); + + // One `dtevent ` opens one chest, so the key is spent on the first. + if (_dtStarting != null) + { + _dtOpened = treasure; + _dtStarting = null; + } + + if (!WorldEventsLive()) + return; + + Dictionary frame = Frame(DtStartedKind, "event"); + PutPosition(frame, at); + + if (treasure.Key != null) + frame["key"] = treasure.Key; + + EmitFrame(frame); + } + + private void OnDangerousEventWon(BasePlayer looter, List invaders) + { + MarkHook("OnDangerousEventWon"); + + if (looter == null || looter.IsNpc) + return; + + TallyFor(looter).TreasuresWon++; + + if (!WorldEventsLive()) + return; + + Treasure chest = NearestTreasure(looter.transform.position, DtWinReach); + Dictionary frame = Frame(DtWonKind, "event"); + frame["steamId"] = SteamIdOf(looter); + frame["name"] = looter.displayName; + + var named = new List(); + int count = 0; + + if (invaders != null) + { + foreach (ulong id in invaders) + { + count++; + + if (named.Count < DtInvadersMax) + named.Add(id.ToString(System.Globalization.CultureInfo.InvariantCulture)); + } + } + + frame["invaders"] = named; + frame["invaderCount"] = count; + PutPosition(frame, chest != null ? chest.At : looter.transform.position); + EmitFrame(frame); + } + + private void OnDangerousEventEnded(UnityEngine.Vector3 at) + { + MarkHook("OnDangerousEventEnded"); + + // The chest's own position, the one its start was raised with. + Treasure chest = NearestTreasure(at, 1f); + + if (chest != null) + _treasures.Remove(chest); + + if (!WorldEventsLive()) + return; + + Dictionary frame = Frame(DtEndedKind, "event"); + PutPosition(frame, at); + EmitFrame(frame); + } + + private Treasure NearestTreasure(UnityEngine.Vector3 at, float reach) + { + Treasure best = null; + float bestDistance = reach; + + foreach (Treasure t in _treasures) + { + float d = Distance2D(t.At, at); + + if (d <= bestDistance) + { + bestDistance = d; + best = t; + } + } + + return best; + } + + /// + /// Main thread. The running chests, found in the world, replacing what the hooks kept: a + /// hotload hears no starts. DT has no API that lists them, so a chest is a large wooden + /// box carrying DT's own TreasureChest component. + /// + private void SeedTreasures(string why) + { + _treasures.Clear(); + + if (DangerousTreasuresPlugin() == null) + return; + + foreach (BaseNetworkable n in BaseNetworkable.serverEntities) + { + var box = n as StorageContainer; + + if (box == null || box.IsDestroyed || box.prefabID != DtChestPrefabId || box.GetComponent("TreasureChest") == null) + continue; + + _treasures.Add(new Treasure { At = box.transform.position }); + } + + if (_treasures.Count > 0) + Puts($"map: {_treasures.Count} Dangerous Treasures chest(s) after {why}"); + } + + /// The map's treasure rows, for its world layer. + private void AddTreasureRows(List world) + { + foreach (Treasure t in _treasures) + { + Dictionary row = At(t.At); + row["kind"] = "treasure"; + world.Add(row); + } + } + + private string DtStartsPath() + { + return Path.Combine(Interface.Oxide.DataDirectory, Name, "dt-starts.json"); + } + + private void SaveDtStarts() + { + WriteDataFile(DtStartsPath(), new List(_dtStarts.Values), "Dangerous Treasures starts"); + } + + private void LoadDtStarts() + { + _dtStarts = new Dictionary(StringComparer.Ordinal); + List starts = ReadDataFile(DtStartsPath(), "Dangerous Treasures starts"); + + if (starts != null) + foreach (DtStart s in starts) + if (s != null && !string.IsNullOrEmpty(s.Key)) + _dtStarts[s.Key] = s; + + ForgetOldDtStarts(); + } + + private void ForgetOldDtStarts() + { + long now = NowMs(); + var stale = new List(); + + foreach (KeyValuePair kv in _dtStarts) + if (now >= kv.Value.AtMs + DtStartMemoryMs) + stale.Add(kv.Key); + + foreach (string key in stale) + _dtStarts.Remove(key); + + if (stale.Count > 0) + SaveDtStarts(); + } + + /// + /// Main thread. Open one Dangerous Treasures event for a run's step: { key, level }, + /// 0 Easy, 1 Medium, 2 Hard; DT picks the spot. A key already answered is answered again + /// with its first start and opens nothing, so a retry never opens a second chest. + /// + private void HandleDtStart(Dictionary cmd, string reqId) + { + if (!WorldWritesAllowed(reqId)) + return; + + string key = StringField(cmd, "key"); + + if (string.IsNullOrEmpty(key) || key.Length > PaymentKeyMax) + { + EmitDtError(reqId, "malformed", "a start is made under a key of at most 128 characters"); + return; + } + + ForgetOldDtStarts(); + + DtStart seen; + + if (_dtStarts.TryGetValue(key, out seen)) + { + EmitDtOpened(reqId, seen, true); + return; + } + + // Not IntField, which reads anything unparseable as 0, and 0 is Easy. + string rawLevel = StringField(cmd, "level"); + int level; + + if (rawLevel == null || !int.TryParse(rawLevel, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out level) || level < 0 || level > 2) + { + EmitDtError(reqId, "bad-level", $"a Dangerous Treasures event is level 0 (Easy), 1 (Medium) or 2 (Hard), not '{rawLevel}'"); + return; + } + + Plugin dt = DangerousTreasuresPlugin(); + + if (dt == null) + { + EmitDtError(reqId, "dt-missing", "Dangerous Treasures is not loaded on this server"); + return; + } + + if (!_consolePlugins) + { + EmitDtError(reqId, "console-unavailable", + "this framework does not run another plugin's console command from code, so Dangerous Treasures cannot be started here (D363, Rust-Plugins#34)"); + return; + } + + string reply = null; + _dtOpened = null; + _dtStarting = key; + + try + { + reply = ConsoleSystem.Run(ConsoleSystem.Option.Server.Quiet(), DtCommand, level); + } + catch (Exception ex) + { + reply = ex.Message; + } + finally + { + _dtStarting = null; + } + + Treasure opened = _dtOpened; + _dtOpened = null; + + if (opened == null) + { + RefuseDtStart(reqId, dt, reply); + return; + } + + var start = new DtStart { Key = key, Level = level, X = opened.At.x, Z = opened.At.z, AtMs = NowMs() }; + _dtStarts[key] = start; + SaveDtStarts(); + + _lastWorldSummary = $"opened a level {level} Dangerous Treasures event at {Grid(opened.At) ?? "?"} for key {key}"; + Puts($"world: {_lastWorldSummary}"); + EmitDtOpened(reqId, start, false); + } + + /// Why our dtevent opened nothing: no spot (yet), or DT's own refusal in its words. + private void RefuseDtStart(string reqId, Plugin dt, string reply) + { + string said = string.IsNullOrEmpty(reply) ? null : Flatten(reply).Trim(); + string failed = null; + + try + { + failed = lang.GetMessage("Manual Event Failed", dt); + } + catch (Exception) + { + // A DT without the message: only an empty reply then reads as "no spot". + } + + bool noSpot = said == null || (!string.IsNullOrEmpty(failed) && string.Equals(said, Flatten(failed).Trim(), StringComparison.Ordinal)); + + if (!noSpot) + EmitDtError(reqId, "refused", $"Dangerous Treasures did not start: {said}"); + else if (DtWarmingUp()) + EmitDtError(reqId, "dt-not-ready", "Dangerous Treasures is not ready yet: it spends about ten minutes after it loads finding where events may go"); + else + EmitDtError(reqId, "dt-no-spot", "Dangerous Treasures found no valid spot for an event"); + } + + private void EmitDtOpened(string reqId, DtStart start, bool repeat) + { + Dictionary frame = Frame("dt.opened", "reply"); + frame["key"] = start.Key; + frame["level"] = start.Level; + frame["repeat"] = repeat; + PutPosition(frame, new UnityEngine.Vector3(start.X, 0f, start.Z)); + + if (reqId != null) + frame["reqId"] = reqId; + + EmitFrame(frame); + } + + private void EmitDtError(string reqId, string reason, string message) + { + Dictionary frame = Frame("dt.error", "reply"); + frame["reason"] = reason; + frame["message"] = message; + + if (reqId != null) + frame["reqId"] = reqId; + + _lastWorldSummary = $"Dangerous Treasures refused ({reason}): {message}"; + EmitFrame(frame); + } + + // ---- Guarded Crate, with NpcSpawn (PLAN_REDESIGNS §14.3.5, D361, D364, D373) ---- + // + // Bazz3l's Guarded Crate drops a locked crate from a cargo plane and rings it with + // NpcSpawn's scientists; whoever kills the last guard has cleared it. The bridge says when + // one starts, is cleared and times out, for the feed, the map and the tally (Warden), and + // places one for a run's step through Guarded Crate's own console command. Its source + // (2.0.17) shapes it: + // + // * `OnGuardedCrateEventStart(Vector3)` is raised INSIDE the call that creates the + // event, so a start our `gcrate position` caused is heard before `ConsoleSystem.Run` + // returns: the key's join is exact, as Dangerous Treasures' is (§14.3.18). + // * `OnGuardedCrateEventEnded(player, crate)` when the last guard dies, crediting that + // guard's killer; the player is null when the guards went some other way (NpcSpawn + // reloaded), which is "no winner". It does not say which event, so the event is + // found by reading Guarded Crate's own list (below), else by the crate's position. + // `OnGuardedCrateEventFailed(Vector3)` is the timeout. + // * **Its unload and `gcrate stop all` end every event and raise no hook.** So the + // running set is cleared when it unloads, and checked against its own list before + // the map is drawn; a hotload seeds from that list too. + // * **`gcrate position` does not check NpcSpawn** (only `start` does), so without it a + // placed crate would land with no guards and be free to hack. The bridge refuses that + // itself (D364). + // * The events are rows of its DATA file, not its config, and its console command is a + // config value. Both are read from their files, as Economics' config is. + + private const string GcStartedKind = "gc.started"; + private const string GcEndedKind = "gc.ended"; + private const string GcFailedKind = "gc.failed"; + + /// Guarded Crate's console command when its config does not say. + private const string GcDefaultCommand = "gcrate"; + + /// How far from a running event its crate or winner may be and still be placed at it. + private const float GcEndReach = 100f; + + /// How long a placement's key is remembered: seven days, as a payment's is. + private const long GcPlaceMemoryMs = 7L * 24L * 60L * 60L * 1000L; + + /// One running guarded crate: where, and the step's key when a step placed it. + private sealed class GuardedCrateEvent + { + public UnityEngine.Vector3 At; + public string Key; + } + + /// The running guarded crates, oldest first. Main thread only. + private readonly List _guardedCrates = new List(); + + /// One placement a step asked for, kept for its key. Persisted as a DT start is. + private sealed class GcPlacement + { + public string Key; + public string Event; + public float X; + public float Z; + public long AtMs; + } + + private Dictionary _gcPlacements = new Dictionary(StringComparer.Ordinal); + + /// The key of the `gcrate position` this plugin is running now, for the start hook. Null otherwise. + private string _gcStarting; + + /// The event the start hook heard while our `gcrate position` ran. + private GuardedCrateEvent _gcOpened; + + /// Guarded Crate's events (its data file) and console command (its config), each read again only when its file changes. + private List _gcEventNames; + private DateTime _gcEventNamesAt; + private string _gcCommand; + private DateTime _gcCommandAt; + + /// Whether Guarded Crate's own list of events could be read the last time it was asked. + private bool _gcListReadable; + private bool _gcListSaid; + + private Plugin GuardedCratePlugin() + { + return plugins.Find("GuardedCrate"); + } + + private Plugin NpcSpawnPlugin() + { + return plugins.Find("NpcSpawn"); + } + + private bool IsGuardedCrate(Plugin plugin) + { + return plugin != null && plugin != this && (plugin.Name == "GuardedCrate" || plugin.Name == "NpcSpawn"); + } + + private void OnGuardedCrateEventStart(UnityEngine.Vector3 at) + { + MarkHook("OnGuardedCrateEventStart"); + + var crate = new GuardedCrateEvent { At = at, Key = _gcStarting }; + _guardedCrates.Add(crate); + + // One `gcrate position` creates one event, so the key is spent on the first. + if (_gcStarting != null) + { + _gcOpened = crate; + _gcStarting = null; + } + + if (!WorldEventsLive()) + return; + + Dictionary frame = Frame(GcStartedKind, "event"); + PutPosition(frame, at); + + if (crate.Key != null) + frame["key"] = crate.Key; + + EmitFrame(frame); + } + + private void OnGuardedCrateEventEnded(BasePlayer player, HackableLockedCrate crate) + { + MarkHook("OnGuardedCrateEventEnded"); + + // A guard killed by another NPC, or by nobody, clears the crate for nobody. + bool winner = player != null && !player.IsNpc; + + if (winner) + TallyFor(player).GuardedWon++; + + GuardedCrateEvent ended = EndedGuardedCrate(player, crate); + + if (ended != null) + _guardedCrates.Remove(ended); + + if (!WorldEventsLive()) + return; + + Dictionary frame = Frame(GcEndedKind, "event"); + + if (winner) + { + frame["steamId"] = SteamIdOf(player); + frame["name"] = player.displayName; + } + + if (ended != null) + PutPosition(frame, ended.At); + else if (crate != null && !crate.IsDestroyed) + PutPosition(frame, crate.transform.position); + + EmitFrame(frame); + } + + private void OnGuardedCrateEventFailed(UnityEngine.Vector3 at) + { + MarkHook("OnGuardedCrateEventFailed"); + + GuardedCrateEvent failed = NearestGuardedCrate(at, 1f); + + if (failed != null) + _guardedCrates.Remove(failed); + + if (!WorldEventsLive()) + return; + + Dictionary frame = Frame(GcFailedKind, "event"); + PutPosition(frame, at); + EmitFrame(frame); + } + + /// + /// Main thread, inside the ended hook. Which running event ended: the one Guarded + /// Crate's own list has just marked complete; else the one nearest its crate, else its + /// winner; else the only one running. Null when none can be told. + /// + private GuardedCrateEvent EndedGuardedCrate(BasePlayer player, HackableLockedCrate crate) + { + List> listed = GuardedCrateList(); + + if (listed != null) + foreach (KeyValuePair e in listed) + if (e.Value == "Complete") + { + GuardedCrateEvent exact = NearestGuardedCrate(e.Key, 1f); + + if (exact != null) + return exact; + } + + GuardedCrateEvent near = null; + + if (crate != null && !crate.IsDestroyed) + near = NearestGuardedCrate(crate.transform.position, GcEndReach); + + if (near == null && player != null) + near = NearestGuardedCrate(player.transform.position, GcEndReach); + + if (near == null && _guardedCrates.Count == 1) + near = _guardedCrates[0]; + + return near; + } + + private GuardedCrateEvent NearestGuardedCrate(UnityEngine.Vector3 at, float reach) + { + GuardedCrateEvent best = null; + float bestDistance = reach; + + foreach (GuardedCrateEvent g in _guardedCrates) + { + float d = Distance2D(g.At, at); + + if (d <= bestDistance) + { + bestDistance = d; + best = g; + } + } + + return best; + } + + /// + /// Main thread. Guarded Crate's own running events, as (position, state) pairs: its + /// private EventManager.Instances, read by reflection because it has no API + /// that lists them. Null when it is not loaded or its shape has changed, which is said + /// once; the hooks alone then keep the set. + /// + private List> GuardedCrateList() + { + Plugin gc = GuardedCratePlugin(); + + if (gc == null) + return null; + + try + { + const System.Reflection.BindingFlags Static = + System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static; + const System.Reflection.BindingFlags Instance = + System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance; + + Type manager = gc.GetType().GetNestedType("EventManager", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic); + System.Reflection.FieldInfo field = manager != null ? manager.GetField("Instances", Static) : null; + var list = field != null ? field.GetValue(null) as System.Collections.IList : null; + + if (list == null) + throw new InvalidOperationException("no EventManager.Instances"); + + var rows = new List>(); + + foreach (object item in list) + { + var component = item as UnityEngine.Component; + + // Unity's own null: a destroyed instance still in the list. + if (component == null) + continue; + + System.Reflection.FieldInfo state = item.GetType().GetField("currentState", Instance); + object value = state != null ? state.GetValue(item) : null; + rows.Add(new KeyValuePair(component.transform.position, value != null ? value.ToString() : null)); + } + + _gcListReadable = true; + return rows; + } + catch (Exception ex) + { + _gcListReadable = false; + + if (!_gcListSaid) + { + _gcListSaid = true; + Puts($"could not read Guarded Crate's running events ({Flatten(ex.Message)}); its hooks alone keep the map's guarded crates"); + } + + return null; + } + } + + /// + /// Main thread. The running set, replaced by what Guarded Crate's own list says: a + /// hotload hears no starts, and its unload and `gcrate stop all` end events without a + /// hook. A step's key is kept for an event still running. Nothing changes when the + /// list cannot be read. + /// + private void SeedGuardedCrates(string why) + { + if (GuardedCratePlugin() == null) + { + _guardedCrates.Clear(); + return; + } + + List> listed = GuardedCrateList(); + + if (listed == null) + return; + + var running = new List(); + + foreach (KeyValuePair e in listed) + { + if (e.Value != null && e.Value != "Running") + continue; + + GuardedCrateEvent known = NearestGuardedCrate(e.Key, 1f); + running.Add(new GuardedCrateEvent { At = e.Key, Key = known != null ? known.Key : null }); + } + + int before = _guardedCrates.Count; + _guardedCrates.Clear(); + _guardedCrates.AddRange(running); + + if (why != null && (running.Count > 0 || before > 0)) + Puts($"map: {running.Count} guarded crate(s) after {why}"); + } + + /// The map's guarded rows, for its world layer. + private void AddGuardedRows(List world) + { + // `gcrate stop all` ends events without a hook; what its own list no longer has goes. + SeedGuardedCrates(null); + + foreach (GuardedCrateEvent g in _guardedCrates) + { + Dictionary row = At(g.At); + row["kind"] = "guarded"; + world.Add(row); + } + } + + /// + /// Hello's integrations.guardedCrate: loaded and version, NpcSpawn's beside it + /// (D364), and events, the event names in Guarded Crate's data file, absent when + /// the file cannot be read. + /// + private Dictionary GuardedCrateBody() + { + Plugin gc = GuardedCratePlugin(); + Dictionary body = IntegrationBody(gc); + body["npcSpawn"] = IntegrationBody(NpcSpawnPlugin()); + + if (gc == null) + return body; + + List events = GuardedCrateEventNames(); + + if (events != null) + body["events"] = events; + + return body; + } + + /// + /// The event names in Guarded Crate's data file (CrateEventEntries, each row's + /// event display name), the key's stray bracket and all), in its order. Null when + /// the file is missing or will not parse. Read again only when the file changes: every + /// status frame asks. The directory comes from the framework (R19). + /// + private List GuardedCrateEventNames() + { + string path = Path.Combine(Interface.Oxide.DataDirectory, "GuardedCrate.json"); + DateTime at; + + try + { + if (!File.Exists(path)) + return null; + + at = File.GetLastWriteTimeUtc(path); + } + catch (Exception) + { + return null; + } + + if (at == _gcEventNamesAt) + return _gcEventNames; + + List names = null; + + try + { + var data = Newtonsoft.Json.Linq.JObject.Parse(File.ReadAllText(path)); + var rows = data["CrateEventEntries"] as Newtonsoft.Json.Linq.JArray; + + if (rows != null) + { + names = new List(); + + foreach (Newtonsoft.Json.Linq.JToken row in rows) + { + var entry = row as Newtonsoft.Json.Linq.JObject; + Newtonsoft.Json.Linq.JToken name = entry != null ? entry["event display name)"] : null; + + if (name != null && name.Type == Newtonsoft.Json.Linq.JTokenType.String && !string.IsNullOrWhiteSpace((string)name)) + names.Add((string)name); + } + } + } + catch (Exception ex) + { + Puts($"could not read Guarded Crate's data file: {Flatten(ex.Message)}"); + } + + _gcEventNames = names; + _gcEventNamesAt = at; + return names; + } + + /// + /// Guarded Crate's console command from its config (command name - (default: + /// gcrate)), or gcrate when the file does not say. Empty when the operator + /// blanked it, which registers no command at all. + /// + private string GuardedCrateCommand() + { + string path = Path.Combine(Interface.Oxide.ConfigDirectory, "GuardedCrate.json"); + DateTime at; + + try + { + if (!File.Exists(path)) + return GcDefaultCommand; + + at = File.GetLastWriteTimeUtc(path); + } + catch (Exception) + { + return GcDefaultCommand; + } + + if (_gcCommand != null && at == _gcCommandAt) + return _gcCommand; + + string command = GcDefaultCommand; + + try + { + var config = Newtonsoft.Json.Linq.JObject.Parse(File.ReadAllText(path)); + Newtonsoft.Json.Linq.JToken name = config["command name - (default: gcrate)"]; + + if (name != null && name.Type == Newtonsoft.Json.Linq.JTokenType.String) + command = ((string)name).Trim(); + } + catch (Exception ex) + { + Puts($"could not read Guarded Crate's config: {Flatten(ex.Message)}"); + } + + _gcCommand = command; + _gcCommandAt = at; + return command; + } + + private string GcPlacementsPath() + { + return Path.Combine(Interface.Oxide.DataDirectory, Name, "gc-places.json"); + } + + private void SaveGcPlacements() + { + WriteDataFile(GcPlacementsPath(), new List(_gcPlacements.Values), "Guarded Crate placements"); + } + + private void LoadGcPlacements() + { + _gcPlacements = new Dictionary(StringComparer.Ordinal); + List placed = ReadDataFile(GcPlacementsPath(), "Guarded Crate placements"); + + if (placed != null) + foreach (GcPlacement p in placed) + if (p != null && !string.IsNullOrEmpty(p.Key)) + _gcPlacements[p.Key] = p; + + ForgetOldGcPlacements(); + } + + private void ForgetOldGcPlacements() + { + long now = NowMs(); + var stale = new List(); + + foreach (KeyValuePair kv in _gcPlacements) + if (now >= kv.Value.AtMs + GcPlaceMemoryMs) + stale.Add(kv.Key); + + foreach (string key in stale) + _gcPlacements.Remove(key); + + if (stale.Count > 0) + SaveGcPlacements(); + } + + /// + /// Main thread. Place one guarded crate for a run's step: { key, event } and a + /// location as a world call takes one (a monument and offsets, or x and z). A key + /// already answered is answered again with its first placement and places nothing. + /// + private void HandleGcPlace(Dictionary cmd, string reqId) + { + if (!WorldWritesAllowed(reqId)) + return; + + string key = StringField(cmd, "key"); + + if (string.IsNullOrEmpty(key) || key.Length > PaymentKeyMax) + { + EmitGcError(reqId, "malformed", "a placement is made under a key of at most 128 characters"); + return; + } + + ForgetOldGcPlacements(); + + GcPlacement seen; + + if (_gcPlacements.TryGetValue(key, out seen)) + { + EmitGcPlaced(reqId, seen, true); + return; + } + + string wanted = (StringField(cmd, "event") ?? string.Empty).Trim(); + + if (wanted.Length == 0) + { + EmitGcError(reqId, "bad-event", "a guarded crate is placed as one of Guarded Crate's events, and none was named"); + return; + } + + Plugin gc = GuardedCratePlugin(); + + if (gc == null) + { + EmitGcError(reqId, "gc-missing", "Guarded Crate is not loaded on this server"); + return; + } + + // Guarded Crate's `position` does not check this itself: the crate would land unguarded. + if (NpcSpawnPlugin() == null) + { + EmitGcError(reqId, "npcspawn-missing", "Guarded Crate is loaded but NpcSpawn is missing: its events cannot start"); + return; + } + + List events = GuardedCrateEventNames(); + string name = null; + + if (events != null) + { + // As Guarded Crate finds an event: by name, ignoring case. + foreach (string e in events) + if (string.Equals(e, wanted, StringComparison.OrdinalIgnoreCase)) + name = e; + + if (name == null) + { + EmitGcError(reqId, "bad-event", $"'{wanted}' is not one of Guarded Crate's events here ({string.Join(", ", events.ToArray())})"); + return; + } + } + else + name = wanted; + + if (!_consolePlugins) + { + EmitGcError(reqId, "console-unavailable", + "this framework does not run another plugin's console command from code, so a guarded crate cannot be placed here (D363, Rust-Plugins#34)"); + return; + } + + UnityEngine.Vector3 at; + string reason, refusal; + + if (!ResolveWorldPoint(cmd, out at, out reason, out refusal)) + { + EmitGcError(reqId, reason, refusal); + return; + } + + string command = GuardedCrateCommand(); + + if (string.IsNullOrEmpty(command)) + { + EmitGcError(reqId, "refused", "Guarded Crate's console command is switched off in its config"); + return; + } + + // Invariant, so a server whose locale writes 1,5 still sends 1.5. + string point = string.Format(System.Globalization.CultureInfo.InvariantCulture, "{0:0.##},{1:0.##},{2:0.##}", at.x, at.y, at.z); + string reply = null; + _gcOpened = null; + _gcStarting = key; + + try + { + reply = ConsoleSystem.Run(ConsoleSystem.Option.Server.Quiet(), command, "position", name, point); + } + catch (Exception ex) + { + reply = ex.Message; + } + finally + { + _gcStarting = null; + } + + GuardedCrateEvent opened = _gcOpened; + _gcOpened = null; + + if (opened == null) + { + RefuseGcPlace(reqId, gc, reply); + return; + } + + var placement = new GcPlacement { Key = key, Event = name, X = opened.At.x, Z = opened.At.z, AtMs = NowMs() }; + _gcPlacements[key] = placement; + SaveGcPlacements(); + + _lastWorldSummary = $"placed a {name} guarded crate at {Grid(opened.At) ?? "?"} for key {key}"; + Puts($"world: {_lastWorldSummary}"); + EmitGcPlaced(reqId, placement, false); + } + + /// Why our gcrate position placed nothing, told by Guarded Crate's own messages, read from its lang. + private void RefuseGcPlace(string reqId, Plugin gc, string reply) + { + string said = string.IsNullOrEmpty(reply) ? null : Flatten(reply).Trim(); + + if (said != null && said == GcMessage(gc, "EVENT_INTERSECTING")) + EmitGcError(reqId, "gc-too-close", "another guarded crate is running within 80 m of that spot"); + else if (said != null && said == GcMessage(gc, "EVENT_NOT_FOUND")) + EmitGcError(reqId, "bad-event", "Guarded Crate has no event of that name"); + else if (said != null && said == GcMessage(gc, "EVENT_POSITION_INVALID")) + EmitGcError(reqId, "bad-position", "Guarded Crate refused that position"); + else + EmitGcError(reqId, "refused", $"Guarded Crate placed nothing{(said != null ? $": {said}" : string.Empty)}"); + } + + /// One of Guarded Crate's messages, flattened as a reply is, or null. + private string GcMessage(Plugin gc, string key) + { + try + { + string text = lang.GetMessage(key, gc); + return string.IsNullOrEmpty(text) ? null : Flatten(text).Trim(); + } + catch (Exception) + { + return null; + } + } + + private void EmitGcPlaced(string reqId, GcPlacement placement, bool repeat) + { + Dictionary frame = Frame("gc.placed", "reply"); + frame["key"] = placement.Key; + frame["event"] = placement.Event; + frame["repeat"] = repeat; + PutPosition(frame, new UnityEngine.Vector3(placement.X, 0f, placement.Z)); + + if (reqId != null) + frame["reqId"] = reqId; + + EmitFrame(frame); + } + + private void EmitGcError(string reqId, string reason, string message) + { + Dictionary frame = Frame("gc.error", "reply"); + frame["reason"] = reason; + frame["message"] = message; + + if (reqId != null) + frame["reqId"] = reqId; + + _lastWorldSummary = $"Guarded Crate refused ({reason}): {message}"; + EmitFrame(frame); + } + + // ---- RaidableBases (PLAN_REDESIGNS §11.4, D202-D208, D386-D390) ---- + // + // nivex's RaidableBases pastes a stocked, guarded CopyPaste building for players to raid. The + // bridge reports its bases (the feed, the map's raids layer, the tally's Warlord count) from + // the plugin's own lifecycle hooks, which both editions raise alike, and spawns one for a + // run's step. PROTOCOL.md §20.12 is the wire; what the spikes found is §11.4.1–§11.4.4. + // + // * **Names are the plugin's (D386).** Difficulty names and levels come from its own + // `GetAllDifficulties` (and `GetRaidableModes` on the paid edition), profile and base + // names from the raids helper. None is written here. + // * **A run's base needs the raids helper to be the run's** (D204–D207, D387). The helper + // tags it at birth, applies the step's settings, places it at a spot, and despawns it + // alone. The base is then a world registry entry like a zone, under its own id, and + // `world.revert`, `world.owned` and `world.expired` treat it as they treat the rest. + // * **Without the helper a step still spawns one where the plugin can be asked**: the paid + // edition's own `SpawnRandomBase` call, at a difficulty, or the free edition's console + // command, which says nothing back. Neither base is the run's; it despawns on its own + // timer, and the reply says so. + // * **An unload of RaidableBases raises `Completed` for every live base, with no owner and + // no raiders (§11.4.2).** Such a completion is neither counted nor told. + + private const string WorldKindRaid = "raidbase"; + + /// A run's raid base id is this bridge's own, so a zone's rg- can never be mistaken for one. + private const string RaidIdPrefix = "raid-"; + + private const string RaidStartedKind = "raid.started"; + private const string RaidCompletedKind = "raid.completed"; + private const string RaidEndedKind = "raid.ended"; + private const string RaidPurchasedKind = "raid.purchased"; + private const string RaidLockedKind = "raid.locked"; + + /// The free edition's console command, as its config ships it. + private const string RbConsoleCommand = "rbevent"; + + /// How far a hook's position may be from a base this bridge knows and still be that base. + private const float RaidReach = 2f; + + /// The most raiders a completion names; the rest are counted in raiderCount. + private const int RaidRaidersMax = 32; + + /// A run's base not open this long after it was asked for never will be: its request is dropped and it expires. + private const long RaidPendingMs = 15L * 60L * 1000L; + + /// How long a spawn's key is remembered, as a payment's is. + private const long RaidStartMemoryMs = 7L * 24L * 60L * 60L * 1000L; + + /// One live base, as the hooks told it. Main thread only. + private sealed class LiveRaid + { + public UnityEngine.Vector3 At; + public int Level; + public string Difficulty; + public bool Pvp; + public string Base; + public long DespawnAtMs; + + /// The run's id for it (a world registry id), or null for a base no run made. + public string Id; + + /// + /// The run and step key behind , kept here too: a teardown takes the + /// base off the record before its despawn raises Ended (W12). + /// + public string RunId; + public string Key; + } + + private readonly List _raids = new List(); + + /// One spawn a step asked for, kept for its key so a retry spawns nothing. Persisted as it is. + private sealed class RaidStart + { + public string Key; + public string Id; + public string Route; + public string Difficulty; + public int Level; + public string Profile; + public string Base; + public float X; + public float Z; + public bool Spot; + public long AtMs; + } + + private Dictionary _raidStarts = new Dictionary(StringComparer.Ordinal); + + private int _raidSerial; + + private Timer _raidTimer; + + private Plugin RaidableBasesPlugin() + { + return plugins.Find("RaidableBases"); + } + + private Plugin RaidsHelperPlugin() + { + return plugins.Find("RunicGatewayRaids"); + } + + /// RaidableBases, its helper and CopyPaste are all in hello, so a load or unload of any says it again. + private bool IsRaidPlugin(Plugin plugin) + { + if (plugin == null || plugin == this) + return false; + + string name = plugin.Name; + return name == "RaidableBases" || name == "RunicGatewayRaids" || name == "CopyPaste"; + } + + /// missing, or the helper's own state: patched, unsupported or no-raidablebases. + private string RaidsHelperState() + { + Plugin helper = RaidsHelperPlugin(); + return helper == null ? "missing" : helper.Call("State") as string ?? "unsupported"; + } + + private bool RaidsHelperPatched() + { + return RaidableBasesPlugin() != null && RaidsHelperState() == "patched"; + } + + /// + /// The installed copy's difficulties, each name once with its level, by level (D386). From + /// its public GetAllDifficulties, which lists one per profile; the free edition's is + /// Normal at 512. + /// + private List> RaidDifficulties() + { + var rows = new List>(); + Plugin rb = RaidableBasesPlugin(); + + if (rb == null) + return rows; + + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + try + { + if (rb.Call("GetAllDifficulties") is System.Collections.IEnumerable all) + { + foreach (object pair in all) + { + string name = TupleItem(pair, 1) as string; + object level = TupleItem(pair, 2); + + if (string.IsNullOrEmpty(name) || !(level is int) || !seen.Add(name)) + continue; + + rows.Add(new KeyValuePair(name, (int)level)); + } + } + } + catch (Exception ex) + { + Puts($"could not read RaidableBases' difficulties: {Flatten(ex.Message)}"); + } + + rows.Sort((a, b) => a.Value != b.Value ? a.Value.CompareTo(b.Value) : string.Compare(a.Key, b.Key, StringComparison.OrdinalIgnoreCase)); + return rows; + } + + /// The name the installed copy gives a level, or null. + private string RaidDifficultyName(int level) + { + foreach (KeyValuePair d in RaidDifficulties()) + { + if (d.Value == level) + return d.Key; + } + + return null; + } + + /// + /// Item (from 1) of a value tuple, following Rest past the + /// seventh. Reflection over System.ValueTuple only: nothing of RaidableBases' own. + /// + private static object TupleItem(object tuple, int n) + { + object at = tuple; + + while (at != null && n > 7) + { + at = at.GetType().GetField("Rest")?.GetValue(at); + n -= 7; + } + + return at?.GetType().GetField("Item" + n)?.GetValue(at); + } + + /// Whether the paid edition's own SpawnRandomBase(string, Vector3, int, int, bool) is there to call (D348: looked for by name, never assumed from the edition). + private bool RaidSpawnCall(Plugin rb) + { + if (rb == null) + return false; + + try + { + foreach (System.Reflection.MethodInfo m in rb.GetType().GetMethods(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic)) + { + if (m.Name != "SpawnRandomBase") + continue; + + System.Reflection.ParameterInfo[] ps = m.GetParameters(); + + if (ps.Length == 5 && ps[0].ParameterType == typeof(string) && ps[1].ParameterType == typeof(UnityEngine.Vector3) + && ps[2].ParameterType == typeof(int) && ps[3].ParameterType == typeof(int) && ps[4].ParameterType == typeof(bool)) + return true; + } + } + catch (Exception) + { + } + + return false; + } + + /// The plugin's RaidableType.Manual as an int, bound by name (the paid edition moved the ints, §11.4.2). + private static int RaidManualType(Plugin rb) + { + try + { + Type type = rb.GetType().GetNestedType("RaidableType", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic); + + if (type != null && type.IsEnum) + return Convert.ToInt32(Enum.Parse(type, "Manual")); + } + catch (Exception) + { + } + + return 1; + } + + /// + /// Hello's integrations.raidableBases: the edition, the difficulties, whether the + /// paid call is there, CopyPaste, and the raids helper with its grid and profiles. + /// + private Dictionary RaidableBasesBody() + { + Plugin rb = RaidableBasesPlugin(); + Dictionary body = IntegrationBody(rb); + Plugin helperPlugin = RaidsHelperPlugin(); + var helper = new Dictionary { { "state", RaidsHelperState() } }; + + if (helperPlugin != null) + { + helper["version"] = helperPlugin.Version.ToString(); + + if (helperPlugin.Call("StateReason") is string reason) + helper["reason"] = reason; + } + + body["helper"] = helper; + + if (rb == null) + return body; + + body["premium"] = rb.Call("IsPremium") is bool premium && premium; + body["spawnCall"] = RaidSpawnCall(rb); + body["copyPaste"] = IntegrationBody(plugins.Find("CopyPaste")); + + var difficulties = new List(); + + foreach (KeyValuePair d in RaidDifficulties()) + difficulties.Add(new Dictionary { { "name", d.Key }, { "level", d.Value } }); + + body["difficulties"] = difficulties; + + if (RaidsHelperPatched()) + { + body["gridReady"] = helperPlugin.Call("GridReady") is bool ready && ready; + + if (helperPlugin.Call("Profiles") is List profiles) + body["profiles"] = profiles; + } + + return body; + } + + // ---- the hooks ---- + + private void OnRaidableBaseStarted(UnityEngine.Vector3 location, int level, bool allowPvp, string id, float a, float b, float loadTime, + ulong ownerId, BasePlayer owner, List raiders, List intruders, List entities, string baseName, + DateTime spawn, DateTime despawn, float protectionRadius, int lootRemaining) + { + MarkHook("OnRaidableBaseStarted"); + + // Its one-argument overload carries the plugin's own class; a framework that padded it + // into this signature hands over no position and no name. + if (location == UnityEngine.Vector3.zero && baseName == null) + return; + + string ours = RaidTagAt(location); + LiveRaid live = NearestRaid(location); + + if (live == null) + { + live = new LiveRaid(); + _raids.Add(live); + } + + live.At = location; + live.Level = level; + live.Difficulty = RaidDifficultyName(level); + live.Pvp = allowPvp; + live.Base = baseName; + live.DespawnAtMs = RaidMs(despawn); + live.Id = ours; + + WorldEntry entry = null; + + if (ours != null && _world.TryGetValue(ours, out entry)) + { + entry.X = location.x; + entry.Y = location.y; + entry.Z = location.z; + entry.Started = true; + entry.Name = baseName; + entry.Difficulty = live.Difficulty; + live.RunId = entry.RunId; + live.Key = entry.Key; + SaveWorld(); + } + + if (!WorldEventsLive()) + return; + + Dictionary frame = RaidFrame(RaidStartedKind, live, entry); + frame["radius"] = Math.Round(protectionRadius, 1); + + if (live.DespawnAtMs > 0) + frame["despawnAt"] = live.DespawnAtMs; + + EmitFrame(frame); + } + + private void OnRaidableBaseCompleted(UnityEngine.Vector3 location, int level, bool allowPvp, string id, float a, float b, float loadTime, + ulong ownerId, BasePlayer owner, List raiders, List intruders, List entities, string baseName, + DateTime spawn, DateTime despawn, float protectionRadius, int lootRemaining) + { + MarkHook("OnRaidableBaseCompleted"); + + var credited = new List(); + + if (raiders != null) + { + foreach (BasePlayer p in raiders) + { + if (p != null && !p.IsNpc && !credited.Contains(p)) + credited.Add(p); + } + } + + // The unload trap (§11.4.2): every live base "completes" with no owner and no raiders. + if (credited.Count == 0 && ownerId == 0UL) + return; + + // The plugin's own participants are its credit; a locked base completed by its owner + // alone, with nobody else listed, credits the owner. + if (credited.Count == 0 && owner != null && !owner.IsNpc) + credited.Add(owner); + + foreach (BasePlayer p in credited) + TallyFor(p).RaidsCompleted++; + + if (!WorldEventsLive()) + return; + + string ours = RaidTagAt(location); + LiveRaid live = NearestRaid(location) ?? new LiveRaid { At = location, Level = level, Difficulty = RaidDifficultyName(level), Pvp = allowPvp, Base = baseName, Id = ours }; + WorldEntry entry = null; + + if (ours != null) + _world.TryGetValue(ours, out entry); + + Dictionary frame = RaidFrame(RaidCompletedKind, live, entry); + var named = new List(); + + foreach (BasePlayer p in credited) + { + if (named.Count >= RaidRaidersMax) + break; + + named.Add(new Dictionary { { "steamId", SteamIdOf(p) }, { "name", p.displayName } }); + } + + frame["raiders"] = named; + frame["raiderCount"] = credited.Count; + + if (ownerId != 0UL) + { + frame["ownerId"] = ownerId.ToString(System.Globalization.CultureInfo.InvariantCulture); + + if (owner != null && !string.IsNullOrEmpty(owner.displayName)) + frame["ownerName"] = owner.displayName; + } + + EmitFrame(frame); + } + + private void OnRaidableBaseEnded(UnityEngine.Vector3 location, int level, bool allowPvp, string id, float a, float b, float loadTime, + ulong ownerId, BasePlayer owner, List raiders, List intruders, List entities, string baseName, + DateTime spawn, DateTime despawn, float protectionRadius, int lootRemaining) + { + MarkHook("OnRaidableBaseEnded"); + + LiveRaid live = NearestRaid(location); + + if (live != null) + _raids.Remove(live); + + string ours = RaidTagAt(location) ?? live?.Id; + WorldEntry entry = null; + + // A run's base the plugin ended first, on its own timer or because it was raided: the + // run's resource is marked expired (D170). One the run's teardown despawned is already + // off the record, and its id, run and key come from the live base instead. + if (ours != null && _world.TryGetValue(ours, out entry)) + { + _world.Remove(ours); + SaveWorld(); + EmitRaidExpired(entry, null); + } + + if (!WorldEventsLive()) + return; + + LiveRaid told = live ?? new LiveRaid { At = location, Level = level, Difficulty = RaidDifficultyName(level), Pvp = allowPvp, Base = baseName }; + told.Id = ours; + EmitFrame(RaidFrame(RaidEndedKind, told, entry)); + } + + private void OnRaidableBasePurchased(string userId, UnityEngine.Vector3 location, string grid, int level, bool allowPvp, string baseName, + DateTime spawn, DateTime despawn) + { + MarkHook("OnRaidableBasePurchased"); + EmitRaidOwner(RaidPurchasedKind, userId, location, level, allowPvp, baseName); + } + + private void OnRaidableBaseLocked(string userId, UnityEngine.Vector3 location, string grid, int level, bool allowPvp, float loadTime, + string baseName, DateTime spawn, DateTime despawn) + { + MarkHook("OnRaidableBaseLocked"); + EmitRaidOwner(RaidLockedKind, userId, location, level, allowPvp, baseName); + } + + /// A player bought a base, or an admin locked one to a player (paid edition, D390). + private void EmitRaidOwner(string kind, string userId, UnityEngine.Vector3 location, int level, bool allowPvp, string baseName) + { + if (!WorldEventsLive()) + return; + + var live = new LiveRaid { At = location, Level = level, Difficulty = RaidDifficultyName(level), Pvp = allowPvp, Base = baseName }; + Dictionary frame = RaidFrame(kind, live, null); + ulong steam; + + if (TryParseSteamId(userId, out steam)) + { + frame["steamId"] = steam.ToString(System.Globalization.CultureInfo.InvariantCulture); + BasePlayer who = BasePlayer.FindByID(steam) ?? BasePlayer.FindSleeping(steam); + + if (who != null && !string.IsNullOrEmpty(who.displayName)) + frame["name"] = who.displayName; + } + + EmitFrame(frame); + } + + /// A raid.* event's common fields, and the run's when the base is one. + private Dictionary RaidFrame(string kind, LiveRaid live, WorldEntry entry) + { + Dictionary frame = Frame(kind, "event"); + PutPosition(frame, live.At); + frame["level"] = live.Level; + frame["pvp"] = live.Pvp; + + if (!string.IsNullOrEmpty(live.Difficulty)) + frame["difficulty"] = live.Difficulty; + + if (!string.IsNullOrEmpty(live.Base)) + frame["base"] = live.Base; + + if (entry != null) + { + frame["id"] = entry.Id; + frame["runId"] = entry.RunId; + frame["key"] = entry.Key; + } + else if (!string.IsNullOrEmpty(live.Id)) + { + // A run's base no longer on the record: a teardown's despawn (W12). + frame["id"] = live.Id; + + if (!string.IsNullOrEmpty(live.RunId)) + frame["runId"] = live.RunId; + + if (!string.IsNullOrEmpty(live.Key)) + frame["key"] = live.Key; + } + + return frame; + } + + private void EmitRaidExpired(WorldEntry e, string reason) + { + Dictionary frame = Frame("world.expired", "event"); + frame["id"] = e.Id; + frame["what"] = WorldKindRaid; + + if (!string.IsNullOrEmpty(e.RunId)) + frame["runId"] = e.RunId; + + if (!string.IsNullOrEmpty(reason)) + frame["reason"] = reason; + + EmitFrame(frame); + } + + /// A hook's DateTime as unix milliseconds; the plugin keeps local times. + private static long RaidMs(DateTime at) + { + if (at == default(DateTime)) + return 0L; + + return ToUnixMs(at.Kind == DateTimeKind.Utc ? at : at.ToUniversalTime()); + } + + private LiveRaid NearestRaid(UnityEngine.Vector3 at) + { + LiveRaid best = null; + float bestDistance = RaidReach; + + foreach (LiveRaid r in _raids) + { + float d = Distance2D(r.At, at); + + if (d <= bestDistance) + { + bestDistance = d; + best = r; + } + } + + return best; + } + + /// The run's id for the base at a spot, from the helper, or null. + private string RaidTagAt(UnityEngine.Vector3 at) + { + Plugin helper = RaidsHelperPlugin(); + return helper != null ? helper.Call("TagAt", at) as string : null; + } + + /// + /// Main thread. The live bases, found again from the plugin's public GetAllEvents: + /// a hotload of the bridge hears no starts. Its tuple's first four items and its base + /// name are read (the plugin warns that the shape may change; a shape this cannot read + /// leaves the set to the hooks). + /// + private void SeedRaids(string why) + { + _raids.Clear(); + Plugin rb = RaidableBasesPlugin(); + + if (rb == null) + return; + + try + { + if (!(rb.Call("GetAllEvents") is System.Collections.IEnumerable all)) + return; + + foreach (object row in all) + { + if (!(TupleItem(row, 1) is UnityEngine.Vector3 at)) + continue; + + int level = TupleItem(row, 3) is int l ? l : 0; + var live = new LiveRaid + { + At = at, + Level = level, + Difficulty = RaidDifficultyName(level), + Pvp = TupleItem(row, 4) is bool pvp && pvp, + Base = TupleItem(row, 14) as string, + DespawnAtMs = TupleItem(row, 16) is DateTime d ? RaidMs(d) : 0L, + Id = RaidTagAt(at) + }; + WorldEntry entry; + + if (live.Id != null && _world.TryGetValue(live.Id, out entry)) + { + live.RunId = entry.RunId; + live.Key = entry.Key; + } + + _raids.Add(live); + } + } + catch (Exception ex) + { + Puts($"could not read RaidableBases' live bases ({why}): {Flatten(ex.Message)}"); + } + + if (_raids.Count > 0) + Puts($"map: {_raids.Count} raid base(s) after {why}"); + } + + /// The map's raids rows (§20.12): every live base, and the run's own among them. + private List RaidRows() + { + var rows = new List(); + + foreach (LiveRaid r in _raids) + { + Dictionary row = At(r.At); + row["level"] = r.Level; + row["pvp"] = r.Pvp; + + if (!string.IsNullOrEmpty(r.Difficulty)) + row["difficulty"] = r.Difficulty; + + if (!string.IsNullOrEmpty(r.Base)) + row["base"] = r.Base; + + if (r.DespawnAtMs > 0) + row["despawnAt"] = r.DespawnAtMs; + + WorldEntry entry; + + if (r.Id != null && _world.TryGetValue(r.Id, out entry)) + { + row["runId"] = entry.RunId; + row["key"] = entry.Key; + } + + rows.Add(row); + } + + return rows; + } + + /// Main thread. RaidableBases unloaded: every base went with it, and every run's base still on its way. + private void RaidableBasesGone() + { + _raids.Clear(); + var gone = new List(); + + foreach (WorldEntry e in _world.Values) + { + if (e.Kind == WorldKindRaid) + gone.Add(e); + } + + if (gone.Count == 0) + return; + + foreach (WorldEntry e in gone) + { + _world.Remove(e.Id); + EmitRaidExpired(e, "raidablebases-unloaded"); + } + + SaveWorld(); + _lastWorldSummary = $"RaidableBases unloaded: {gone.Count} run base(s) went with it"; + Puts($"world: {_lastWorldSummary}"); + } + + // ---- a run's base in the world registry ---- + + /// Main thread. Despawns a run's base through the helper. Whether it was there to take. + private bool DespawnRaid(WorldEntry e) + { + Plugin helper = RaidsHelperPlugin(); + + if (helper == null) + return false; + + string done = helper.Call("Despawn", e.Id) as string; + return done == "removed" || done == "cancelled"; + } + + /// + /// Main thread. Whether a run's base is still out there. A base does not survive a + /// restart (RaidableBases despawns every one as it unloads), so one from another boot is + /// gone. Without the helper the bridge cannot ask, and "I cannot ask" is not "it is gone". + /// + private bool RaidHeld(WorldEntry e) + { + if (!string.IsNullOrEmpty(e.BootId) && e.BootId != _bootId) + return false; + + Plugin helper = RaidsHelperPlugin(); + + if (helper == null || RaidableBasesPlugin() == null) + return RaidableBasesPlugin() != null; + + string live = helper.Call("LiveState", e.Id) as string; + return live == "open" || live == "queued"; + } + + private void EnsureRaidTimer() + { + if (_raidTimer == null) + _raidTimer = timer.Every(30f, CheckRaidsPending); + } + + /// + /// Main thread, every 30 s while a run's base is on its way. One that has not opened + /// fifteen minutes after it was asked for never will (its paste failed, or it found no + /// room): the request is dropped and the run's resource expires, with the reason. + /// + private void CheckRaidsPending() + { + List due = null; + bool waiting = false; + long now = NowMs(); + + foreach (WorldEntry e in _world.Values) + { + if (e.Kind != WorldKindRaid || e.Started) + continue; + + waiting = true; + + if (now - e.TakenMs < RaidPendingMs) + continue; + + if (due == null) + due = new List(); + + due.Add(e); + } + + if (!waiting && _raidTimer != null) + { + _raidTimer.Destroy(); + _raidTimer = null; + return; + } + + if (due == null) + return; + + foreach (WorldEntry e in due) + { + _world.Remove(e.Id); + DespawnRaid(e); + EmitRaidExpired(e, "never-started"); + } + + SaveWorld(); + _lastWorldSummary = $"{due.Count} run raid base(s) never opened and were dropped"; + Puts($"world: {_lastWorldSummary}"); + } + + // ---- the spawn's keys ---- + + private string RaidStartsPath() + { + return Path.Combine(Interface.Oxide.DataDirectory, Name, "raid-starts.json"); + } + + private void SaveRaidStarts() + { + WriteDataFile(RaidStartsPath(), new List(_raidStarts.Values), "RaidableBases spawns"); + } + + private void LoadRaidStarts() + { + _raidStarts = new Dictionary(StringComparer.Ordinal); + List starts = ReadDataFile(RaidStartsPath(), "RaidableBases spawns"); + + if (starts != null) + foreach (RaidStart s in starts) + if (s != null && !string.IsNullOrEmpty(s.Key)) + _raidStarts[s.Key] = s; + + ForgetOldRaidStarts(); + + foreach (WorldEntry e in _world.Values) + { + if (e.Kind == WorldKindRaid && !e.Started) + { + EnsureRaidTimer(); + break; + } + } + } + + private void ForgetOldRaidStarts() + { + long now = NowMs(); + var stale = new List(); + + foreach (KeyValuePair kv in _raidStarts) + if (now >= kv.Value.AtMs + RaidStartMemoryMs) + stale.Add(kv.Key); + + foreach (string key in stale) + _raidStarts.Remove(key); + + if (stale.Count > 0) + SaveRaidStarts(); + } + + // ---- raid.spawn and raid.check ---- + + /// + /// Main thread. One base for a run's step (§20.12). With the raids helper it is the run's: + /// a world registry entry, despawned at teardown. Without it, the plugin's own call or + /// console command spawns one that despawns on its own timer. A key already answered is + /// answered again with its first spawn and spawns nothing. + /// + private void HandleRaidSpawn(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)) + { + EmitRaidError(reqId, "malformed", "a spawn is made for a run, under a key of at most 128 characters", null); + return; + } + + ForgetOldRaidStarts(); + + RaidStart seen; + + if (_raidStarts.TryGetValue(key, out seen)) + { + EmitRaidQueued(reqId, seen, true, null); + return; + } + + Plugin rb = RaidableBasesPlugin(); + + if (rb == null) + { + EmitRaidError(reqId, "rb-missing", "RaidableBases is not loaded on this server", null); + return; + } + + if (plugins.Find("CopyPaste") == null) + { + EmitRaidError(reqId, "copypaste-missing", "CopyPaste is not loaded on this server, and RaidableBases pastes every base with it", null); + return; + } + + string difficulty = StringField(cmd, "difficulty"); + string profile = StringField(cmd, "profile"); + string baseName = StringField(cmd, "base"); + bool spot = !string.IsNullOrEmpty(StringField(cmd, "monument")) || DoubleField(cmd, "x").HasValue || DoubleField(cmd, "z").HasValue; + bool settings = cmd.ContainsKey("pvp") || cmd.ContainsKey("npcs") || cmd.ContainsKey("npcCount") || cmd.ContainsKey("despawnMinutes") || cmd.ContainsKey("lock"); + bool needsHelper = spot || settings || !string.IsNullOrEmpty(profile) || !string.IsNullOrEmpty(baseName); + + // The difficulty, by the plugin's own names (D386). + int? level = null; + + if (!string.IsNullOrEmpty(difficulty)) + { + foreach (KeyValuePair d in RaidDifficulties()) + { + if (string.Equals(d.Key, difficulty, StringComparison.OrdinalIgnoreCase)) + { + difficulty = d.Key; + level = d.Value; + break; + } + } + + if (!level.HasValue) + { + EmitRaidError(reqId, "bad-difficulty", $"RaidableBases has no difficulty '{difficulty}' on this server", null); + return; + } + } + + if (RaidsHelperPatched()) + { + SpawnWithHelper(cmd, reqId, key, runId, difficulty, level, profile, baseName, spot); + return; + } + + if (needsHelper) + { + string state = RaidsHelperState(); + EmitRaidError(reqId, state == "missing" ? "helper-needed" : "helper-unsupported", + state == "missing" + ? "a spot, a profile, a base or settings for one spawn need RunicGatewayRaids.cs beside the bridge, and it is not installed" + : $"RunicGatewayRaids.cs could not patch this RaidableBases ({state}), so a spot, a profile, a base or settings cannot be used", + null); + return; + } + + SpawnWithoutHelper(rb, reqId, key, difficulty, level); + } + + private void SpawnWithHelper(Dictionary cmd, string reqId, string key, string runId, string difficulty, int? level, + string profile, string baseName, bool spot) + { + Plugin helper = RaidsHelperPlugin(); + string id = $"{RaidIdPrefix}{NowMs()}-{++_raidSerial}"; + var req = new Dictionary { { "id", id } }; + + if (difficulty != null) + req["difficulty"] = difficulty; + + if (level.HasValue) + req["level"] = level.Value; + + if (!string.IsNullOrEmpty(profile)) + req["profile"] = profile; + + if (!string.IsNullOrEmpty(baseName)) + req["base"] = baseName; + + if (spot) + { + UnityEngine.Vector3 at; + string reason, refusal; + + // A spot is a world call's location; its height is the step's own (below), not `y`. + var where = new Dictionary(cmd); + where.Remove("y"); + + if (!ResolveWorldPoint(where, out at, out reason, out refusal)) + { + EmitRaidError(reqId, reason, refusal, null); + return; + } + + req["x"] = at.x; + req["z"] = at.z; + + double? adjust = DoubleField(cmd, "heightAdjust"); + double? height = DoubleField(cmd, "height"); + + if (adjust.HasValue && height.HasValue) + { + EmitRaidError(reqId, "malformed", "a spot takes a height adjustment or an exact height, not both", null); + return; + } + + if (height.HasValue) + req["height"] = height.Value; + else if (adjust.HasValue) + req["heightAdjust"] = adjust.Value; + } + + foreach (string flag in new[] { "pvp", "npcs", "lock" }) + { + bool value; + + if (cmd.ContainsKey(flag) && TryParseFlag(StringField(cmd, flag), out value)) + req[flag] = value; + } + + int npcCount = IntField(cmd, "npcCount"); + int despawn = IntField(cmd, "despawnMinutes"); + + if (cmd.ContainsKey("npcCount")) + { + if (npcCount < 0 || npcCount > WorldHardMaxNpcs) + { + EmitRaidError(reqId, "out-of-range", $"a raid base takes 0 to {WorldHardMaxNpcs} NPCs, not {npcCount}", null); + return; + } + + req["npcCount"] = npcCount; + } + + if (cmd.ContainsKey("despawnMinutes")) + { + if (despawn < 1 || despawn > 24 * 60) + { + EmitRaidError(reqId, "out-of-range", $"a raid base despawns after 1 to 1440 minutes, not {despawn}", null); + return; + } + + req["despawnMinutes"] = despawn; + } + + var answer = helper.Call("Spawn", req) as Dictionary; + + if (answer == null) + { + EmitRaidError(reqId, "refused", "the raids helper did not answer", null); + return; + } + + List warnings = answer.TryGetValue("warnings", out object w) ? w as List : null; + + if (!(answer.TryGetValue("ok", out object ok) && ok is bool done && done)) + { + EmitRaidError(reqId, StringField(answer, "reason") ?? "refused", StringField(answer, "message") ?? "the raids helper refused", warnings); + return; + } + + double? x = DoubleField(answer, "x"); + double? z = DoubleField(answer, "z"); + + var start = new RaidStart + { + Key = key, + Id = id, + Route = StringField(answer, "route") == "spot" ? "spot" : "pick", + Difficulty = StringField(answer, "difficulty"), + Level = IntField(answer, "level"), + Profile = StringField(answer, "profile"), + Base = StringField(answer, "base"), + X = x.HasValue ? (float)x.Value : 0f, + Z = z.HasValue ? (float)z.Value : 0f, + Spot = x.HasValue, + AtMs = NowMs() + }; + + _world[id] = new WorldEntry + { + Id = id, + Kind = WorldKindRaid, + RunId = runId, + Key = key, + X = start.X, + Y = 0f, + Z = start.Z, + Name = start.Base, + Difficulty = start.Difficulty, + BootId = _bootId, + WipeId = _wipeId, + TakenMs = NowMs() + }; + + _raidStarts[key] = start; + SaveWorld(); + SaveRaidStarts(); + EnsureRaidTimer(); + + _lastWorldSummary = $"asked RaidableBases for a {start.Difficulty ?? "?"} base ({start.Route}) for run {runId} as {id}"; + Puts($"world: {_lastWorldSummary}"); + EmitRaidQueued(reqId, start, false, warnings); + } + + /// No helper: the paid edition's own call at a difficulty, else the free edition's console command. Neither base is the run's. + private void SpawnWithoutHelper(Plugin rb, string reqId, string key, string difficulty, int? level) + { + var start = new RaidStart { Key = key, Difficulty = difficulty, Level = level ?? 0, AtMs = NowMs() }; + + if (RaidSpawnCall(rb)) + { + if (!level.HasValue) + { + List> all = RaidDifficulties(); + + if (all.Count == 0) + { + EmitRaidError(reqId, "bad-difficulty", "RaidableBases lists no difficulty on this server", null); + return; + } + + KeyValuePair picked = all[UnityEngine.Random.Range(0, all.Count)]; + start.Difficulty = picked.Key; + start.Level = picked.Value; + } + + object result = rb.Call("SpawnRandomBase", null, UnityEngine.Vector3.zero, start.Level, RaidManualType(rb), true); + + if (!(result is bool queued) || !queued) + { + EmitRaidError(reqId, "refused", $"RaidableBases did not queue a base: {(result as string ?? "no reason given")}", null); + return; + } + + start.Route = "plugin"; + } + else + { + if (!_consolePlugins) + { + EmitRaidError(reqId, "console-unavailable", + "this framework does not run another plugin's console command from code, so this edition of RaidableBases cannot be asked for a base without RunicGatewayRaids.cs (D363, Rust-Plugins#34)", + null); + return; + } + + try + { + ConsoleSystem.Run(ConsoleSystem.Option.Server.Quiet(), RbConsoleCommand); + } + catch (Exception ex) + { + EmitRaidError(reqId, "refused", $"RaidableBases' console command threw: {Flatten(ex.Message)}", null); + return; + } + + start.Route = "console"; + start.Difficulty = null; + } + + _raidStarts[key] = start; + SaveRaidStarts(); + + _lastWorldSummary = $"asked RaidableBases for a base by its {start.Route} (not the run's: it despawns on its own timer)"; + Puts($"world: {_lastWorldSummary}"); + EmitRaidQueued(reqId, start, false, null); + } + + /// Main thread. A spot's checks, spawning nothing (D208): the editor's advice. + private void HandleRaidCheck(Dictionary cmd, string reqId) + { + if (!WorldReady(reqId)) + return; + + if (RaidableBasesPlugin() == null) + { + EmitRaidError(reqId, "rb-missing", "RaidableBases is not loaded on this server", null); + return; + } + + if (!RaidsHelperPatched()) + { + EmitRaidError(reqId, RaidsHelperState() == "missing" ? "helper-needed" : "helper-unsupported", + "checking a spot needs RunicGatewayRaids.cs beside the bridge, patched", null); + return; + } + + UnityEngine.Vector3 at; + string reason, refusal; + var where = new Dictionary(cmd); + where.Remove("y"); + + if (!ResolveWorldPoint(where, out at, out reason, out refusal)) + { + EmitRaidError(reqId, reason, refusal, null); + return; + } + + var req = new Dictionary { { "x", at.x }, { "z", at.z } }; + + foreach (string field in new[] { "difficulty", "profile", "base" }) + { + string value = StringField(cmd, field); + + if (!string.IsNullOrEmpty(value)) + req[field] = value; + } + + var answer = RaidsHelperPlugin().Call("Check", req) as Dictionary; + Dictionary reply = Frame("raid.checked", "reply"); + + if (answer == null) + { + reply["ok"] = false; + reply["reason"] = "refused"; + reply["message"] = "the raids helper did not answer"; + } + else + { + reply["ok"] = answer.TryGetValue("ok", out object ok) && ok is bool fine && fine; + reply["warnings"] = answer.TryGetValue("warnings", out object w) && w is List list ? list : new List(); + + if (StringField(answer, "reason") != null) + reply["reason"] = StringField(answer, "reason"); + + if (StringField(answer, "message") != null) + reply["message"] = StringField(answer, "message"); + + if (StringField(answer, "base") != null) + reply["base"] = StringField(answer, "base"); + } + + PutPosition(reply, at); + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + private void EmitRaidQueued(string reqId, RaidStart start, bool repeat, List warnings) + { + Dictionary frame = Frame("raid.queued", "reply"); + frame["key"] = start.Key; + frame["route"] = start.Route; + frame["repeat"] = repeat; + + // The run's base, despawned at teardown, only by the helper's routes (D204). + frame["despawns"] = start.Id != null ? "teardown" : "own-timer"; + + if (start.Id != null) + frame["id"] = start.Id; + + if (!string.IsNullOrEmpty(start.Difficulty)) + { + frame["difficulty"] = start.Difficulty; + frame["level"] = start.Level; + } + + if (!string.IsNullOrEmpty(start.Profile)) + frame["profile"] = start.Profile; + + if (!string.IsNullOrEmpty(start.Base)) + frame["base"] = start.Base; + + if (start.Spot) + PutPosition(frame, new UnityEngine.Vector3(start.X, 0f, start.Z)); + + frame["warnings"] = warnings ?? new List(); + + if (reqId != null) + frame["reqId"] = reqId; + + EmitFrame(frame); + } + + private void EmitRaidError(string reqId, string reason, string message, List warnings) + { + Dictionary frame = Frame("raid.error", "reply"); + frame["reason"] = reason; + frame["message"] = message; + + if (warnings != null && warnings.Count > 0) + frame["warnings"] = warnings; + + if (reqId != null) + frame["reqId"] = reqId; + + _lastWorldSummary = $"RaidableBases spawn refused ({reason}): {message}"; + EmitFrame(frame); + } + + // ---- ZLevels Remastered (PLAN_REDESIGNS §14.3.6, D369, D374) ---- + // + // Hello says whether it is loaded, and that is all. No level is read or sent: the + // leaderboard does not change for it (D374). Its XP boosts are permissions, so an event + // lends one through `perm.lend` with no code of ours (§12). + + private Plugin ZLevelsPlugin() + { + return plugins.Find("ZLevelsRemastered"); + } + + private bool IsZLevels(Plugin plugin) + { + return plugin != null && plugin != this && plugin.Name == "ZLevelsRemastered"; + } + // ---- the zone's messages (D193) ---- // // ZoneManager can say a zone's enter and leave messages itself, but only to chat unless its @@ -8715,6 +12190,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) @@ -8885,6 +12361,22 @@ namespace Oxide.Plugins dome = true; } + // §14.3.2 (D368): TruePVE's rules in the zone, as `exclude` or one ruleset by name. + string truePve; + string truePveRefusal; + + if (!ReadTruePve(cmd, out truePve, out truePveRefusal)) + { + EmitWorldError(reqId, "malformed", truePveRefusal); + return; + } + + if (truePve != null && TruePvePlugin() == null) + { + EmitWorldError(reqId, "truepve-missing", "TruePVE is not loaded on this server, so a zone cannot change its PvP rules"); + return; + } + var entry = new WorldEntry { Id = $"rg-{ShortRun(runId)}-{NowMs() % 100000000L}-{++_zoneSerial}", @@ -8926,11 +12418,35 @@ namespace Oxide.Plugins return; } + // The mapping last, all or nothing like the dome: only TruePVE's own answer can say + // whether it has a ruleset of that name (D368), and a zone without the rules its + // step asked for is not opened at all. + if (truePve != null) + { + object mapped = TruePvePlugin()?.Call("AddOrUpdateMapping", entry.Id, truePve); + + if (!(mapped is bool && (bool)mapped)) + { + RemoveDome(entry); + zm.Call("EraseTemporaryZone", this, entry.Id); + + if (truePve == TruePveExclude) + EmitWorldError(reqId, "refused", "TruePVE did not map the zone, so it was not opened"); + else + EmitWorldError(reqId, "truepve-ruleset", + $"TruePVE on this server has no ruleset named '{truePve}' (names are case-sensitive), so the zone was not opened"); + + return; + } + + entry.TruePve = truePve; + } + _world[entry.Id] = entry; SaveWorld(); EnsureWorldTimer(); - _lastWorldSummary = $"zone {entry.Id} r={radius:0} for {holdMs / 60000L} min, {flags.Count} flag(s){(dome ? ", domed" : "")}, run {runId}"; + _lastWorldSummary = $"zone {entry.Id} r={radius:0} for {holdMs / 60000L} min, {flags.Count} flag(s){(dome ? ", domed" : "")}{(truePve != null ? $", TruePVE {truePve}" : "")}, run {runId}"; Puts($"world: {_lastWorldSummary}"); EmitWorldPlaced(reqId, new List { entry }, false); } @@ -9249,9 +12765,12 @@ namespace Oxide.Plugins // Not on record. That is either something a wipe or a prune already took — // gone, which is a success — or something this bridge never made. Only a // thing that is actually THERE can be the second, and only that is refused. - bool exists = id.StartsWith("rg-", StringComparison.Ordinal) - ? zm != null && zm.Call("CheckZoneID", id) != null - : FindEntity(id) != null; + // A raid base's id is this bridge's own (§20.12), so one off the record is gone. + bool exists = id.StartsWith(RaidIdPrefix, StringComparison.Ordinal) + ? false + : id.StartsWith("rg-", StringComparison.Ordinal) + ? zm != null && zm.Call("CheckZoneID", id) != null + : FindEntity(id) != null; (exists ? refused : gone).Add(id); } @@ -9281,11 +12800,21 @@ namespace Oxide.Plugins // left there outlives the zone it was drawn over (D194). RemoveDome(e); + // And its TruePVE mapping, which TruePVE never prunes for an id like ours (D362). + UnmapTruePve(e); + if (wasThere) zm.Call("EraseTemporaryZone", this, e.Id); _zoneQuietUntil.Remove(e.Id); } + else if (e.Kind == WorldKindRaid) + { + // Off the record BEFORE the despawn: its `Despawn()` raises the plugin's hooks, + // and an ending still on the record would be told as an expiry (§20.12). + _world.Remove(e.Id); + wasThere = DespawnRaid(e); + } else { wasThere = e.Kind == WorldKindNpc ? KillWorldNpc(e) : KillEntity(e.Id); @@ -9340,6 +12869,8 @@ namespace Oxide.Plugins if (e.Kind == WorldKindZone) held = zm == null || zm.Call("CheckZoneID", e.Id) != null; + else if (e.Kind == WorldKindRaid) + held = RaidHeld(e); else held = FindEntity(e.Id) != null; @@ -9358,7 +12889,11 @@ namespace Oxide.Plugins if (pruned != null) { foreach (string id in pruned) + { + // A zone ZoneManager could not put back is erased all the same, mapping and all. + UnmapTruePve(_world[id]); _world.Remove(id); + } SaveWorld(); } @@ -9395,6 +12930,18 @@ namespace Oxide.Plugins row["remainingMs"] = Math.Max(0L, e.DeadlineMs - NowMs()); } + if (e.Kind == WorldKindRaid) + { + // The base's own name and its difficulty, both the plugin's (D386). + if (!string.IsNullOrEmpty(e.Name)) + row["name"] = e.Name; + + if (!string.IsNullOrEmpty(e.Difficulty)) + row["difficulty"] = e.Difficulty; + + row["started"] = e.Started; + } + return row; } @@ -9565,6 +13112,7 @@ namespace Oxide.Plugins foreach (WorldEntry e in due) { RemoveDome(e); + UnmapTruePve(e); if (zm != null && zm.Call("CheckZoneID", e.Id) != null) zm.Call("EraseTemporaryZone", this, e.Id); @@ -9613,8 +13161,12 @@ namespace Oxide.Plugins if (stale.Count == 0) return; + // TruePVE's table outlives a wipe, so the old map's mappings are removed with it. foreach (string id in stale) + { + UnmapTruePve(_world[id]); _world.Remove(id); + } SaveWorld(); _lastWorldSummary = $"a new wipe dropped {stale.Count} things from the old map"; @@ -9625,7 +13177,10 @@ namespace Oxide.Plugins [ConsoleCommand("rg.world")] private void CmdWorld(ConsoleSystem.Arg arg) { - int zones = 0, crates = 0, npcs = 0; + int zones = 0, crates = 0, npcs = 0, raidBases = 0; + + // Guarded Crate's `stop all` ends events without a hook; count what is really running. + SeedGuardedCrates(null); foreach (WorldEntry e in _world.Values) { @@ -9633,14 +13188,30 @@ namespace Oxide.Plugins zones++; else if (e.Kind == WorldKindNpc) npcs++; + else if (e.Kind == WorldKindRaid) + raidBases++; else crates++; } + Plugin raidHelper = RaidsHelperPlugin(); + arg.ReplyWith( $"protocol={ProtocolVersion} eventsEnabled={(_config != null && _config.EventsEnabled)} " + $"zones={zones} crates={crates} npcs={npcs} zoneManager={(ZoneManagerPlugin() != null)} " + - $"wipeId={(_wipeId ?? "none")}" + Environment.NewLine + + $"truePve={(TruePvePlugin() != null)} truePveLeftover={_truePvePending.Count} " + + $"dynamicPvp={(DynamicPvpPlugin() != null)} pvpZones={_pvpZones.Count} " + + $"dangerousTreasures={(DangerousTreasuresPlugin() != null)} treasures={_treasures.Count} dtStarts={_dtStarts.Count} dtWarmingUp={DtWarmingUp()} " + + $"guardedCrate={(GuardedCratePlugin() != null)} npcSpawn={(NpcSpawnPlugin() != null)} guarded={_guardedCrates.Count} gcPlaces={_gcPlacements.Count} " + + $"gcEvents={(GuardedCrateEventNames() != null ? string.Join("|", GuardedCrateEventNames().ToArray()) : "none")} gcCommand={GuardedCrateCommand()} gcListReadable={_gcListReadable} " + + $"zLevels={(ZLevelsPlugin() != null ? ZLevelsPlugin().Version.ToString() : "absent")} " + + $"raidableBases={(RaidableBasesPlugin() != null ? RaidableBasesPlugin().Version.ToString() : "absent")} raidsHelper={RaidsHelperState()} " + + $"raidGrid={(raidHelper != null && raidHelper.Call("GridReady") is bool gridReady ? (gridReady ? "ready" : "building") : "unknown")} " + + $"raids={_raids.Count} raidBases={raidBases} " + + $"wipeId={(_wipeId ?? "none")} calls={_worldCalls.Count} consolePlugins={_consolePlugins} " + + $"commandKeys={_commandRecords.Count} commandsRunning={_commandJobs.Count} " + + $"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}"); } @@ -9768,9 +13339,41 @@ namespace Oxide.Plugins public int Consumed; } + /// + /// One "you earned a reward" message for one player (protocol 14, D333). The key names + /// the reward — a run and its step — so a message is told once however many syncs carry + /// it. + /// + private sealed class RewardNotice + { + public string Key; + public string SteamId; + public string Message; + public string Delivery; + } + + /// The most notices one sync carries; the rest wait for the next. + private const int NoticesMax = 2000; + + /// The longest notice key. The site's are short (run:N:step:N). + private const int NoticeKeyMax = 128; + private Dictionary _runTallies = new Dictionary(); private Dictionary _credits = new Dictionary(); + /// + /// The site's untold notices, by key, as the last sync carried them. In memory only: the + /// site sends its whole untold set on every sync, so a reload loses nothing it will not + /// hear again. + /// + private Dictionary _notices = new Dictionary(); + + /// + /// The keys already told, kept on disk until the site stops sending them. A reload + /// between telling a player and the report that says so would otherwise tell them twice. + /// + private HashSet _noticesTold = new HashSet(StringComparer.Ordinal); + /// Recently said chat keys, and when. Main thread only. private readonly Dictionary _chatKeys = new Dictionary(); @@ -10303,6 +13906,139 @@ namespace Oxide.Plugins WriteDataFile(CreditsPath(), new List(_credits.Values), "kit credits"); } + // ── Reward notices (protocol 14, PROTOCOL.md §20.2, D333) ────────────────────────────── + // + // "You earned kit vip — type /kit vip", said once to the player a reward step picked, in + // chat or as a popup. The site decides what to say and to whom; this plugin decides only + // WHEN: at once if the player is up and about, otherwise the next time they wake on this + // server. A sleeping or loading player sees no popup and reads no chat, so "online" is not + // enough — awake is. + + private string NoticesPath() + { + return Path.Combine(Interface.Oxide.DataDirectory, Name, "notices.json"); + } + + private void SaveNoticesTold() + { + WriteDataFile(NoticesPath(), new List(_noticesTold), "told reward notices"); + } + + private void LoadNoticesTold() + { + List told = ReadDataFile(NoticesPath(), "told reward notices"); + + if (told == null) + return; + + foreach (string key in told) + { + if (!string.IsNullOrEmpty(key)) + _noticesTold.Add(key); + } + } + + /// + /// Main thread, at the end of a sync that carried notices. Takes the site's set as + /// the whole truth, forgets told keys the site no longer sends (it has heard, or the + /// reward was withdrawn), tells everybody awake now, and lists every told key of the set + /// for the report. + /// + private void ApplyNotices(PermSyncJob job) + { + var next = new Dictionary(StringComparer.Ordinal); + + foreach (RewardNotice n in job.Notices) + next[n.Key] = n; + + _notices = next; + + int before = _noticesTold.Count; + _noticesTold.RemoveWhere(k => !next.ContainsKey(k)); + bool changed = _noticesTold.Count != before; + + foreach (RewardNotice n in next.Values) + { + if (_noticesTold.Contains(n.Key)) + continue; + + BasePlayer player = AwakePlayer(n.SteamId); + + if (player != null && TellNotice(player, n)) + changed = true; + } + + if (changed) + SaveNoticesTold(); + + foreach (string key in _noticesTold) + job.NoticesTold.Add(key); + } + + /// The player with this Steam id if they are connected and awake, else null. + private static BasePlayer AwakePlayer(string steamId) + { + ulong id; + + if (!ulong.TryParse(steamId, out id)) + return null; + + BasePlayer player = BasePlayer.FindByID(id); + return player != null && player.IsConnected && !player.IsSleeping() ? player : null; + } + + /// + /// Main thread. Says one notice to one player and records it told. A popup where + /// PopupNotifications is loaded and the step chose one, chat otherwise (§18) — the reward + /// was granted long before this moment, and the player is told either way. + /// + private bool TellNotice(BasePlayer player, RewardNotice n) + { + SayTo(player, n.Message, n.Delivery); + return _noticesTold.Add(n.Key); + } + + /// Main thread. One line to one player, as a popup where one was asked for and can be shown. + private void SayTo(BasePlayer player, string message, string delivery) + { + Plugin popup = delivery == DeliveryPopup ? PopupPlugin() : null; + + if (popup != null) + popup.Call("CreatePopupNotification", message, player, 0f); + else + player.ChatMessage(message); + } + + /// + /// A player woke — after connecting, or after sleeping in a bag. Their untold notices are + /// said now. The site hears at the next sync's report. + /// + private void OnPlayerSleepEnded(BasePlayer player) + { + MarkHook("OnPlayerSleepEnded"); + + if (player == null) + return; + + // Coins paid while they were away (protocol 14, §20.3). + TellPayments(player); + + if (_notices.Count == 0) + return; + + string steamId = SteamIdOf(player); + bool changed = false; + + foreach (RewardNotice n in _notices.Values) + { + if (n.SteamId == steamId && !_noticesTold.Contains(n.Key) && TellNotice(player, n)) + changed = true; + } + + if (changed) + SaveNoticesTold(); + } + private void WriteDataFile(string path, object rows, string what) { try @@ -10377,7 +14113,10 @@ namespace Oxide.Plugins } } - Puts($"rewards: {_runTallies.Count} tallies and {_credits.Count} kit credits on record"); + LoadNoticesTold(); + LoadPayments(); + + Puts($"rewards: {_runTallies.Count} tallies, {_credits.Count} kit credits, {_noticesTold.Count} told notices and {_payments.Count} Economics payments on record"); EnsureTallyTimer(); } @@ -11333,6 +15072,82 @@ namespace Oxide.Plugins EmitFrame(reply); } + /// + /// Main thread. Protocol 14 (PROTOCOL.md §20.1; the module's PLAN_REDESIGNS.md §2.6). Of + /// the given players, how many have used kit up and how many are waiting out its + /// cooldown — the warning beside a kit weekend ("9 of 41 linked players have used it + /// up"). Counts, never a list: the website asked about players it already knows, + /// and the answer it needs is how many, not who. + /// + /// + /// Two Kits reads per player, so the list is bounded by the sync's own bound: the site + /// sends its linked players for this server, and a sync carries no more than that. + /// + /// + private void HandleKitsUses(Dictionary cmd, string reqId) + { + Plugin kits = KitsPlugin(); + + if (kits == null) + { + EmitRewardError("kits.error", reqId, "kits-missing", "Kits is not loaded on this server"); + return; + } + + string kit = StringField(cmd, "kit"); + + if (string.IsNullOrEmpty(kit) || !(kits.Call("GetKitInfo", kit) is Newtonsoft.Json.Linq.JObject)) + { + EmitRewardError("kits.error", reqId, "unknown-kit", $"this server has no kit named '{Flatten(kit ?? "")}'"); + return; + } + + var ids = new List(); + + foreach (string raw in Strings(cmd, "steamIds")) + { + ulong id; + + if (ulong.TryParse(raw, out id)) + ids.Add(id); + } + + if (ids.Count > PermMaxOps) + { + EmitRewardError("kits.error", reqId, "too-large", $"at most {PermMaxOps} players in one question, not {ids.Count}"); + return; + } + + int max = CallInt(kits, "GetKitMaxUses", kit); + int exhausted = 0; + int onCooldown = 0; + + foreach (ulong id in ids) + { + // A kit with no use limit is never used up. + if (max > 0 && CallInt(kits, "GetPlayerKitUses", id, kit) >= max) + exhausted++; + + object cooldown = kits.Call("GetPlayerKitCooldown", id, kit); + + if (cooldown is double && (double)cooldown > 0) + onCooldown++; + } + + Dictionary reply = Frame("kits.uses", "reply"); + reply["kit"] = kit; + reply["max"] = max; + reply["cooldown"] = CallInt(kits, "GetKitCooldown", kit); + reply["asked"] = ids.Count; + reply["exhausted"] = exhausted; + reply["onCooldown"] = onCooldown; + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + private static string CreditKey(string steamId, string kit) { return steamId + "|" + Lower(kit); @@ -11566,6 +15381,1001 @@ namespace Oxide.Plugins Puts($"rewards: Kits wipes use counts at a new save, so {_credits.Count} kit credits start over"); } + // ── Economics (protocol 14, PROTOCOL.md §20.3; the module's PLAN_REDESIGNS.md §11.2) ──── + // + // Wulf's Economics, optional like the other mods (D199): every verb below answers + // `economics-missing` without it, and nothing else in this plugin needs it. Its API is five + // private methods, each with a `string` and an `object` overload, called by name. What the + // spike found (§11.2.1) shapes every call here: + // + // * an amount is boxed as a `double` — an `int` matches no overload, and `Call` answers + // null having done nothing; + // * only ids parsed as Steam ids are sent, because Economics makes an account of any + // string it is given; + // * what a deposit paid is the balance after less the balance before, because Deposit + // answers `true` while rounding to two places and clamping to its balance limit. + // + // Coins paid are kept (D335): there is no revert. A payment or adjustment is remembered by + // its key for seven days, so a retried step or a resent form is answered from the first + // reply and never pays twice. + + private const double EconomicsAmountMin = 0.01; + private const double EconomicsAmountMax = 1000000d; + + /// A deposit that moved the balance by less than this moved nothing: it rounds to two places. + private const double EconomicsMovedMin = 0.005; + + /// How long a payment's key and reply are kept: seven days, the tally ceiling (D108). + private const long PaymentMemoryMs = TallyMaxHoldMs; + + /// The longest payment key. The site's are short (runId:stepIndex). + private const int PaymentKeyMax = 128; + + /// The lowest individual Steam id. Anything below it is not a player. + private const ulong SteamIdBase = 76561197960265728UL; + + private const string EconomicsPay = "pay"; + private const string EconomicsDeposit = "deposit"; + private const string EconomicsWithdraw = "withdraw"; + private const string EconomicsSet = "set"; + + /// + /// One payment (economics.pay) or one staff adjustment (economics.adjust), + /// kept with what it did so a repeat of its key is answered the same. Persisted as it is. + /// + private sealed class EconomicsPayment + { + public string Key; + + /// pay, or the adjustment's deposit, withdraw or set. + public string Op; + + public long AtMs; + public double Amount; + + /// A payment's message, {amount} not yet filled in. Blank tells nobody. + public string Message; + + public string Delivery; + public List Paid = new List(); + public List Failed = new List(); + } + + private sealed class EconomicsPaid + { + public string SteamId; + public double Before; + public double After; + + /// Whether the payment's message has been said to this player (always, when there is none). + public bool Told; + } + + private sealed class EconomicsFailed + { + public string SteamId; + public string Reason; + } + + /// What Economics' own config says, read from its file because its API does not. + private sealed class EconomicsSettings + { + public double? StartingBalance; + public bool? WipesWithMap; + } + + /// Payments and adjustments of the last seven days, by . + private Dictionary _payments = new Dictionary(StringComparer.Ordinal); + + private EconomicsSettings _economicsSettings; + private DateTime _economicsSettingsAt; + + private Plugin EconomicsPlugin() + { + return plugins.Find("Economics"); + } + + private bool IsEconomics(Plugin plugin) + { + return plugin != null && plugin != this && plugin.Name == "Economics"; + } + + /// + /// Hello's integrations.economics: loaded and version like every optional mod, and + /// the two settings the site needs from Economics' config. Each is absent when the file + /// does not say, and the site then assumes Economics' defaults. + /// + private Dictionary EconomicsBody() + { + Plugin eco = EconomicsPlugin(); + Dictionary body = IntegrationBody(eco); + + if (eco == null) + return body; + + EconomicsSettings settings = EconomicsConfig(); + + if (settings != null && settings.StartingBalance.HasValue) + body["startingBalance"] = settings.StartingBalance.Value; + + if (settings != null && settings.WipesWithMap.HasValue) + body["wipesWithMap"] = settings.WipesWithMap.Value; + + return body; + } + + /// + /// Economics' config, read again only when the file changes: every status frame asks. + /// A file that will not parse is said once per version of it, and answers nothing. On + /// Carbon the file is under carbon/configs, so the directory comes from the + /// framework (R19), as Kits' does. + /// + private EconomicsSettings EconomicsConfig() + { + string path = Path.Combine(Interface.Oxide.ConfigDirectory, "Economics.json"); + DateTime at; + + try + { + if (!File.Exists(path)) + return null; + + at = File.GetLastWriteTimeUtc(path); + } + catch (Exception) + { + return null; + } + + if (_economicsSettings != null && at == _economicsSettingsAt) + return _economicsSettings; + + var settings = new EconomicsSettings(); + + try + { + var config = Newtonsoft.Json.Linq.JObject.Parse(File.ReadAllText(path)); + Newtonsoft.Json.Linq.JToken start = config["Starting account balance (0 or higher)"]; + Newtonsoft.Json.Linq.JToken wipe = config["Wipe balances on new save file"]; + + if (start != null && (start.Type == Newtonsoft.Json.Linq.JTokenType.Integer || start.Type == Newtonsoft.Json.Linq.JTokenType.Float)) + settings.StartingBalance = (double)start; + + if (wipe != null && wipe.Type == Newtonsoft.Json.Linq.JTokenType.Boolean) + settings.WipesWithMap = (bool)wipe; + } + catch (Exception ex) + { + Puts($"could not read Economics' config: {Flatten(ex.Message)}"); + } + + _economicsSettings = settings; + _economicsSettingsAt = at; + return settings; + } + + /// Main thread. A balance to two places, or null when Economics did not answer with one. + private static double? ReadBalance(Plugin eco, string steamId) + { + object value = eco.Call("Balance", steamId); + return value is double ? (double?)Math.Round((double)value, 2) : null; + } + + /// A Steam id as Economics should see it, or false. Economics checks none itself. + private static bool TryParseSteamId(string raw, out ulong id) + { + return ulong.TryParse(raw, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out id) + && id >= SteamIdBase + && raw.Length == 17; + } + + private static string Coins(double amount) + { + return amount.ToString("#,0.##", System.Globalization.CultureInfo.InvariantCulture); + } + + private static bool AmountIn(double amount, double min) + { + return !double.IsNaN(amount) && !double.IsInfinity(amount) && amount >= min && amount <= EconomicsAmountMax; + } + + /// + /// Where a payment or an adjustment is remembered. The two keep apart, so a site's step + /// key can never be answered with a staff adjustment's reply; the three adjustments share + /// one, so a key is one adjustment whatever its op. + /// + private static string PaymentSlot(string op, string key) + { + return (op == EconomicsPay ? "pay|" : "adjust|") + key; + } + + /// Main thread. One player's balance into their tally, from their connect or Economics loading. + private void NoteBalance(BasePlayer player) + { + Plugin eco = EconomicsPlugin(); + + if (eco == null || player == null) + return; + + double? balance = ReadBalance(eco, SteamIdOf(player)); + + if (!balance.HasValue) + return; + + Tally tally = TallyFor(player); + tally.Balance = balance.Value; + tally.HasBalance = true; + } + + private void NoteOnlineBalances() + { + foreach (BasePlayer player in BasePlayer.activePlayerList) + NoteBalance(player); + } + + /// + /// Every balance change, deposits and withdrawals included, raised before their own hooks. + /// Folded into the tally as a value (rule 2: aggregates only). The player need not be + /// online: a payment or a staff adjustment reaches an offline player, and their frame + /// then has no name. + /// + private void OnEconomicsBalanceUpdated(string playerId, double balance) + { + MarkHook("OnEconomicsBalanceUpdated"); + + ulong id; + + if (!TryParseSteamId(playerId, out id)) + return; + + // Named only while connected: a sleeper is found too, and a name on the frame is what + // tells the site a player was here (it moves their last-seen). A balance is not play. + BasePlayer player = BasePlayer.FindByID(id); + Tally tally = player != null && player.IsConnected ? TallyFor(player) : TallyForId(id); + tally.Balance = Math.Round(balance, 2); + tally.HasBalance = true; + } + + /// + /// Raised with no arguments when a new save wipes the balances (Economics' own setting), + /// and with the caller after ecowipe. One method taking an IPlayer binds + /// both on both frameworks, with null for the first (§11.2.1). A balance not yet flushed + /// is from before the wipe, so it is dropped. + /// + private void OnEconomicsDataWiped(Oxide.Core.Libraries.Covalence.IPlayer player) + { + MarkHook("OnEconomicsDataWiped"); + + foreach (Tally tally in _tallies.Values) + tally.HasBalance = false; + + Dictionary frame = Frame("economics.wiped", "event"); + + // A new save's wipe opens a new map, whose balances the site has not seen yet; the + // last wipe's are a record of how it ended. `ecowipe` clears the wipe in play. + frame["newSave"] = player == null; + + EmitFrame(frame); + Puts($"Economics wiped every balance ({(player == null ? "new save" : "ecowipe")})"); + } + + /// + /// Main thread. Coins from an event step to a list of players, kept once paid (D335). One + /// call for the whole list. A key already paid is answered from its first reply with + /// repeat: true, and pays nobody. + /// + private void HandleEconomicsPay(Dictionary cmd, string reqId) + { + Plugin eco = EconomicsPlugin(); + + if (eco == null) + { + EmitEconomicsError(reqId, "economics-missing", "Economics is not installed on this server", null); + return; + } + + if (_config == null || !_config.EventsEnabled) + { + EmitEconomicsError(reqId, "events-disabled", + "events are switched off on this server — set EventsEnabled in the RunicGateway plugin config", null); + return; + } + + string key = StringField(cmd, "key"); + + if (string.IsNullOrEmpty(key) || key.Length > PaymentKeyMax) + { + EmitEconomicsError(reqId, "malformed", "a payment has a key", null); + return; + } + + ForgetOldPayments(); + + EconomicsPayment done; + + if (_payments.TryGetValue(PaymentSlot(EconomicsPay, key), out done)) + { + EmitPaid(done, reqId, true); + return; + } + + double amount = DoubleField(cmd, "amount") ?? double.NaN; + + if (!AmountIn(amount, EconomicsAmountMin)) + { + EmitEconomicsError(reqId, "bad-amount", + $"a payment is {Coins(EconomicsAmountMin)} to {Coins(EconomicsAmountMax)} coins", null); + return; + } + + var payment = new EconomicsPayment + { + Key = key, + Op = EconomicsPay, + AtMs = NowMs(), + Amount = amount, + Message = Flatten(StringField(cmd, "message") ?? "").Trim(), + Delivery = StringField(cmd, "delivery") == DeliveryPopup ? DeliveryPopup : DeliveryChat + }; + + var ids = new List(); + var seen = new HashSet(StringComparer.Ordinal); + + foreach (string raw in Strings(cmd, "steamIds")) + { + ulong id; + + if (!seen.Add(raw)) + continue; + + if (TryParseSteamId(raw, out id)) + ids.Add(raw); + else + payment.Failed.Add(new EconomicsFailed { SteamId = raw, Reason = "bad-id" }); + } + + if (seen.Count > MaxRecipients()) + { + EmitEconomicsError(reqId, "too-large", $"this server pays at most {MaxRecipients()} players in one step, not {seen.Count}", null); + return; + } + + foreach (string id in ids) + { + double? before = ReadBalance(eco, id); + object ok = eco.Call("Deposit", id, amount); + double? after = ReadBalance(eco, id); + + if (!(ok is bool) || !(bool)ok) + { + payment.Failed.Add(new EconomicsFailed { SteamId = id, Reason = "refused" }); + continue; + } + + if (!before.HasValue || !after.HasValue) + { + // Deposited, but not measured. Listed as failed so nobody is told an amount, + // and remembered with the key so a retry still pays nothing more. + payment.Failed.Add(new EconomicsFailed { SteamId = id, Reason = "unmeasured" }); + continue; + } + + if (after.Value - before.Value < EconomicsMovedMin) + { + payment.Failed.Add(new EconomicsFailed { SteamId = id, Reason = "at-limit" }); + continue; + } + + var paid = new EconomicsPaid { SteamId = id, Before = before.Value, After = after.Value, Told = payment.Message.Length == 0 }; + payment.Paid.Add(paid); + + BasePlayer awake = AwakePlayer(id); + + if (awake != null && !paid.Told) + TellPaid(awake, payment, paid); + } + + _payments[PaymentSlot(EconomicsPay, key)] = payment; + SavePayments(); + + _lastRewardSummary = $"paid {Coins(amount)} coins to {payment.Paid.Count} player(s), {payment.Failed.Count} failed"; + EmitPaid(payment, reqId, false); + } + + /// Main thread. Of the given players, each one's balance, for the account and admin pages. + private void HandleEconomicsBalances(Dictionary cmd, string reqId) + { + Plugin eco = EconomicsPlugin(); + + if (eco == null) + { + EmitEconomicsError(reqId, "economics-missing", "Economics is not installed on this server", null); + return; + } + + var ids = new List(); + var seen = new HashSet(StringComparer.Ordinal); + + foreach (string raw in Strings(cmd, "steamIds")) + { + ulong id; + + if (TryParseSteamId(raw, out id) && seen.Add(raw)) + ids.Add(raw); + } + + if (ids.Count > PermMaxOps) + { + EmitEconomicsError(reqId, "too-large", $"at most {PermMaxOps} players in one question, not {ids.Count}", null); + return; + } + + var rows = new List(); + + foreach (string id in ids) + { + double? balance = ReadBalance(eco, id); + + if (balance.HasValue) + rows.Add(new Dictionary { { "steamId", id }, { "balance", balance.Value } }); + } + + Dictionary reply = Frame("economics.balances", "reply"); + reply["balances"] = rows; + + // A balance at the starting value may be an account Economics has never seen (§11.2.1). + EconomicsSettings settings = EconomicsConfig(); + + if (settings != null && settings.StartingBalance.HasValue) + reply["startingBalance"] = settings.StartingBalance.Value; + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + /// + /// Main thread. Staff: deposit, withdraw or set one player's balance. A withdrawal above the + /// balance is refused with the balance, because Economics takes all or nothing. + /// + private void HandleEconomicsAdjust(Dictionary cmd, string reqId) + { + Plugin eco = EconomicsPlugin(); + + if (eco == null) + { + EmitEconomicsError(reqId, "economics-missing", "Economics is not installed on this server", null); + return; + } + + string key = StringField(cmd, "key"); + + if (string.IsNullOrEmpty(key) || key.Length > PaymentKeyMax) + { + EmitEconomicsError(reqId, "malformed", "an adjustment has a key", null); + return; + } + + string op = StringField(cmd, "op"); + string method = op == EconomicsDeposit ? "Deposit" : op == EconomicsWithdraw ? "Withdraw" : op == EconomicsSet ? "SetBalance" : null; + + if (method == null) + { + EmitEconomicsError(reqId, "malformed", "an adjustment deposits, withdraws or sets", null); + return; + } + + ForgetOldPayments(); + + EconomicsPayment done; + + if (_payments.TryGetValue(PaymentSlot(op, key), out done)) + { + EmitAdjusted(done, reqId, true); + return; + } + + string steamId = StringField(cmd, "steamId"); + ulong parsed; + + if (!TryParseSteamId(steamId, out parsed)) + { + EmitEconomicsError(reqId, "malformed", "an adjustment names one player by Steam id", null); + return; + } + + double amount = DoubleField(cmd, "amount") ?? double.NaN; + + if (!AmountIn(amount, op == EconomicsSet ? 0d : EconomicsAmountMin)) + { + EmitEconomicsError(reqId, "bad-amount", + $"an adjustment is {(op == EconomicsSet ? "0" : Coins(EconomicsAmountMin))} to {Coins(EconomicsAmountMax)} coins", null); + return; + } + + double? before = ReadBalance(eco, steamId); + object ok = eco.Call(method, steamId, amount); + double? after = ReadBalance(eco, steamId); + + if (!(ok is bool) || !(bool)ok) + { + if (op == EconomicsWithdraw && before.HasValue && amount > before.Value) + EmitEconomicsError(reqId, "insufficient", $"this player has only {Coins(before.Value)} coins", before); + else + EmitEconomicsError(reqId, "refused", $"Economics refused to {op} {Coins(amount)} coins", before); + + return; + } + + if (!before.HasValue || !after.HasValue) + { + EmitEconomicsError(reqId, "refused", "Economics did not report this player's balance", null); + return; + } + + if (op == EconomicsDeposit && after.Value - before.Value < EconomicsMovedMin) + { + EmitEconomicsError(reqId, "at-limit", "this player's balance is at Economics' limit", before); + return; + } + + var adjustment = new EconomicsPayment + { + Key = key, + Op = op, + AtMs = NowMs(), + Amount = amount + }; + + adjustment.Paid.Add(new EconomicsPaid { SteamId = steamId, Before = before.Value, After = after.Value, Told = true }); + + _payments[PaymentSlot(op, key)] = adjustment; + SavePayments(); + + _lastRewardSummary = $"{op} {Coins(amount)} coins for {steamId}: {Coins(before.Value)} → {Coins(after.Value)}"; + EmitAdjusted(adjustment, reqId, false); + } + + private void EmitPaid(EconomicsPayment payment, string reqId, bool repeat) + { + var paid = new List(); + var failed = new List(); + + foreach (EconomicsPaid row in payment.Paid) + paid.Add(new Dictionary { { "steamId", row.SteamId }, { "before", row.Before }, { "after", row.After } }); + + foreach (EconomicsFailed row in payment.Failed) + failed.Add(new Dictionary { { "steamId", row.SteamId }, { "reason", row.Reason } }); + + Dictionary reply = Frame("economics.paid", "reply"); + reply["key"] = payment.Key; + reply["repeat"] = repeat; + reply["amount"] = payment.Amount; + reply["paid"] = paid; + reply["failed"] = failed; + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + private void EmitAdjusted(EconomicsPayment adjustment, string reqId, bool repeat) + { + EconomicsPaid row = adjustment.Paid.Count > 0 ? adjustment.Paid[0] : null; + + Dictionary reply = Frame("economics.adjusted", "reply"); + reply["key"] = adjustment.Key; + reply["repeat"] = repeat; + reply["op"] = adjustment.Op; + reply["amount"] = adjustment.Amount; + + if (row != null) + { + reply["steamId"] = row.SteamId; + reply["before"] = row.Before; + reply["after"] = row.After; + } + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + private void EmitEconomicsError(string reqId, string reason, string message, double? balance) + { + Dictionary frame = Frame("economics.error", "reply"); + frame["reason"] = reason; + frame["message"] = message; + + if (balance.HasValue) + frame["balance"] = balance.Value; + + if (reqId != null) + frame["reqId"] = reqId; + + _lastRewardSummary = $"refused ({reason}): {message}"; + EmitFrame(frame); + } + + /// Main thread. Says a payment's message to one player, with what they received, and marks it told. + private void TellPaid(BasePlayer player, EconomicsPayment payment, EconomicsPaid row) + { + string line = payment.Message.Replace("{amount}", Coins(row.After - row.Before)); + + if (line.Length > MaxChat()) + line = line.Substring(0, MaxChat()); + + SayTo(player, line, payment.Delivery); + row.Told = true; + } + + /// Main thread, as a player wakes. Every payment they have not been told of yet. + private void TellPayments(BasePlayer player) + { + if (_payments.Count == 0) + return; + + string steamId = SteamIdOf(player); + bool changed = false; + + foreach (EconomicsPayment payment in _payments.Values) + { + if (payment.Op != EconomicsPay || string.IsNullOrEmpty(payment.Message)) + continue; + + foreach (EconomicsPaid row in payment.Paid) + { + if (row.SteamId == steamId && !row.Told) + { + TellPaid(player, payment, row); + changed = true; + } + } + } + + if (changed) + SavePayments(); + } + + /// Forgets payments seven days old, with any message nobody woke up to hear. True when any went. + private bool ForgetOldPayments() + { + long now = NowMs(); + var stale = new List(); + + foreach (KeyValuePair kv in _payments) + { + if (now >= kv.Value.AtMs + PaymentMemoryMs) + stale.Add(kv.Key); + } + + foreach (string slot in stale) + _payments.Remove(slot); + + if (stale.Count > 0) + SavePayments(); + + return stale.Count > 0; + } + + private string PaymentsPath() + { + return Path.Combine(Interface.Oxide.DataDirectory, Name, "payments.json"); + } + + private void SavePayments() + { + WriteDataFile(PaymentsPath(), new List(_payments.Values), "Economics payments"); + } + + private void LoadPayments() + { + _payments = new Dictionary(StringComparer.Ordinal); + List payments = ReadDataFile(PaymentsPath(), "Economics payments"); + + if (payments == null) + return; + + foreach (EconomicsPayment p in payments) + { + if (p == null || string.IsNullOrEmpty(p.Key) || string.IsNullOrEmpty(p.Op)) + continue; + + if (p.Paid == null) + p.Paid = new List(); + + if (p.Failed == null) + p.Failed = new List(); + + if (p.Message == null) + p.Message = ""; + + _payments[PaymentSlot(p.Op, p.Key)] = p; + } + + ForgetOldPayments(); + } + + // ── Backpacks (protocol 14, PROTOCOL.md §20.4; the module's PLAN_REDESIGNS.md §11.3) ──── + // + // WhiteThunder's Backpacks, optional like the other mods (D199). **Read, never write (R2):** + // `backpacks.read` is the only verb, and nothing here calls a Backpacks method that changes + // a backpack or a capacity. The extra space an event gives is a `backpacks.size.N` + // permission the site leases through `perm.sync` like a kit reward (D337), so it needs no + // code here at all. What the spike found (§11.3.1) shapes the read: + // + // * `API_ReadBackpackContentsAsJson` answers Backpacks' own item data — item ids, slots + // that run across pages, nested contents — and null for a player who never had one; + // * a capacity read while the backpack is not loaded is cached by Backpacks and pins it + // until the player reconnects, so the capacity is read only after the contents read + // has loaded the backpack. + + private const string BackpacksSizePrefix = "backpacks.size."; + + /// Backpacks' own bound on a page, and its default "Max size per page". + private const int BackpacksPageMax = 48; + + private int _backpacksPageSize = BackpacksPageMax; + private DateTime _backpacksConfigAt; + + private Plugin BackpacksPlugin() + { + return plugins.Find("Backpacks"); + } + + private bool IsBackpacks(Plugin plugin) + { + return plugin != null && plugin != this && plugin.Name == "Backpacks"; + } + + /// + /// Hello's integrations.backpacks: loaded and version like every optional mod, and + /// sizes, the N of every registered backpacks.size.N, ascending. They are + /// the operator's own Permission sizes, and what the site may lease (D337). + /// + private Dictionary BackpacksBody() + { + Plugin backpacks = BackpacksPlugin(); + Dictionary body = IntegrationBody(backpacks); + + if (backpacks != null) + body["sizes"] = BackpackSizes(); + + return body; + } + + private List BackpackSizes() + { + var sizes = new SortedSet(); + + foreach (string name in permission.GetPermissions() ?? new string[0]) + { + if (name == null || !name.StartsWith(BackpacksSizePrefix, StringComparison.OrdinalIgnoreCase)) + continue; + + int n; + + // `backpacks.size.profile.6-48` is a dynamic-size profile, not a size. + if (int.TryParse(name.Substring(BackpacksSizePrefix.Length), System.Globalization.NumberStyles.None, + System.Globalization.CultureInfo.InvariantCulture, out n) && n > 0) + sizes.Add(n); + } + + return new List(sizes); + } + + /// + /// Backpacks' "Max size per page" from its config, read again only when the file + /// changes; 48, its default, when the file does not say. The directory comes from the + /// framework, as Economics' does (R19). + /// + private int BackpacksPageSize() + { + string path = Path.Combine(Interface.Oxide.ConfigDirectory, "Backpacks.json"); + DateTime at; + + try + { + if (!File.Exists(path)) + return BackpacksPageMax; + + at = File.GetLastWriteTimeUtc(path); + } + catch (Exception) + { + return BackpacksPageMax; + } + + if (at == _backpacksConfigAt) + return _backpacksPageSize; + + int size = BackpacksPageMax; + + try + { + var config = Newtonsoft.Json.Linq.JObject.Parse(File.ReadAllText(path)); + var section = config["Backpack size"] as Newtonsoft.Json.Linq.JObject; + Newtonsoft.Json.Linq.JToken page = section != null ? section["Max size per page"] : null; + + // Clamped as Backpacks clamps it. + if (page != null && page.Type == Newtonsoft.Json.Linq.JTokenType.Integer) + size = Math.Max(1, Math.Min(BackpacksPageMax, (int)page)); + } + catch (Exception ex) + { + Puts($"could not read Backpacks' config: {Flatten(ex.Message)}"); + } + + _backpacksPageSize = size; + _backpacksConfigAt = at; + return size; + } + + /// + /// Main thread. One player's backpack, for a staff view or the player's own page. Live: + /// nothing is kept here, and the site stores nothing either. + /// + private void HandleBackpacksRead(Dictionary cmd, string reqId) + { + Plugin backpacks = BackpacksPlugin(); + + if (backpacks == null) + { + EmitBackpacksError(reqId, "backpacks-missing", "Backpacks is not installed on this server"); + return; + } + + string raw = StringField(cmd, "steamId"); + ulong id; + + if (raw == null || !TryParseSteamId(raw, out id)) + { + EmitBackpacksError(reqId, "bad-id", $"'{Flatten(raw ?? "")}' is not a Steam id"); + return; + } + + Dictionary reply = Frame("backpacks.contents", "reply"); + reply["steamId"] = raw; + + // Loads an offline player's backpack from disk; null when they have never had one. + string json = backpacks.Call("API_ReadBackpackContentsAsJson", id) as string; + + if (json == null) + { + reply["exists"] = false; + } + else + { + List> items; + + try + { + items = BackpackStacks(Newtonsoft.Json.Linq.JArray.Parse(json), true); + } + catch (Exception ex) + { + EmitBackpacksError(reqId, "unreadable", $"Backpacks answered contents this bridge could not read: {Flatten(ex.Message)}"); + return; + } + + // Only now, through the backpack the read just loaded, so the capacity follows a + // size granted later (§11.3.1's trap). + int capacity = CallInt(backpacks, "API_GetBackpackCapacityById", id, raw); + + // Past the capacity is what waits for the owner's next open to be moved (D338). + foreach (Dictionary stack in items) + { + if ((int)stack["slot"] >= capacity) + stack["overflow"] = true; + } + + reply["exists"] = true; + reply["capacity"] = capacity; + reply["pageSize"] = BackpacksPageSize(); + reply["items"] = items; + } + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + /// + /// Backpacks' item data reduced to what a grid shows, by slot. + /// keeps one level of contents — a weapon's attachments, a container's items — + /// and no deeper. + /// + private static List> BackpackStacks(Newtonsoft.Json.Linq.JArray data, bool nest) + { + var stacks = new List>(); + + foreach (Newtonsoft.Json.Linq.JToken token in data) + { + var item = token as Newtonsoft.Json.Linq.JObject; + + if (item != null) + stacks.Add(BackpackStack(item, nest)); + } + + stacks.Sort((a, b) => ((int)a["slot"]).CompareTo((int)b["slot"])); + return stacks; + } + + /// + /// One stack: slot, shortname, the game's English name (D340, so the site needs no item + /// catalogue), amount, and skin and name when set. An id no item definition knows is sent + /// with a null shortname and its itemId. A blueprint names what it is for (D341): + /// blueprint is the target's shortname and display the target's name. + /// + private static Dictionary BackpackStack(Newtonsoft.Json.Linq.JObject item, bool nest) + { + int itemId = JsonInt(item["ID"]); + ItemDefinition def = ItemManager.FindItemDefinition(itemId); + + var stack = new Dictionary + { + { "slot", JsonInt(item["Position"]) }, + { "shortname", def != null ? def.shortname : null }, + { "amount", JsonInt(item["Amount"]) } + }; + + if (def == null) + stack["itemId"] = itemId; + else + stack["display"] = def.displayName != null ? def.displayName.english : def.shortname; + + Newtonsoft.Json.Linq.JToken blueprint = item["IsBlueprint"]; + + if (blueprint != null && blueprint.Type == Newtonsoft.Json.Linq.JTokenType.Boolean && (bool)blueprint) + { + ItemDefinition target = ItemManager.FindItemDefinition(JsonInt(item["BlueprintTarget"])); + + if (target != null) + { + stack["blueprint"] = target.shortname; + stack["display"] = target.displayName != null ? target.displayName.english : target.shortname; + } + } + + Newtonsoft.Json.Linq.JToken skin = item["Skin"]; + + if (skin != null && skin.Type == Newtonsoft.Json.Linq.JTokenType.Integer && (ulong)skin != 0UL) + stack["skin"] = (ulong)skin; + + Newtonsoft.Json.Linq.JToken name = item["Name"]; + + if (name != null && name.Type == Newtonsoft.Json.Linq.JTokenType.String && ((string)name).Length > 0) + stack["name"] = (string)name; + + var contents = item["Contents"] as Newtonsoft.Json.Linq.JArray; + + if (nest && contents != null && contents.Count > 0) + stack["contents"] = BackpackStacks(contents, false); + + return stack; + } + + private static int JsonInt(Newtonsoft.Json.Linq.JToken token) + { + return token != null && token.Type == Newtonsoft.Json.Linq.JTokenType.Integer ? (int)token : 0; + } + + private void EmitBackpacksError(string reqId, string reason, string message) + { + Dictionary frame = Frame("backpacks.error", "reply"); + frame["reason"] = reason; + frame["message"] = message; + + if (reqId != null) + frame["reqId"] = reqId; + + EmitFrame(frame); + } + // ── Chat ────────────────────────────────────────────────────────────────── /// @@ -12003,7 +16813,21 @@ namespace Oxide.Plugins { "betterChat", IntegrationBody(BetterChatPlugin()) }, { "popupNotifications", IntegrationBody(PopupPlugin()) }, { "zoneDomes", ZoneDomesBody() }, - { "runicNpc", RunicNpcBody() } + { "runicNpc", RunicNpcBody() }, + { "economics", EconomicsBody() }, + { "backpacks", BackpacksBody() }, + // Protocol 14 (§14.3.2): whether a zone step may change PvP rules here. + { "truePve", IntegrationBody(TruePvePlugin()) }, + // Protocol 14 (§14.3.3): whether the map's pvp layer and the feed's zone lines can come. + { "dynamicPvp", IntegrationBody(DynamicPvpPlugin()) }, + // Protocol 14 (§14.3.4): whether a step may open a Dangerous Treasures event here. + { "dangerousTreasures", IntegrationBody(DangerousTreasuresPlugin()) }, + // Protocol 14 (§14.3.5): whether a step may place a guarded crate here, and which. + { "guardedCrate", GuardedCrateBody() }, + // Protocol 14 (§14.3.6): whether its XP boosts are here for a step to lend. + { "zLevels", IntegrationBody(ZLevelsPlugin()) }, + // Protocol 14 (§20.12): its edition, its difficulties, the raids helper and its profiles. + { "raidableBases", RaidableBasesBody() } }; } @@ -13042,6 +17866,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) @@ -13055,6 +17882,8 @@ namespace Oxide.Plugins if (!_mapWorld.Remove(e)) _mapBases.Remove(e); + + WorldEventKilled(entity); } /// @@ -13259,6 +18088,10 @@ namespace Oxide.Plugins { Dictionary row; + // A run's raid base is drawn on the raids layer, with its run (§20.12). + if (w.Kind == WorldKindRaid) + continue; + if (w.Kind == WorldKindZone) { row = At(new UnityEngine.Vector3(w.X, 0f, w.Z)); @@ -13397,12 +18230,22 @@ namespace Oxide.Plugins _mapLiveLastMs = clock.ElapsedMilliseconds; _mapLiveMaxMs = Math.Max(_mapLiveMaxMs, _mapLiveLastMs); + // Dangerous Treasures' running chests (§14.3.4): the world's, like a locked crate. + AddTreasureRows(world); + + // Guarded Crate's running events (§14.3.5), likewise. + AddGuardedRows(world); + Dictionary reply = Frame("map.live", "reply"); reply["mapKey"] = MapKey(); reply["world"] = world; reply["events"] = events; reply["players"] = players; reply["bases"] = bases; + reply["pvp"] = PvpZoneRows(); + + // RaidableBases' live bases (§20.12): a layer of their own with its own audience (D114). + reply["raids"] = RaidRows(); reply["tookMs"] = clock.ElapsedMilliseconds; if (sleepersTruncated) @@ -13549,6 +18392,1458 @@ 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; + + // Guarded Crate delivers its crate by cargo plane: that is its event, said as + // `gc.started`, not an airdrop (§14.3.5). It has registered the plane by now. + Plugin gc = GuardedCratePlugin(); + + object ours = gc != null ? gc.Call("API_IsGuardedCrateCargoPlane", plane as CargoPlane) : null; + + if (ours is bool && (bool)ours) + 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; + } + } + + // The game runs one Travelling Vendor at a time, and its event quietly starts nothing + // while one is out (W3). Said, not counted, and not kept: a retry looks again. + if (ev == "travelling_vendor" && TravellingVendorOut()) + { + EmitWorldError(reqId, "already-out", "a Travelling Vendor is already out, and the game runs one at a time; nothing was called"); + 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); + } + + /// Main thread. Whether a Travelling Vendor is on the map now. + private static bool TravellingVendorOut() + { + foreach (TravellingVendor v in UnityEngine.Object.FindObjectsOfType()) + if (v != null && !v.IsDestroyed) + return true; + + return false; + } + + /// 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 ---- /// @@ -13670,5 +19965,663 @@ namespace Oxide.Plugins $"fired: {firedText}" + Environment.NewLine + $"silent: {silentText}"); } + + // ---- console commands an admin typed (PROTOCOL.md §20.11, PLAN_REDESIGNS §16, D382-D384) ---- + // + // `command.run` runs the commands a step's author typed, as the server console would: once + // (a *Run console commands* step and its teardown), or once per recipient who is online with + // `{steamid}` and `{name}` filled in (a *Reward by console command*). There is no allowlist + // (D384); the site's switchboard is the switch, as it is for every world write. + // + // **At most once, by the key.** A command cannot be asked whether it already ran, so the key + // is written to `commands-run.json` BEFORE anything runs. A repeat answers from that record + // and runs nothing; a repeat while the first is still running waits for it and gets its + // answer. A record left running by a restart or a reload is answered `interrupted`: some of + // its commands may have run, and none is run again. + // + // **The teardown is kept with the key (D385).** A step's teardown commands come with its + // run and are stored, not run. The site's ledger keeps its own copy and sends it back at + // teardown; but core records nothing for a step that FAILED, or whose answer was lost, and + // then asks with only the key. So `{ key, teardownOf }` runs the teardown stored under + // `teardownOf`, when anything there may have run, under its own key, once. + // + // **Spread over frames.** A full reward is 100 recipients by 10 commands. A recipient's + // commands run together in one frame, and a frame stops taking recipients past + // `CommandsPerFrame` commands or `CommandFrameMs`, so the game never stalls on a step. + // + // **`unknown-command`.** `ConsoleSystem.Run` answers null both for a command it does not + // know and for one that replies nothing. The game's own "Command '...' not found" line is + // logged synchronously inside the call on Oxide and on Carbon (spike, 2026-10-16), so the + // run listens to the log while it runs, and stops at the first such line, before running + // the command for anyone else. What the command logged while it ran is its reply when + // `Run` returns nothing. + + /// At most this many commands in one request (§16.2). + private const int CommandsMax = 10; + + /// The longest command, in characters (§16.2). + private const int CommandLengthMax = 512; + + /// The most recipients one reward names (the kit reward's bound, §16.3). + private const int CommandRecipientsMax = 100; + + /// A console reply is trimmed to this many characters (§16.6). + private const int CommandReplyMax = 200; + + /// At most this many replies go back with an answer and stay in its record. + private const int CommandRepliesMax = 20; + + /// At most this many skipped names go back; the rest are counted. + private const int CommandSkippedMax = 50; + + /// How long a key is remembered: seven days, as a payment's is (§16.4). + private const long CommandMemoryMs = 7L * 24L * 60L * 60L * 1000L; + + /// The most records kept, oldest forgotten first, so the file stays small. + private const int CommandRecordsMax = 500; + + /// A frame takes no more recipients once it has run this many commands… + private const int CommandsPerFrame = 30; + + /// …or spent this long on them. + private const double CommandFrameMs = 8.0; + + private static readonly System.Text.RegularExpressions.Regex SteamIdPlaceholder = + new System.Text.RegularExpressions.Regex(@"\{steamid\}", System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.CultureInvariant); + + private static readonly System.Text.RegularExpressions.Regex NamePlaceholder = + new System.Text.RegularExpressions.Regex(@"\{name\}", System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.CultureInvariant); + + /// One command that ran, and what the console said. Persisted with its record. + private sealed class CommandReply + { + public string Command; + public string SteamId; + public string Reply; + } + + /// One key's run: written before anything runs, and again when it is done. + private sealed class CommandRecord + { + public string Key; + public long AtMs; + public bool Done; + /// Null when the run finished; a reason (`unknown-command`) when it stopped. + public string Reason; + public string Message; + public int Ran; + public int Rewarded; + public List Replies = new List(); + public List Skipped = new List(); + public int SkippedCount; + /// The step's teardown commands, kept for teardownOf; never run with the record (D385). + public List Teardown = new List(); + } + + /// One recipient a reward names: the Steam id, and the name the site knows. + private sealed class CommandRecipient + { + public ulong Id; + public string Name; + } + + /// A run in progress. Main thread only; never persisted (its record is). + private sealed class CommandJob + { + public CommandRecord Record; + public List Commands; + /// Null for a run without recipients: the commands run once. + public List Recipients; + public int Next; + public readonly List Waiting = new List(); + } + + private Dictionary _commandRecords = new Dictionary(StringComparer.Ordinal); + + private readonly Dictionary _commandJobs = new Dictionary(StringComparer.Ordinal); + + private string CommandRecordsPath() + { + return Path.Combine(Interface.Oxide.DataDirectory, Name, "commands-run.json"); + } + + private void SaveCommandRecords() + { + WriteDataFile(CommandRecordsPath(), new List(_commandRecords.Values), "console command keys"); + } + + /// Main thread, once per load. A record still running belongs to a load that is gone. + private void LoadCommandRecords() + { + _commandRecords = new Dictionary(StringComparer.Ordinal); + List records = ReadDataFile(CommandRecordsPath(), "console command keys"); + + if (records != null) + foreach (CommandRecord r in records) + if (r != null && !string.IsNullOrEmpty(r.Key)) + _commandRecords[r.Key] = r; + + ForgetOldCommandRecords(); + } + + private void ForgetOldCommandRecords() + { + long now = NowMs(); + var stale = new List(); + + foreach (KeyValuePair kv in _commandRecords) + if (now >= kv.Value.AtMs + CommandMemoryMs && !_commandJobs.ContainsKey(kv.Key)) + stale.Add(kv.Key); + + if (_commandRecords.Count - stale.Count > CommandRecordsMax) + { + var oldest = new List(_commandRecords.Values); + oldest.Sort((a, b) => a.AtMs.CompareTo(b.AtMs)); + + foreach (CommandRecord r in oldest) + { + if (_commandRecords.Count - stale.Count <= CommandRecordsMax) + break; + + if (!stale.Contains(r.Key) && !_commandJobs.ContainsKey(r.Key)) + stale.Add(r.Key); + } + } + + foreach (string key in stale) + _commandRecords.Remove(key); + + if (stale.Count > 0) + SaveCommandRecords(); + } + + /// + /// Main thread. { key, commands, recipients? }. Checks everything before the key + /// is recorded, so a refusal for a malformed request is not remembered as a run. + /// + private void HandleCommandRun(Dictionary cmd, string reqId) + { + if (!WorldWritesAllowed(reqId)) + return; + + string key = StringField(cmd, "key"); + + if (string.IsNullOrEmpty(key) || key.Length > PaymentKeyMax) + { + EmitCommandError(reqId, "malformed", "commands are run under a key of at most 128 characters", null); + return; + } + + CommandJob running; + + if (_commandJobs.TryGetValue(key, out running)) + { + // The site retried while the first is still spreading over frames: one answer each. + if (reqId != null) + running.Waiting.Add(reqId); + + return; + } + + ForgetOldCommandRecords(); + + CommandRecord seen; + + if (_commandRecords.TryGetValue(key, out seen)) + { + if (!seen.Done) + { + EmitCommandError(reqId, "interrupted", + "these commands began before the plugin last reloaded or the server restarted, so some of them may have run; none is run again", seen); + return; + } + + EmitCommandAnswer(reqId, seen, true); + return; + } + + string teardownOf = StringField(cmd, "teardownOf"); + + if (!string.IsNullOrEmpty(teardownOf)) + { + StartTeardownOf(key, teardownOf, reqId); + return; + } + + string refusal = null; + List commands = CommandLines(cmd, "commands", ref refusal); + List teardown = CommandLines(cmd, "teardown", ref refusal); + + if (refusal == null && commands.Count == 0) + refusal = "there are no commands to run"; + + List recipients = null; + + if (refusal == null && cmd.ContainsKey("recipients")) + { + recipients = new List(); + var ids = new HashSet(); + + foreach (Dictionary row in Rows(cmd, "recipients")) + { + ulong id; + + if (!ulong.TryParse(StringField(row, "steamId"), System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out id) || id == 0UL) + { + refusal = $"'{StringField(row, "steamId")}' is not a Steam id"; + break; + } + + if (ids.Add(id)) + recipients.Add(new CommandRecipient { Id = id, Name = StringField(row, "name") }); + } + + if (refusal == null && recipients.Count > CommandRecipientsMax) + refusal = $"a reward runs for at most {CommandRecipientsMax} people at a time, not {recipients.Count}"; + + if (refusal == null && teardown.Count > 0) + refusal = "a reward has no teardown: nothing it runs is undone"; + } + + if (refusal == null) + { + // Nobody to fill the placeholders with: refused rather than sent with the braces in. + var unfilled = new List(teardown); + + if (recipients == null) + unfilled.AddRange(commands); + + foreach (string line in unfilled) + { + if (SteamIdPlaceholder.IsMatch(line) || NamePlaceholder.IsMatch(line)) + { + refusal = "{steamid} and {name} are filled in only when commands are run for recipients"; + break; + } + } + } + + if (refusal != null) + { + EmitCommandError(reqId, "malformed", refusal, null); + return; + } + + // The key is on disk before the first command runs (§16.4), and the teardown with it. + var record = new CommandRecord { Key = key, AtMs = NowMs(), Teardown = teardown }; + _commandRecords[key] = record; + SaveCommandRecords(); + + var job = new CommandJob { Record = record, Commands = commands, Recipients = recipients }; + + if (reqId != null) + job.Waiting.Add(reqId); + + _commandJobs[key] = job; + StepCommandJob(job); + } + + /// + /// The command lines in one array field, trimmed, blanks dropped. A refusal is set on the + /// first line too long or with a control character in it, or on more than + /// ; an earlier refusal is kept. + /// + private static List CommandLines(Dictionary cmd, string field, ref string refusal) + { + var lines = new List(); + + foreach (string raw in Strings(cmd, field)) + { + string line = (raw ?? string.Empty).Trim(); + + if (line.Length == 0) + continue; + + if (refusal == null && line.Length > CommandLengthMax) + refusal = $"a command is at most {CommandLengthMax} characters, and one is {line.Length}"; + else if (refusal == null && HasControl(line)) + refusal = "a command is one line, with no line breaks or control characters in it"; + + lines.Add(line); + } + + if (refusal == null && lines.Count > CommandsMax) + refusal = $"at most {CommandsMax} {field} commands are run at a time, not {lines.Count}"; + + return lines; + } + + /// + /// Main thread. D385: run the teardown stored with , under + /// , when anything there may have run — some of its commands ran, + /// or a reload cut it off. Nothing stored, or nothing ran: answered with nothing run and + /// nothing recorded, which is a server this step never reached. + /// + private void StartTeardownOf(string key, string of, string reqId) + { + if (_commandJobs.ContainsKey(of)) + { + EmitCommandError(reqId, "busy", "the commands this teardown is for are still running; ask again in a moment", null); + return; + } + + CommandRecord first; + bool due = _commandRecords.TryGetValue(of, out first) + && first.Teardown != null && first.Teardown.Count > 0 + && (first.Ran > 0 || !first.Done); + + if (!due) + { + EmitCommandAnswer(reqId, new CommandRecord { Key = key, Done = true }, false); + return; + } + + var record = new CommandRecord { Key = key, AtMs = NowMs() }; + _commandRecords[key] = record; + SaveCommandRecords(); + + var job = new CommandJob { Record = record, Commands = new List(first.Teardown) }; + + if (reqId != null) + job.Waiting.Add(reqId); + + _commandJobs[key] = job; + StepCommandJob(job); + } + + private static bool HasControl(string text) + { + foreach (char c in text) + if (char.IsControl(c)) + return true; + + return false; + } + + /// + /// Main thread. One frame's share of a job: whole recipients, until the frame has run + /// commands or spent . The rest + /// wait for the next frame. + /// + private void StepCommandJob(CommandJob job) + { + if (!_commandJobs.ContainsKey(job.Record.Key)) + return; + + var clock = System.Diagnostics.Stopwatch.StartNew(); + int ranThisFrame = 0; + + try + { + if (job.Recipients == null) + { + // One run, without recipients: every command, now. + if (!RunCommandSet(job, job.Commands, null)) + return; + + FinishCommandJob(job); + return; + } + + while (job.Next < job.Recipients.Count) + { + if (ranThisFrame >= CommandsPerFrame || clock.Elapsed.TotalMilliseconds >= CommandFrameMs) + { + NextTick(() => StepCommandJob(job)); + return; + } + + CommandRecipient who = job.Recipients[job.Next++]; + BasePlayer player = OnlinePlayer(who.Id); + + if (player == null) + { + // D383: skipped and named, never held until they join. + string name = string.IsNullOrEmpty(who.Name) ? who.Id.ToString(System.Globalization.CultureInfo.InvariantCulture) : who.Name; + + if (job.Record.Skipped.Count < CommandSkippedMax) + job.Record.Skipped.Add(name); + + job.Record.SkippedCount++; + continue; + } + + var filled = new List(job.Commands.Count); + + foreach (string line in job.Commands) + filled.Add(FillPlaceholders(line, player)); + + if (!RunCommandSet(job, filled, player)) + return; + + job.Record.Rewarded++; + ranThisFrame += filled.Count; + } + + FinishCommandJob(job); + } + catch (Exception ex) + { + StopCommandJob(job, "failed", $"the commands stopped: {Flatten(ex.Message)}"); + } + } + + /// A connected player by Steam id, or null: a sleeper is not online. + private static BasePlayer OnlinePlayer(ulong id) + { + foreach (BasePlayer p in BasePlayer.activePlayerList) + if (p != null && (ulong)p.userID == id && p.IsConnected) + return p; + + return null; + } + + /// + /// {steamid} as digits; {name} as ONE quoted argument, the way the game's own + /// BuildCommand quotes one, so spaces, quotes and semicolons in a name stay inside it + /// (spike, 2026-10-16). Control characters are dropped first. + /// + private static string FillPlaceholders(string line, BasePlayer player) + { + string id = ((ulong)player.userID).ToString(System.Globalization.CultureInfo.InvariantCulture); + var clean = new StringBuilder(); + + foreach (char c in player.displayName ?? string.Empty) + if (!char.IsControl(c)) + clean.Append(c); + + string quoted = clean.ToString().QuoteSafe(); + string filled = SteamIdPlaceholder.Replace(line, m => id); + return NamePlaceholder.Replace(filled, m => quoted); + } + + /// + /// Main thread. One set of commands, in order. False when the job stopped: a command the + /// server does not know ends it there, before anyone else's turn. + /// + private bool RunCommandSet(CommandJob job, List lines, BasePlayer player) + { + foreach (string line in lines) + { + bool notFound; + string reply = RunConsoleLine(line, out notFound); + + if (notFound) + { + string verb = line.Split(' ')[0]; + string why = $"the server does not know the command '{verb}'"; + + if (!_consolePlugins) + why += "; this framework does not run a plugin's console command from code, so a plugin's command is not found here (D363)"; + else + why += ", or the plugin that adds it is not loaded"; + + StopCommandJob(job, "unknown-command", why); + return false; + } + + job.Record.Ran++; + + if (job.Record.Replies.Count < CommandRepliesMax) + { + job.Record.Replies.Add(new CommandReply + { + Command = line, + SteamId = player != null ? ((ulong)player.userID).ToString(System.Globalization.CultureInfo.InvariantCulture) : null, + Reply = reply, + }); + } + } + + return true; + } + + /// + /// Main thread. One line, as the server console runs it, and what it answered: its reply, + /// or what it logged while it ran when it replies nothing. when + /// the game logged that it does not know the command. + /// + private string RunConsoleLine(string line, out bool notFound) + { + string unknown = "Command '" + line + "' not found"; + string verb = line.Split(' ')[0]; + var heard = new List(); + bool missing = false; + + UnityEngine.Application.LogCallback listen = (message, trace, type) => + { + if (message == null) + return; + + // Exactly the line, as the game prints it; or its first word, should a framework + // print only that. A command the line's own command runs is not this line. + if (message == unknown || message == "Command '" + verb + "' not found") + { + missing = true; + return; + } + + if (heard.Count < 8) + heard.Add(message); + }; + + string reply; + UnityEngine.Application.logMessageReceived += listen; + + try + { + // Not Quiet: the operator's console shows each command and its answer, as typed. + reply = ConsoleSystem.Run(ConsoleSystem.Option.Server, line); + } + catch (Exception ex) + { + reply = "threw: " + ex.Message; + } + finally + { + UnityEngine.Application.logMessageReceived -= listen; + } + + notFound = missing; + + if (string.IsNullOrEmpty(reply) && heard.Count > 0) + reply = string.Join(" | ", heard.ToArray()); + + reply = Flatten(reply ?? string.Empty); + return reply.Length > CommandReplyMax ? reply.Substring(0, CommandReplyMax) : reply; + } + + private void FinishCommandJob(CommandJob job) + { + CommandRecord r = job.Record; + r.Done = true; + _commandJobs.Remove(r.Key); + SaveCommandRecords(); + + _lastWorldSummary = job.Recipients == null + ? $"ran {r.Ran} console command(s) for key {r.Key}" + : $"ran console commands for {r.Rewarded} recipient(s), {r.SkippedCount} offline, for key {r.Key}"; + Puts($"world: {_lastWorldSummary}"); + + foreach (string waiting in job.Waiting) + EmitCommandAnswer(waiting, r, false); + } + + private void StopCommandJob(CommandJob job, string reason, string message) + { + CommandRecord r = job.Record; + r.Done = true; + r.Reason = reason; + r.Message = message; + _commandJobs.Remove(r.Key); + SaveCommandRecords(); + + Puts($"world: console commands for key {r.Key} stopped ({reason}) after {r.Ran}: {message}"); + + foreach (string waiting in job.Waiting) + EmitCommandAnswer(waiting, r, false); + } + + /// A finished record: command.ran, or command.error for one that stopped. + private void EmitCommandAnswer(string reqId, CommandRecord r, bool repeat) + { + if (r.Reason != null) + { + Dictionary error = CommandFrame("command.error", reqId, r); + error["reason"] = r.Reason; + error["message"] = r.Message; + error["repeat"] = repeat; + _lastWorldSummary = $"console commands refused ({r.Reason}): {r.Message}"; + EmitFrame(error); + return; + } + + Dictionary frame = CommandFrame("command.ran", reqId, r); + frame["repeat"] = repeat; + EmitFrame(frame); + } + + private void EmitCommandError(string reqId, string reason, string message, CommandRecord r) + { + Dictionary frame = CommandFrame("command.error", reqId, r); + frame["reason"] = reason; + frame["message"] = message; + _lastWorldSummary = $"console commands refused ({reason}): {message}"; + EmitFrame(frame); + } + + /// What every answer carries: the key, how many ran, the replies and who was skipped. + private Dictionary CommandFrame(string kind, string reqId, CommandRecord r) + { + Dictionary frame = Frame(kind, "reply"); + + if (r != null) + { + frame["key"] = r.Key; + frame["ran"] = r.Ran; + frame["rewarded"] = r.Rewarded; + + var replies = new List(); + + foreach (CommandReply reply in r.Replies) + { + var row = new Dictionary { { "command", reply.Command }, { "reply", reply.Reply ?? string.Empty } }; + + if (reply.SteamId != null) + row["steamId"] = reply.SteamId; + + replies.Add(row); + } + + frame["replies"] = replies; + frame["skipped"] = new List(r.Skipped); + frame["skippedCount"] = r.SkippedCount; + } + + if (reqId != null) + frame["reqId"] = reqId; + + return frame; + } } } diff --git a/overlay/oxide/plugins/RunicGatewayRaids.cs b/overlay/oxide/plugins/RunicGatewayRaids.cs new file mode 100644 index 0000000..998eaa5 --- /dev/null +++ b/overlay/oxide/plugins/RunicGatewayRaids.cs @@ -0,0 +1,1697 @@ +// Runic Gateway Raids — a helper for RaidableBases, shipped beside the Runic Gateway bridge. +// Copyright (C) Runic Gateway contributors. GPL-3.0-or-later. +// +// WHAT IT IS FOR (docs/modules/rust/PLAN_REDESIGNS.md §11.4, D168, D204–D208, D387–D389): +// +// nivex's RaidableBases pastes a CopyPaste building, stocks it and guards it, and its public API +// spawns nothing and despawns nothing (§11.4.1). An event's step wants more than the plugin offers +// another plugin: the base the run made and no other, despawned at the run's end; settings for that +// one spawn; the lock to the first attacker for that one base; and a spot an admin chose. So this +// helper reaches RaidableBases' internals, by reflection and three Harmony patches, which is the +// org lead's decision (D204–D207) and the reason it lives here and not in the bridge (D168): +// +// * **A run's base is tagged at birth.** A prefix on the spawn queue's `Add(RandomBase)` sees the +// request this helper's own call made (the plugin picks the spot), and a postfix on +// `OpenEvent(RandomBase)` sees the base that request became. So the base is the run's by +// identity, never by matching a name or a position against the operator's own bases (§11.4.4). +// * **Settings for one spawn go on a copy of its profile** (see `CopyProfile`): PvP, NPCs and +// their count, despawn minutes, and at a spot its height. The profile every other base shares +// is never touched — the plugin's own `Clone()` is shallow and leaked a height into it on the +// rig (§11.4.4). **PvP is set on the base itself after `OpenEvent`** too, because the plugin's +// config switches ("Convert PVE To PVP") are applied inside it and win over a profile (§11.4.2). +// * **The lock is per event (D206).** A prefix on each base's `BypassUseOwners()` answers from the +// run's own setting for a tagged base; every other base follows the plugin's config. +// * **A spot is checked, then pasted (D207, D208, D388, D389).** This helper's own look for players, +// buildings, deployables and raid bases, then the plugin's own area check with its reason, then +// the ocean level against the ground. Rocks and cliffs are warnings; the rest refuse. The base is +// built the way the plugin's "spawn where I look" builds one, from numbers instead of a view. +// * **One base is despawned through its own `Despawn()`**, which raises the plugin's usual hooks. +// * **Profiles reload under watch.** The site edits RaidableBases' profile files (D205), and the +// bridge reloads them through here and asks afterwards whether each loaded. +// +// Names are the operator's: every difficulty, profile and base name is read from the installed +// plugin, never written here (D386). The plugin's own enum is bound by name, since the paid edition +// inserted `Purchased` and moved the ints (§11.4.2). Both editions are served, free and paid, by +// what each has (D348): the paid edition's own `SpawnRandomBase(string, Vector3, int, int, bool)` +// for the plugin's pick, the free edition's private typed one otherwise. +// +// It is optional. Without it the bridge still reports bases on the feed and the map, and a step +// still spawns one where the plugin can be asked to (the paid call, or the free edition's console +// command), but nothing despawns it early and there are no settings, no lock and no spot; the site +// says so. A RaidableBases reload drops the patches with the old assembly, and this re-patches. +// `rgr.status` in the server console prints the state. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Reflection; +using HarmonyLib; +using Newtonsoft.Json; +using Oxide.Core; +using Oxide.Core.Plugins; +using UnityEngine; + +namespace Oxide.Plugins +{ + [Info("Runic Gateway Raids", "Runic Gateway", "0.1.0")] + [Description("Helper for Runic Gateway: RaidableBases spawns, overrides, locks and despawns one event's base, and reloads its profiles under watch.")] + public class RunicGatewayRaids : RustPlugin + { + private const string HarmonyId = "runicgateway.raids"; + + private const BindingFlags Any = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic; + + /// The radius a spot is checked at when the profile gives none: the plugin's own `rbe` uses 50 m. + private const float DefaultSpotRadius = 50f; + + /// A spot this far under the ocean surface is "in water" (§11.4.2: the water map reads −500 on the rigs). + private const float WaterMargin = 0.5f; + + /// How long an ended base's tag is still answered by : its last hooks follow its despawn. + private const float EndedTagSeconds = 120f; + + /// The instance the static patches report to. Null while unloaded. + private static RunicGatewayRaids _instance; + + private Harmony _harmony; + private readonly List _patched = new List(); + + /// patched, unsupported (RaidableBases is here but not the shape this reaches), or no-raidablebases. + private string _state = "no-raidablebases"; + private string _reason; + + // ── What one RaidableBases load is made of. A reload is a new assembly, so all of it is + // looked up again then. ───────────────────────────────────────────────────────────── + + private Plugin _rb; + private Type _rbType; + private Type _raidableBase; + private Type _randomBase; + private Type _spawnsType; + private Type _raidableType; + private object _manual; + private int _manualMask; + + private MethodInfo _openEvent; + private MethodInfo _queueAdd; + private MethodInfo _bypass; + private MethodInfo _despawn; + private MethodInfo _gridLoading; + private MethodInfo _pasteBuilding; + private MethodInfo _getBuilding; + private MethodInfo _getPasteData; + private MethodInfo _parseListed; + private MethodInfo _fileExists; + private MethodInfo _reloadProfiles; + private MethodInfo _getDistance; + private MethodInfo _areaSafe; + + /// Paid: SpawnRandomBase(string, Vector3, int, int, bool). Null on the free edition. + private MethodInfo _spawnApi; + + /// Free: SpawnRandomBase(RaidableType, string, bool, BasePlayer, IPlayer, bool). Null on the paid edition. + private MethodInfo _spawnTyped; + + private ConstructorInfo _spawnsCtor; + + // ── The run's bases ─────────────────────────────────────────────────── + + /// One base a run asked for: its settings, and what it became. + private sealed class Tag + { + public string Id; + public bool? Pvp; + public bool? Npcs; + public int? NpcCount; + public int? DespawnMinutes; + public bool? Lock; + public string BaseName; + public string Profile; + + /// The plugin's request object, until OpenEvent turns it into a base. + public object Request; + + /// The base, once open. + public object Raid; + + public Vector3 Location; + public bool Cancelled; + public float EndedAt = -1f; + } + + /// Requests this helper made, by the plugin's request object. Main thread. + private static readonly Dictionary Requested = new Dictionary(); + + /// Open bases a run owns, by the base. Read by the lock patch. + private static readonly Dictionary Tagged = new Dictionary(); + + private readonly Dictionary _byId = new Dictionary(StringComparer.Ordinal); + + /// Set for the length of this helper's own call into the plugin's pick, for the queue patch. + private static Tag _capturing; + + /// Profile reloads this helper finished, for the bridge's watch. + private int _reloadsFinished; + + private bool _reloading; + + // ── Lifecycle ───────────────────────────────────────────────────────── + + private void OnServerInitialized() + { + _instance = this; + Patch(); + } + + private void Unload() + { + Unpatch(); + ForgetAll(); + _instance = null; + } + + private void OnPluginLoaded(Plugin plugin) + { + if (plugin != null && plugin.Name == "RaidableBases") + { + Unpatch(); + ForgetAll(); + Patch(); + } + } + + private void OnPluginUnloaded(Plugin plugin) + { + if (plugin != null && plugin.Name == "RaidableBases") + { + // Its unload despawned every base; nothing tagged is left in the world. + Unpatch(); + ForgetAll(); + _rb = null; + _state = "no-raidablebases"; + _reason = null; + } + } + + private void ForgetAll() + { + Requested.Clear(); + Tagged.Clear(); + _byId.Clear(); + _capturing = null; + _reloading = false; + } + + // ── The API the bridge calls ───────────────────────────────────────── + + /// patched, unsupported or no-raidablebases. + private string State() => _state; + + /// Why the state is not patched, or null. + private string StateReason() => _reason; + + /// The helper's own version, for hello. + private string HelperVersion() => Version.ToString(); + + /// Whether the plugin's spawn grid is built. Until it is, the plugin refuses every spawn and every profile reload. + private bool GridReady() + { + if (_state != "patched") + return false; + + try + { + return !(bool)_gridLoading.Invoke(_rb, null); + } + catch (Exception) + { + return false; + } + } + + /// + /// Every profile the plugin loaded, as hello carries them: name, difficulty, + /// level, enabled, pvp, bases (only those whose CopyPaste file + /// exists), and seabed and float (whether it is built for water, D389). + /// + private List Profiles() + { + var rows = new List(); + + if (_state != "patched") + return rows; + + foreach (KeyValuePair kv in ProfileTable()) + { + object options = F(kv.Value, "Options"); + + if (options == null) + continue; + + var row = new Dictionary + { + { "name", kv.Key }, + { "difficulty", ModeOf(options) }, + { "level", LevelOf(options) }, + { "enabled", F(options, "Enabled") is bool on && on }, + { "pvp", F(options, "AllowPVP") is bool pvp && pvp }, + { "bases", BasesOf(kv.Key, options) } + }; + + object water = F(options, "Water"); + row["seabed"] = F(water, "Seabed") is float seabed && seabed > 0f; + row["float"] = F(water, "AllowSubmerged") is bool floats && floats; + rows.Add(row); + } + + return rows; + } + + /// + /// Main thread. Asks the plugin for one base for a run. carries + /// id (the bridge's), difficulty (a name) and level, profile, + /// base, and for a spot x, z with heightAdjust or height; + /// and the settings pvp, npcs, npcCount, despawnMinutes, + /// lock. Answers ok with what was asked for, or reason and message. + /// + private Dictionary Spawn(Dictionary req) + { + if (_state != "patched") + return Refuse("helper-unsupported", $"the RaidableBases helper is {_state}{(_reason != null ? $" ({_reason})" : "")}"); + + string id = Text(req, "id"); + + if (string.IsNullOrEmpty(id) || _byId.ContainsKey(id)) + return Refuse("malformed", "a spawn needs an id this helper has not seen"); + + if (!GridReady()) + return Refuse("rb-not-ready", "RaidableBases is still building its spawn grid, and refuses every spawn until it has"); + + if (Flag(_rb, "IsSpawnerBusy") || Flag(_rb, "IsLoaderBusy")) + return Refuse("rb-busy", "RaidableBases is pasting another base; it takes one at a time"); + + var tag = new Tag + { + Id = id, + Pvp = BoolOf(req, "pvp"), + Npcs = BoolOf(req, "npcs"), + NpcCount = IntOf(req, "npcCount"), + DespawnMinutes = IntOf(req, "despawnMinutes"), + Lock = BoolOf(req, "lock") + }; + + string refusal; + string reason; + Choice choice = Choose(req, Number(req, "x").HasValue, out reason, out refusal); + + if (choice == null) + return Refuse(reason, refusal); + + tag.BaseName = choice.BaseName; + tag.Profile = choice.ProfileName; + + Dictionary answer = Number(req, "x").HasValue + ? SpawnAt(req, tag, choice) + : SpawnPicked(tag, choice); + + if (answer.TryGetValue("ok", out object ok) && ok is bool done && done) + { + _byId[id] = tag; + answer["difficulty"] = choice.Difficulty; + answer["level"] = choice.Level; + + if (choice.ProfileName != null) + answer["profile"] = choice.ProfileName; + + if (choice.BaseName != null) + answer["base"] = choice.BaseName; + } + + return answer; + } + + /// + /// Main thread. The checks a spot must pass (D208, D388, D389), without spawning: + /// x, z, and the difficulty, profile or base whose + /// radius and water settings apply. Answers ok, the refusal's reason and + /// message, and warnings. + /// + private Dictionary Check(Dictionary req) + { + if (_state != "patched") + return Refuse("helper-unsupported", $"the RaidableBases helper is {_state}"); + + string reason; + string refusal; + Choice choice = Choose(req, true, out reason, out refusal); + + if (choice == null) + return Refuse(reason, refusal); + + var warnings = new List(); + Vector3 at; + + if (!CheckSpot(req, choice, warnings, out at, out reason, out refusal)) + { + Dictionary no = Refuse(reason, refusal); + no["warnings"] = warnings; + return no; + } + + return new Dictionary { { "ok", true }, { "warnings", warnings }, { "base", choice.BaseName } }; + } + + /// + /// Main thread. Takes one base away: removed (it was open and its own Despawn() + /// ran), cancelled (it was still on its way and will be taken away the moment it + /// opens), or gone. + /// + private string Despawn(string id) + { + Tag tag; + + if (string.IsNullOrEmpty(id) || !_byId.TryGetValue(id, out tag)) + return "gone"; + + if (tag.Raid != null && IsOpen(tag.Raid)) + { + try + { + _despawn.Invoke(tag.Raid, null); + return "removed"; + } + catch (Exception ex) + { + PrintWarning($"could not despawn base {id}: {(ex.InnerException ?? ex).Message}"); + return "gone"; + } + } + + if (tag.Raid == null && !tag.Cancelled && tag.EndedAt < 0f) + { + tag.Cancelled = true; + Dequeue(tag.Request); + return "cancelled"; + } + + return "gone"; + } + + /// open, queued (asked for and not open yet) or gone. + private string LiveState(string id) + { + Tag tag; + + if (string.IsNullOrEmpty(id) || !_byId.TryGetValue(id, out tag) || tag.Cancelled) + return "gone"; + + if (tag.Raid != null) + return IsOpen(tag.Raid) ? "open" : "gone"; + + return tag.EndedAt < 0f ? "queued" : "gone"; + } + + /// The id of the run's base at this spot, or null: how the bridge joins a lifecycle hook to a run. + private string TagAt(Vector3 at) + { + foreach (Tag tag in _byId.Values) + { + if (tag.Raid == null) + continue; + + if (tag.EndedAt >= 0f && Time.realtimeSinceStartup - tag.EndedAt > EndedTagSeconds) + continue; + + Vector3 where = tag.Raid is UnityEngine.Object unity && unity == null ? tag.Location : (F(tag.Raid, "Location") is Vector3 now ? now : tag.Location); + + if (Distance2D(where, at) <= 2f) + return tag.Id; + } + + return null; + } + + /// + /// Main thread. Has the plugin reload its profiles, as rb.reloadprofiles does. + /// False when it would refuse: its grid is still building, or a reload is running. + /// + private bool ReloadProfiles() + { + if (_state != "patched" || _reloading || !GridReady()) + return false; + + IEnumerator inner; + + try + { + // No player. Typed in the console, `rb.reloadprofiles` also reloads the per-base loot + // tables and logs a line; with no player the plugin skips both, and a profile edit + // touches neither (they are their own files, `Base_Loot/`). + inner = _reloadProfiles.Invoke(_rb, new object[] { null }) as IEnumerator; + } + catch (Exception ex) + { + PrintWarning($"could not start RaidableBases' profile reload: {(ex.InnerException ?? ex).Message}"); + return false; + } + + if (inner == null) + return false; + + _reloading = true; + ServerMgr.Instance.StartCoroutine(Watch(inner)); + return true; + } + + /// How many profile reloads have finished since this helper loaded. + private int ReloadsFinished() => _reloadsFinished; + + /// loaded, error (the plugin could not read it) or absent. + private string ProfileState(string name) + { + if (_state != "patched" || string.IsNullOrEmpty(name)) + return "absent"; + + foreach (KeyValuePair kv in ProfileTable()) + { + if (string.Equals(kv.Key, name, StringComparison.OrdinalIgnoreCase)) + return "loaded"; + } + + if (F(_rb, "profileErrors") is IEnumerable errors) + { + foreach (object file in errors) + { + string text = file as string; + + if (text != null && System.IO.Path.GetFileNameWithoutExtension(text).Equals(name, StringComparison.OrdinalIgnoreCase)) + return "error"; + } + } + + return "absent"; + } + + private IEnumerator Watch(IEnumerator inner) + { + try + { + yield return inner; + } + finally + { + _reloading = false; + _reloadsFinished++; + } + } + + // ── Choosing a base ────────────────────────────────────────────────── + + private sealed class Choice + { + public string BaseName; + public string ProfileName; + public object Profile; + public string Difficulty; + public int Level; + } + + /// + /// Main thread. Which base, profile and difficulty a request means, from the plugin's own + /// table. A base names its profile; a profile picks one of its bases at random; a + /// difficulty alone picks at random among its enabled profiles when a concrete base is + /// needed (a spot), and is left to the plugin otherwise. Null with a reason when the + /// names do not fit together or do not exist. + /// + private Choice Choose(Dictionary req, bool needBase, out string reason, out string refusal) + { + reason = null; + refusal = null; + + string wantBase = Text(req, "base"); + string wantProfile = Text(req, "profile"); + string wantDifficulty = Text(req, "difficulty"); + int? wantLevel = IntOf(req, "level"); + List> table = ProfileTable(); + + if (!string.IsNullOrEmpty(wantBase)) + { + foreach (KeyValuePair kv in table) + { + object options = F(kv.Value, "Options"); + + foreach (string b in BasesOf(kv.Key, options)) + { + if (!b.Equals(wantBase, StringComparison.OrdinalIgnoreCase)) + continue; + + if (!string.IsNullOrEmpty(wantProfile) && !kv.Key.Equals(wantProfile, StringComparison.OrdinalIgnoreCase)) + { + reason = "bad-base"; + refusal = $"base '{wantBase}' belongs to profile '{kv.Key}', not '{wantProfile}'"; + return null; + } + + Choice found = ChoiceOf(kv.Key, kv.Value, options, b); + + if (!FitsDifficulty(found, wantDifficulty, wantLevel, out refusal)) + { + reason = "bad-difficulty"; + return null; + } + + return found; + } + } + + reason = "bad-base"; + refusal = $"RaidableBases has no base '{wantBase}' with a CopyPaste file"; + return null; + } + + if (!string.IsNullOrEmpty(wantProfile)) + { + foreach (KeyValuePair kv in table) + { + if (!kv.Key.Equals(wantProfile, StringComparison.OrdinalIgnoreCase)) + continue; + + object options = F(kv.Value, "Options"); + List bases = BasesOf(kv.Key, options); + + if (bases.Count == 0) + { + reason = "bad-profile"; + refusal = $"profile '{kv.Key}' has no base with a CopyPaste file"; + return null; + } + + Choice found = ChoiceOf(kv.Key, kv.Value, options, bases[UnityEngine.Random.Range(0, bases.Count)]); + + if (!FitsDifficulty(found, wantDifficulty, wantLevel, out refusal)) + { + reason = "bad-difficulty"; + return null; + } + + return found; + } + + reason = "bad-profile"; + refusal = $"RaidableBases has no profile '{wantProfile}'"; + return null; + } + + // A difficulty, or nothing at all. + var candidates = new List(); + + foreach (KeyValuePair kv in table) + { + object options = F(kv.Value, "Options"); + + if (!(F(options, "Enabled") is bool on) || !on) + continue; + + Choice shape = ChoiceOf(kv.Key, kv.Value, options, null); + string ignored; + + if (!FitsDifficulty(shape, wantDifficulty, wantLevel, out ignored)) + continue; + + foreach (string b in BasesOf(kv.Key, options)) + candidates.Add(ChoiceOf(kv.Key, kv.Value, options, b)); + } + + if (candidates.Count == 0) + { + reason = string.IsNullOrEmpty(wantDifficulty) && !wantLevel.HasValue ? "bad-profile" : "bad-difficulty"; + refusal = string.IsNullOrEmpty(wantDifficulty) && !wantLevel.HasValue + ? "RaidableBases has no enabled profile with a base" + : $"RaidableBases has no enabled profile with a base at difficulty '{wantDifficulty ?? wantLevel.ToString()}'"; + return null; + } + + if (needBase) + return candidates[UnityEngine.Random.Range(0, candidates.Count)]; + + // The plugin picks the base among the difficulty's own (its cycle, its rules). With no + // difficulty asked for, one is picked here at random from those that have a base. + Choice pick = candidates[UnityEngine.Random.Range(0, candidates.Count)]; + return new Choice { Difficulty = pick.Difficulty, Level = pick.Level }; + } + + private Choice ChoiceOf(string name, object profile, object options, string baseName) + { + return new Choice + { + BaseName = baseName, + ProfileName = name, + Profile = profile, + Difficulty = ModeOf(options), + Level = LevelOf(options) + }; + } + + private static bool FitsDifficulty(Choice choice, string difficulty, int? level, out string refusal) + { + refusal = null; + + if (!string.IsNullOrEmpty(difficulty) && !string.Equals(choice.Difficulty, difficulty, StringComparison.OrdinalIgnoreCase)) + { + refusal = $"profile '{choice.ProfileName}' is difficulty '{choice.Difficulty}', not '{difficulty}'"; + return false; + } + + if (level.HasValue && string.IsNullOrEmpty(difficulty) && choice.Level != level.Value) + { + refusal = $"profile '{choice.ProfileName}' is level {choice.Level}, not {level.Value}"; + return false; + } + + return true; + } + + /// The plugin's profile table, name first. Empty when it cannot be read. + private List> ProfileTable() + { + var rows = new List>(); + + if (!(F(F(_rb, "Buildings"), "Profiles") is IDictionary profiles)) + return rows; + + foreach (DictionaryEntry kv in profiles) + { + if (kv.Key is string name && kv.Value != null) + rows.Add(new KeyValuePair(name, kv.Value)); + } + + rows.Sort((a, b) => string.Compare(a.Key, b.Key, StringComparison.OrdinalIgnoreCase)); + return rows; + } + + /// A profile's bases that have a CopyPaste file: the profile's own name, then its additional bases. + private List BasesOf(string profileName, object options) + { + var bases = new List(); + + if (Exists(profileName)) + bases.Add(profileName); + + if (F(options, "AdditionalBases") is IDictionary extra) + { + foreach (DictionaryEntry kv in extra) + { + if (kv.Key is string name && !bases.Contains(name) && Exists(name)) + bases.Add(name); + } + } + + return bases; + } + + private bool Exists(string file) + { + try + { + return _fileExists.Invoke(_rb, new object[] { file }) is bool yes && yes; + } + catch (Exception) + { + return false; + } + } + + /// The paid edition's difficulty name; the free edition has one, "Normal". + private static string ModeOf(object options) + { + return F(options, "Mode") is string mode && !string.IsNullOrEmpty(mode) ? mode : "Normal"; + } + + /// The paid edition's difficulty level; the free edition's hooks always carry 512. + private static int LevelOf(object options) + { + return F(options, "Level") is int level ? level : 512; + } + + // ── Spawning ───────────────────────────────────────────────────────── + + /// + /// The plugin picks the spot (D203). The queue patch sees the request our call makes and + /// tags it, with the settings on a copy of its profile. + /// + private Dictionary SpawnPicked(Tag tag, Choice choice) + { + object result = null; + _capturing = tag; + + try + { + if (_spawnApi != null) + { + result = _spawnApi.Invoke(_rb, new object[] { choice.BaseName, Vector3.zero, choice.Level, Convert.ToInt32(_manual), true }); + } + else + { + result = _spawnTyped.Invoke(_rb, new object[] { _manual, choice.BaseName, true, null, null, true }); + } + } + catch (Exception ex) + { + _capturing = null; + return Refuse("refused", $"RaidableBases threw: {(ex.InnerException ?? ex).Message}"); + } + finally + { + _capturing = null; + } + + if (tag.Request == null || !(result is bool queued) || !queued) + { + if (tag.Request != null) + { + Requested.Remove(tag.Request); + Dequeue(tag.Request); + } + + string said = result as string; + return Refuse("refused", $"RaidableBases did not queue a base: {(said ?? LastMessage() ?? "no reason given")}"); + } + + return new Dictionary { { "ok", true }, { "route", "pick" } }; + } + + /// + /// A spot an admin gave (D207): checked (D208), then built the way the plugin's own + /// "spawn where I look" builds one and pasted at once. + /// + private Dictionary SpawnAt(Dictionary req, Tag tag, Choice choice) + { + var warnings = new List(); + Vector3 at; + string reason; + string refusal; + + if (!CheckSpot(req, choice, warnings, out at, out reason, out refusal)) + { + Dictionary no = Refuse(reason, refusal); + no["warnings"] = warnings; + return no; + } + + // The base as the plugin builds it: an additional base comes back as a clone of its + // profile carrying that base's own paste options and name (§11.4.2: the paid edition + // finds no profile without the difficulty's name). + object built; + + try + { + object answer = _getBuilding.GetParameters().Length >= 4 + ? _getBuilding.Invoke(_rb, new object[] { _manual, choice.Difficulty, choice.BaseName, null }) + : _getBuilding.Invoke(_rb, new object[] { _manual, choice.BaseName, null }); + built = answer?.GetType().GetField("Item2")?.GetValue(answer); + } + catch (Exception ex) + { + return Refuse("refused", $"RaidableBases could not find base '{choice.BaseName}': {(ex.InnerException ?? ex).Message}"); + } + + if (built == null) + return Refuse("bad-base", $"RaidableBases will not build base '{choice.BaseName}' for an event: {LastMessage() ?? "no reason given"}"); + + object copy; + + try + { + copy = CopyProfile(built); + object options = F(copy, "Options"); + object setup = F(options, "Setup"); + double? adjust = Number(req, "heightAdjust"); + double? height = Number(req, "height"); + + if (height.HasValue) + { + S(setup, "EnableForcedHeight", true); + S(setup, "ForcedHeightValue", (float)height.Value); + } + else if (adjust.HasValue) + { + S(setup, "PasteHeightAdjustment", (F(setup, "PasteHeightAdjustment") is float now ? now : 0f) + (float)adjust.Value); + } + + ApplySettings(options, tag); + } + catch (Exception ex) + { + return Refuse("refused", $"could not copy profile '{choice.ProfileName}': {(ex.InnerException ?? ex).Message}"); + } + + object rb; + + try + { + rb = Activator.CreateInstance(_randomBase); + S(rb, "Instance", _rb); + S(rb, "BaseName", choice.BaseName); + S(rb, "Profile", copy); + S(rb, "Position", at); + S(rb, "type", _manual); + S(rb, "spawns", _spawnsCtor.Invoke(new object[] { _rb })); + S(rb, "pasteData", _getPasteData.Invoke(_rb, new object[] { choice.BaseName })); + Radii(rb); + _parseListed?.Invoke(_rb, new[] { rb }); + } + catch (Exception ex) + { + return Refuse("refused", $"could not build the spawn request: {(ex.InnerException ?? ex).Message}"); + } + + tag.Request = rb; + Requested[rb] = tag; + bool pasting; + + try + { + pasting = _pasteBuilding.Invoke(_rb, new[] { rb }) is bool yes && yes; + } + catch (Exception ex) + { + Requested.Remove(rb); + return Refuse("refused", $"RaidableBases threw while pasting: {(ex.InnerException ?? ex).Message}"); + } + + if (!pasting) + { + Requested.Remove(rb); + return Refuse("refused", "RaidableBases would not paste (is CopyPaste loaded?)"); + } + + // As the plugin's own queue does after a paste starts, so its queue waits for this one. + S(_rb, "IsSpawnerBusy", true); + S(rb, "IsPasting", true); + + var ok = new Dictionary { { "ok", true }, { "route", "spot" }, { "warnings", warnings } }; + ok["x"] = Math.Round(at.x, 1); + ok["z"] = Math.Round(at.z, 1); + return ok; + } + + /// The radii the plugin's own queue sets on a request, from the copied profile. + private void Radii(object rb) + { + try + { + object options = F(F(rb, "Profile"), "Options"); + + if (_getDistance != null) + S(rb, "typeDistance", _getDistance.Invoke(_rb, new[] { _manual })); + + float protection = ProtectionRadius(options); + S(rb, "protectionRadius", protection); + + float arena = F(F(options, "ArenaWalls"), "Radius") is float walls ? walls : 0f; + S(rb, "safeRadius", Mathf.Max(arena, protection)); + + float cupboard = F(F(F(F(_rb, "config"), "Settings"), "Management"), "CupboardDetectionRadius") is float c ? c : 0f; + S(rb, "buildRadius", Mathf.Max(105f, Mathf.Max(cupboard, Mathf.Max(arena, protection)) + 5f)); + } + catch (Exception ex) + { + PrintWarning($"could not set a spot's radii; the plugin's defaults stand: {(ex.InnerException ?? ex).Message}"); + } + } + + private float ProtectionRadius(object options) + { + try + { + MethodInfo radius = options?.GetType().GetMethod("ProtectionRadius", Any, null, new[] { _raidableType }, null); + + if (radius != null && radius.Invoke(options, new[] { _manual }) is float r && r > 0f) + return r; + } + catch (Exception) + { + } + + return DefaultSpotRadius; + } + + /// A spawn's settings, on its own copy of the profile's options. + private static void ApplySettings(object options, Tag tag) + { + if (tag.Pvp.HasValue) + S(options, "AllowPVP", tag.Pvp.Value); + + object npc = F(options, "NPC"); + + if (npc != null && (tag.Npcs.HasValue || tag.NpcCount.HasValue)) + { + if (tag.Npcs == false || tag.NpcCount == 0) + { + S(npc, "Enabled", false); + } + else + { + S(npc, "Enabled", true); + + if (tag.NpcCount.HasValue) + { + // The profile's own mix of the two kinds, at the count asked for, and not a + // random amount: the event said how many. + int murderers = Math.Max(0, F(npc, "SpawnAmountMurderers") is int m ? m : 0); + int scientists = Math.Max(0, F(npc, "SpawnAmountScientists") is int s ? s : 0); + int count = tag.NpcCount.Value; + int sci = murderers + scientists == 0 ? count : (int)Math.Round(count * (double)scientists / (murderers + scientists)); + S(npc, "SpawnAmountScientists", sci); + S(npc, "SpawnAmountMurderers", count - sci); + S(npc, "SpawnRandomAmountScientists", false); + S(npc, "SpawnRandomAmountMurderers", false); + } + } + } + + if (tag.DespawnMinutes.HasValue) + { + object despawn = F(options, "DespawnOptions"); + + if (despawn != null) + { + S(despawn, "OverrideConfig", true); + S(despawn, "DespawnMinutes", tag.DespawnMinutes.Value); + S(despawn, "DespawnMinutesInactive", tag.DespawnMinutes.Value); + } + } + } + + /// Takes a request off the plugin's queue, when it is still on it. + private void Dequeue(object request) + { + if (request == null) + return; + + try + { + object queue = F(F(_rb, "Queues"), "queue"); + + if (queue == null) + return; + + Type type = queue.GetType(); + + if (!(type.GetMethod("ToArray")?.Invoke(queue, null) is Array items)) + return; + + type.GetMethod("Clear")?.Invoke(queue, null); + MethodInfo enqueue = type.GetMethod("Enqueue"); + + foreach (object item in items) + { + if (!ReferenceEquals(item, request)) + enqueue.Invoke(queue, new[] { item }); + } + } + catch (Exception ex) + { + PrintWarning($"could not take a request off RaidableBases' queue; it is cancelled when it opens: {(ex.InnerException ?? ex).Message}"); + } + } + + // ── A spot's checks (D208, D388, D389) ─────────────────────────────── + + /// + /// Main thread. Whether a base may go at the request's x, z. Refuses for a + /// player, a building, a deployable, a raid base or water; rocks and cliffs are warnings. + /// + private bool CheckSpot(Dictionary req, Choice choice, List warnings, out Vector3 at, out string reason, out string refusal) + { + at = Vector3.zero; + reason = null; + refusal = null; + + double? x = Number(req, "x"); + double? z = Number(req, "z"); + + if (!x.HasValue || !z.HasValue) + { + reason = "malformed"; + refusal = "a spot needs x and z"; + return false; + } + + float half = World.Size / 2f; + + if (Math.Abs(x.Value) > half || Math.Abs(z.Value) > half) + { + reason = "off-map"; + refusal = $"({x.Value:0}, {z.Value:0}) is off this map, which runs from -{half:0} to {half:0}"; + return false; + } + + at = new Vector3((float)x.Value, 0f, (float)z.Value); + float ground = TerrainMeta.HeightMap.GetHeight(at); + at.y = ground; + + object options = choice.Profile != null ? F(choice.Profile, "Options") : null; + float radius = options != null ? ProtectionRadius(options) : DefaultSpotRadius; + + // Ours first: the plugin's check stops at the first thing it finds, and a boulder found + // first would hide a player's hut behind a warning. + if (!OwnChecks(at, radius, out reason, out refusal)) + return false; + + string said; + + if (!PluginAreaSafe(at, radius, out said)) + { + if (IsTerrainWarning(said)) + { + warnings.Add(said); + } + else + { + reason = "spot-blocked"; + refusal = $"RaidableBases' area check refused the spot: {said}"; + return false; + } + } + + // The ocean level against the ground. The water map read −500 everywhere on the rigs' + // map, so it cannot be used for this (§11.4.2). + float water = WaterSystem.OceanLevel; + + if (water > ground + WaterMargin) + { + object waterOptions = F(options, "Water"); + bool seabed = F(waterOptions, "Seabed") is float chance && chance > 0f; + bool floats = F(waterOptions, "AllowSubmerged") is bool yes && yes; + + if (!seabed && !floats) + { + reason = "in-water"; + refusal = $"the spot is {water - ground:0.0} m under water, and profile '{choice.ProfileName ?? "?"}' is not built for water"; + return false; + } + + warnings.Add($"the spot is {water - ground:0.0} m under water; profile '{choice.ProfileName}' allows it"); + } + + return true; + } + + /// Players, buildings, deployables and raid bases within the radius, by this helper's own look. + private bool OwnChecks(Vector3 at, float radius, out string reason, out string refusal) + { + reason = null; + refusal = null; + + foreach (BasePlayer player in BasePlayer.allPlayerList) + { + if (player == null || player.IsNpc || player.transform == null) + continue; + + ulong uid = player.userID; + + if (!uid.IsSteamId()) + continue; + + if (Distance2D(player.transform.position, at) <= radius) + { + reason = "spot-blocked"; + refusal = $"a player is within {radius:0} m ({player.displayName})"; + return false; + } + } + + if (F(_rb, "Raids") is IList raids) + { + foreach (object raid in raids) + { + if (raid == null || raid is UnityEngine.Object unity && unity == null) + continue; + + if (F(raid, "Location") is Vector3 where && Distance2D(where, at) <= radius * 2f) + { + reason = "spot-blocked"; + refusal = $"a raid base is within {radius * 2f:0} m"; + return false; + } + } + } + + var nearby = new List(); + Vis.Entities(at, radius, nearby); + + foreach (BaseEntity entity in nearby) + { + if (entity == null || entity.IsDestroyed) + continue; + + if (entity is BuildingPrivlidge || entity is BuildingBlock && entity.OwnerID.IsSteamId()) + { + reason = "spot-blocked"; + refusal = $"a player's building is within {radius:0} m"; + return false; + } + + if (entity.OwnerID.IsSteamId() && !(entity is BasePlayer)) + { + reason = "spot-blocked"; + refusal = $"a player's deployable ({entity.ShortPrefabName}) is within {radius:0} m"; + return false; + } + } + + return true; + } + + /// The plugin's own area check, bound by parameter count (§11.4.2), with its reason. + private bool PluginAreaSafe(Vector3 at, float radius, out string said) + { + said = null; + object controller = F(_rb, "SpawnsController"); + object messages = F(F(_rb, "Queues"), "Messages"); + + if (controller == null || _areaSafe == null) + return true; + + try + { + (F(messages, "_elements") as IDictionary)?.Clear(); + + ParameterInfo[] ps = _areaSafe.GetParameters(); + var args = new object[ps.Length]; + args[0] = at; + args[1] = 0f; + args[2] = radius; + args[3] = radius; + args[4] = radius; + args[5] = _manualMask; + args[6] = false; + args[7] = null; + + for (int i = 8; i < ps.Length; i++) + args[i] = ps[i].ParameterType == _raidableType ? _manual : ps[i].HasDefaultValue ? ps[i].DefaultValue : null; + + if (_areaSafe.Invoke(controller, args) is bool safe && safe) + return true; + + said = messages?.GetType().GetMethod("GetLast", Any)?.Invoke(messages, new object[] { null }) as string; + said = Clean(said) ?? "no reason given"; + return false; + } + catch (Exception ex) + { + // The check itself broke: advice lost, not a refusal. + PrintWarning($"RaidableBases' area check threw: {(ex.InnerException ?? ex).Message}"); + return true; + } + } + + /// + /// Whether the plugin's reason is the terrain, which D388 makes a warning. Its messages + /// are English literals in its source (not its lang file), so the words are its own. + /// + private static bool IsTerrainWarning(string said) + { + return said != null && (said.IndexOf("Rock is too large", StringComparison.OrdinalIgnoreCase) >= 0 + || said.IndexOf("Cliff formation is too large", StringComparison.OrdinalIgnoreCase) >= 0); + } + + /// The plugin's debug line ("1x - Rock is too large: x y z") down to its words. + private static string Clean(string said) + { + if (string.IsNullOrEmpty(said)) + return null; + + int dash = said.IndexOf(" - ", StringComparison.Ordinal); + string text = dash >= 0 && dash < 8 ? said.Substring(dash + 3) : said; + return text.Trim(); + } + + private string LastMessage() + { + try + { + object messages = F(F(_rb, "Queues"), "Messages"); + + if (!(F(messages, "_elements") is IDictionary elements) || elements.Count == 0) + return null; + + return Clean(messages.GetType().GetMethod("GetLast", Any)?.Invoke(messages, new object[] { null }) as string); + } + catch (Exception) + { + return null; + } + } + + // ── Patching ───────────────────────────────────────────────────────── + + private void Patch() + { + _rb = plugins.Find("RaidableBases"); + + if (_rb == null) + { + _state = "no-raidablebases"; + _reason = null; + return; + } + + _rbType = _rb.GetType(); + _raidableBase = Nested("RaidableBase"); + _randomBase = Nested("RandomBase"); + _spawnsType = Nested("RaidableSpawns"); + _raidableType = Nested("RaidableType"); + Type queueType = Nested("QueueController"); + Type spawnsController = Nested("SpawnsControllerManager"); + + _manual = null; + + try + { + if (_raidableType != null && _raidableType.IsEnum) + _manual = Enum.Parse(_raidableType, "Manual"); + } + catch (Exception) + { + _manual = null; + } + + _openEvent = _randomBase == null ? null : _rbType.GetMethod("OpenEvent", Any, null, new[] { _randomBase }, null); + _pasteBuilding = _randomBase == null ? null : _rbType.GetMethod("PasteBuilding", Any, null, new[] { _randomBase }, null); + _parseListed = _randomBase == null ? null : _rbType.GetMethod("ParseListedOptions", Any, null, new[] { _randomBase }, null); + _queueAdd = queueType == null || _randomBase == null ? null : queueType.GetMethod("Add", Any, null, new[] { _randomBase }, null); + _bypass = _raidableBase?.GetMethod("BypassUseOwners", Any, null, Type.EmptyTypes, null); + _despawn = _raidableBase?.GetMethod("Despawn", Any, null, Type.EmptyTypes, null); + _gridLoading = _rbType.GetMethod("IsGridLoading", Any, null, Type.EmptyTypes, null); + _getPasteData = _rbType.GetMethod("GetPasteData", Any, null, new[] { typeof(string) }, null); + _fileExists = _rbType.GetMethod("FileExists", Any, null, new[] { typeof(string) }, null); + _reloadProfiles = FindMethod(_rbType, "ReloadProfiles", m => m.GetParameters().Length == 1 && typeof(IEnumerator).IsAssignableFrom(m.ReturnType)); + _getDistance = _raidableType == null ? null : _rbType.GetMethod("GetDistance", Any, null, new[] { _raidableType }, null); + _getBuilding = FindMethod(_rbType, "GetBuilding", m => m.GetParameters().Length >= 3); + _areaSafe = spawnsController == null ? null : FindMethod(spawnsController, "IsAreaSafe", m => m.GetParameters().Length >= 9 && m.GetParameters()[0].ParameterType == typeof(Vector3)); + _spawnApi = FindMethod(_rbType, "SpawnRandomBase", m => Shape(m, typeof(string), typeof(Vector3), typeof(int), typeof(int), typeof(bool))); + _spawnTyped = _raidableType == null ? null : FindMethod(_rbType, "SpawnRandomBase", m => + { + ParameterInfo[] ps = m.GetParameters(); + return ps.Length == 6 && ps[0].ParameterType == _raidableType && ps[1].ParameterType == typeof(string); + }); + _spawnsCtor = _spawnsType == null ? null : Array.Find(_spawnsType.GetConstructors(Any), c => c.GetParameters().Length == 1); + + try + { + FieldInfo mask = _rbType.GetField("manualMask", Any); + _manualMask = mask != null ? Convert.ToInt32(mask.GetValue(null)) : 0; + } + catch (Exception) + { + _manualMask = 0; + } + + string missing = + _raidableBase == null ? "RaidableBase" : + _randomBase == null ? "RandomBase" : + _manual == null ? "RaidableType.Manual" : + _openEvent == null ? "OpenEvent(RandomBase)" : + _queueAdd == null ? "QueueController.Add(RandomBase)" : + _bypass == null ? "RaidableBase.BypassUseOwners()" : + _despawn == null ? "RaidableBase.Despawn()" : + _gridLoading == null ? "IsGridLoading()" : + _pasteBuilding == null ? "PasteBuilding(RandomBase)" : + _getBuilding == null ? "GetBuilding" : + _getPasteData == null ? "GetPasteData(string)" : + _fileExists == null ? "FileExists(string)" : + _reloadProfiles == null ? "ReloadProfiles(IPlayer)" : + _spawnsCtor == null ? "RaidableSpawns(RaidableBases)" : + _spawnApi == null && _spawnTyped == null ? "SpawnRandomBase" : + _manualMask == 0 ? "manualMask" : + null; + + if (missing != null) + { + Fail($"this RaidableBases ({_rb.Version}) has no {missing}"); + return; + } + + try + { + _harmony = new Harmony(HarmonyId); + PatchOne(_queueAdd, prefix: nameof(BeforeQueueAdd)); + PatchOne(_openEvent, postfix: nameof(AfterOpenEvent)); + PatchOne(_bypass, prefix: nameof(BeforeBypassUseOwners)); + } + catch (Exception ex) + { + Fail($"Harmony could not patch RaidableBases: {ex.Message}"); + return; + } + + _state = "patched"; + _reason = null; + + // Not a refusal: the spot check loses only the plugin's half and says why in the log. + if (_areaSafe == null) + PrintWarning($"this RaidableBases ({_rb.Version}) has no SpawnsController.IsAreaSafe: a spot is checked by this helper's own look and the water test only"); + + Puts($"patched RaidableBases {_rb.Version} ({(_spawnApi != null ? "paid" : "free")} edition's spawn call): " + + "an event's base can be tagged, set, locked, placed and despawned"); + } + + private void PatchOne(MethodBase target, string prefix = null, string postfix = null) + { + _harmony.Patch(target, + prefix: prefix == null ? null : new HarmonyMethod(typeof(RunicGatewayRaids).GetMethod(prefix, BindingFlags.Static | BindingFlags.NonPublic)), + postfix: postfix == null ? null : new HarmonyMethod(typeof(RunicGatewayRaids).GetMethod(postfix, BindingFlags.Static | BindingFlags.NonPublic))); + _patched.Add(target); + } + + private void Unpatch() + { + if (_harmony != null) + { + foreach (MethodBase target in _patched) + { + try + { + _harmony.Unpatch(target, HarmonyPatchType.All, HarmonyId); + } + catch (Exception ex) + { + PrintWarning($"could not remove a RaidableBases patch: {ex.Message}"); + } + } + } + + _patched.Clear(); + _harmony = null; + } + + private void Fail(string reason) + { + Unpatch(); + _state = "unsupported"; + _reason = reason; + PrintWarning($"{reason}. Runic Gateway still reports raid bases, but an event cannot despawn, set, lock or place its own."); + } + + private Type Nested(string name) => _rbType?.GetNestedType(name, Any); + + private static MethodInfo FindMethod(Type type, string name, Predicate fits) + { + if (type == null) + return null; + + foreach (MethodInfo m in type.GetMethods(Any)) + { + if (m.Name == name && fits(m)) + return m; + } + + return null; + } + + private static bool Shape(MethodInfo m, params Type[] types) + { + ParameterInfo[] ps = m.GetParameters(); + + if (ps.Length != types.Length) + return false; + + for (int i = 0; i < ps.Length; i++) + { + if (ps[i].ParameterType != types[i]) + return false; + } + + return true; + } + + // ── The patches. Static, as Harmony needs; nothing thrown here may reach RaidableBases. ── + + /// Prefix on QueueController.Add(RandomBase): tags the request our own pick made. + private static void BeforeQueueAdd(object __0) + { + try + { + Tag tag = _capturing; + + if (tag == null || __0 == null || _instance == null) + return; + + _capturing = null; + tag.Request = __0; + Requested[__0] = tag; + _instance.CopySettingsOnto(__0, tag); + } + catch (Exception) + { + // Never RaidableBases' problem. + } + } + + /// Postfix on OpenEvent(RandomBase): the request has become a base. + private static void AfterOpenEvent(object __result, object __0) + { + try + { + Tag tag; + + if (__result == null || __0 == null || !Requested.TryGetValue(__0, out tag)) + return; + + Requested.Remove(__0); + tag.Raid = __result; + tag.Request = null; + Tagged[__result] = tag; + + if (F(__result, "Location") is Vector3 where) + tag.Location = where; + + // The config's Convert switches were applied inside OpenEvent, after the profile. + if (tag.Pvp.HasValue) + S(__result, "AllowPVP", tag.Pvp.Value); + + if (tag.Cancelled && _instance != null) + { + RunicGatewayRaids self = _instance; + self.NextTick(() => self.Despawn(tag.Id)); + } + } + catch (Exception) + { + // Never RaidableBases' problem. + } + } + + /// Prefix on RaidableBase.BypassUseOwners(): a run's base answers from its own lock (D206). + private static bool BeforeBypassUseOwners(object __instance, ref bool __result) + { + try + { + Tag tag; + + if (__instance != null && Tagged.TryGetValue(__instance, out tag) && tag.Lock.HasValue) + { + // Locked to the first attacker means the owner rules are NOT bypassed. + __result = !tag.Lock.Value; + return false; + } + } + catch (Exception) + { + } + + return true; + } + + /// The pick route's settings, on a copy of the request's profile. + private void CopySettingsOnto(object request, Tag tag) + { + if (!tag.Pvp.HasValue && !tag.Npcs.HasValue && !tag.NpcCount.HasValue && !tag.DespawnMinutes.HasValue) + return; + + object copy = CopyProfile(F(request, "Profile")); + ApplySettings(F(copy, "Options"), tag); + S(request, "Profile", copy); + } + + // ── The plugin's own hooks, for the tags' bookkeeping ──────────────── + + private void OnRaidableBaseEnded(Vector3 location, int level, bool allowPvp, string id, float a, float b, float loadTime, ulong ownerId, + BasePlayer owner, List raiders, List intruders, List entities, string baseName, + DateTime spawn, DateTime despawn, float protectionRadius, int lootRemaining) + { + foreach (Tag tag in _byId.Values) + { + if (tag.Raid != null && tag.EndedAt < 0f && Distance2D(tag.Location, location) <= 2f) + tag.EndedAt = Time.realtimeSinceStartup; + } + + // Forget what ended long ago, so the table stays the size of what is live. + var stale = new List(); + + foreach (Tag tag in _byId.Values) + { + if (tag.EndedAt >= 0f && Time.realtimeSinceStartup - tag.EndedAt > EndedTagSeconds) + stale.Add(tag.Id); + } + + foreach (string gone in stale) + { + if (_byId.TryGetValue(gone, out Tag tag) && tag.Raid != null) + Tagged.Remove(tag.Raid); + + _byId.Remove(gone); + } + } + + // ── Small things ───────────────────────────────────────────────────── + + private static bool IsOpen(object raid) + { + if (raid == null || raid is UnityEngine.Object unity && unity == null) + return false; + + return !(F(raid, "IsDespawning") is bool despawning && despawning); + } + + private static bool Flag(object o, string name) => F(o, name) is bool on && on; + + private static object F(object o, string name) + { + if (o == null) + return null; + + Type type = o.GetType(); + FieldInfo field = type.GetField(name, Any); + + if (field != null) + return field.GetValue(o); + + PropertyInfo property = type.GetProperty(name, Any); + return property != null && property.GetIndexParameters().Length == 0 ? property.GetValue(o) : null; + } + + private static void S(object o, string name, object value) + { + if (o == null) + return; + + Type type = o.GetType(); + FieldInfo field = type.GetField(name, Any); + + if (field != null) + { + field.SetValue(o, value); + return; + } + + PropertyInfo property = type.GetProperty(name, Any); + + if (property != null && property.CanWrite) + property.SetValue(o, value); + } + + private static readonly MethodInfo MemberwiseCloneMethod = typeof(object).GetMethod("MemberwiseClone", BindingFlags.Instance | BindingFlags.NonPublic); + + private static object ShallowCopy(object o) => o == null ? null : MemberwiseCloneMethod.Invoke(o, null); + + /// + /// A copy of a profile one spawn may change without touching the profile every other + /// base shares (§11.4.4). The profile and its options are copied member by member, so the + /// loot list, the spawn table and every value the plugin computes after it reads a + /// profile are carried over as they are — a JSON round trip lost those, and the base it + /// opened broke the plugin's damage hooks on the rig (build, 2026-10-16). The three + /// objects a spawn's settings write into (Setup, NPC, DespawnOptions) + /// are copied again, which is what the plugin's own shallow Clone() does not do and + /// why its height leaked into the shared profile (§11.4.4). Returns the profile's copy; + /// its Options is the options' copy. + /// + private static object CopyProfile(object profile) + { + object copy = ShallowCopy(profile); + object options = ShallowCopy(F(profile, "Options")); + + foreach (string nested in new[] { "Setup", "NPC", "DespawnOptions" }) + { + object inner = F(options, nested); + + if (inner != null) + S(options, nested, ShallowCopy(inner)); + } + + S(copy, "Options", options); + return copy; + } + + private static Dictionary Refuse(string reason, string message) + { + return new Dictionary { { "ok", false }, { "reason", reason }, { "message", message } }; + } + + private static string Text(Dictionary req, string key) + { + return req != null && req.TryGetValue(key, out object value) && value != null ? value.ToString() : null; + } + + private static double? Number(Dictionary req, string key) + { + string text = Text(req, key); + double value; + return text != null && double.TryParse(text, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out value) + ? value + : (double?)null; + } + + private static int? IntOf(Dictionary req, string key) + { + double? value = Number(req, key); + return value.HasValue ? (int)Math.Round(value.Value) : (int?)null; + } + + private static bool? BoolOf(Dictionary req, string key) + { + if (req == null || !req.TryGetValue(key, out object value) || value == null) + return null; + + if (value is bool b) + return b; + + string text = value.ToString().Trim().ToLowerInvariant(); + return text == "true" || text == "1" ? true : text == "false" || text == "0" ? false : (bool?)null; + } + + private static float Distance2D(Vector3 a, Vector3 b) + { + float dx = a.x - b.x; + float dz = a.z - b.z; + return Mathf.Sqrt(dx * dx + dz * dz); + } + + // ── Console ────────────────────────────────────────────────────────── + + [ConsoleCommand("rgr.status")] + private void StatusCommand(ConsoleSystem.Arg arg) + { + if (arg.Connection != null && !arg.IsAdmin) + return; + + int open = 0, queued = 0; + + foreach (Tag tag in _byId.Values) + { + string live = LiveState(tag.Id); + + if (live == "open") + open++; + else if (live == "queued") + queued++; + } + + arg.ReplyWith($"Runic Gateway Raids {Version}: {_state}{(_reason != null ? $" ({_reason})" : "")}" + + (_state == "patched" ? $"; {(_spawnApi != null ? "paid" : "free")} spawn call; grid {(GridReady() ? "ready" : "building")}; " + + $"{ProfileTable().Count} profiles; event bases open {open}, on their way {queued}" : "")); + } + } +} 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') +})