From 4e83def0dde5fa9b53022f502d4afe40bf62e2ea Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 15 Oct 2026 12:26:36 -0500 Subject: [PATCH] =?UTF-8?q?feat(bridge):=20Guarded=20Crate=20on=20protocol?= =?UTF-8?q?=2014=20(PLAN=5FREDESIGNS=20=C2=A714.3.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 6 of the §14.3 integration plan. Spec: docs rust-link/PROTOCOL.md §20.9. - gc.started, gc.ended (the clearer, or nobody) and gc.failed from Guarded Crate's three hooks; the start's key joins exactly, inside the call. - gc.place { key, event, location as a world call } runs ` position x,y,z`; refuses npcspawn-missing itself, since Guarded Crate's `position` does not check and the crate would land unguarded (D364). - The running set is checked against Guarded Crate's private list (by reflection): the hotload seed, `stop all` (no hook), and which event ended. - Hello's integrations.guardedCrate with npcSpawn and the events from its data file; the tally's guardedWon (Warden); map.live's guarded rows; rg.world. - Guarded Crate's cargo plane no longer says airdrop.called. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- README.md | 8 + overlay/oxide/plugins/RunicGateway.cs | 735 +++++++++++++++++++++++++- 2 files changed, 737 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index f420199..2a65cad 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,14 @@ after DT loads it is still finding its spots, and a start then is answered "not 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. + 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`). diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index 3c66c8b..ab2210f 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -74,7 +74,10 @@ namespace Oxide.Plugins /// 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. + /// 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. /// /// private const int ProtocolVersion = 14; @@ -282,7 +285,9 @@ namespace Oxide.Plugins // Protocol 14, Dynamic PVP's zones (§20.7). "OnCreatedDynamicPVP", "OnDeletedDynamicPVP", // Protocol 14, Dangerous Treasures (§20.8). - "OnDangerousEventStarted", "OnDangerousEventWon", "OnDangerousEventEnded" + "OnDangerousEventStarted", "OnDangerousEventWon", "OnDangerousEventEnded", + // Protocol 14, Guarded Crate (§20.9). + "OnGuardedCrateEventStart", "OnGuardedCrateEventEnded", "OnGuardedCrateEventFailed" }; private void Init() @@ -577,6 +582,7 @@ namespace Oxide.Plugins LoadRewards(); LoadWorldCalls(); LoadDtStarts(); + LoadGcPlacements(); } // ---- the outbound queue ---- @@ -1081,6 +1087,12 @@ namespace Oxide.Plugins 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 "tally.open": // Protocol 10 (PLAN.md §29). Who takes part in a run, counted by the game. HandleTallyOpen(cmd, reqId); @@ -1649,6 +1661,7 @@ namespace Oxide.Plugins SeedPvpZones("load"); SeedTreasures("load"); NoteDtSeen(DangerousTreasuresPlugin() != null); + SeedGuardedCrates("load"); // Protocol 14: the console probe (D363) and the world events' sweep. Before hello, which // carries what the probe found. @@ -2892,6 +2905,9 @@ namespace Oxide.Plugins /// 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; public readonly Dictionary Crafted = new Dictionary(); /// Stage 4 (D247): kills of RunicNPC's NPCs, by profile name. Also in . @@ -2959,7 +2975,7 @@ namespace Oxide.Plugins { return Gathered.Count == 0 && NpcKills == 0 && Structures == 0 && AnimalKills == 0 && WeaponKills.Count == 0 && Headshots == 0 - && BestPvpM <= 0f && BestPveM <= 0f && CratesHacked == 0 && DropsLooted == 0 && TreasuresWon == 0 + && BestPvpM <= 0f && BestPveM <= 0f && CratesHacked == 0 && DropsLooted == 0 && TreasuresWon == 0 && GuardedWon == 0 && Crafted.Count == 0 && Built == 0 && Repaired == 0 && Healed == 0 && Rockets == 0 && Explosives == 0 && Missions == 0 && NpcProfileKills.Count == 0 && !HasBalance; @@ -3066,6 +3082,8 @@ namespace Oxide.Plugins frame["dropsLooted"] = tally.DropsLooted; if (tally.TreasuresWon > 0) frame["treasuresWon"] = tally.TreasuresWon; + if (tally.GuardedWon > 0) + frame["guardedWon"] = tally.GuardedWon; if (tally.Crafted.Count > 0) frame["crafted"] = tally.Crafted; if (tally.Built > 0) @@ -5196,7 +5214,8 @@ namespace Oxide.Plugins // 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). - if (IsZonePlugin(plugin) || IsRunicNpc(plugin) || IsEconomics(plugin) || IsBackpacks(plugin) || IsTruePve(plugin) || IsDynamicPvp(plugin) || IsDangerousTreasures(plugin)) + // Guarded Crate and NpcSpawn too: a placement is refused without either (D364). + if (IsZonePlugin(plugin) || IsRunicNpc(plugin) || IsEconomics(plugin) || IsBackpacks(plugin) || IsTruePve(plugin) || IsDynamicPvp(plugin) || IsDangerousTreasures(plugin) || IsGuardedCrate(plugin)) NextTick(ResendHello); // Dynamic PVP raises its creates as it opens its zones, which at a load are its @@ -5239,9 +5258,13 @@ namespace Oxide.Plugins if (plugin != null && plugin.Name == "BetterChat") _titlesRegistered = false; - if (IsZonePlugin(plugin) || IsRunicNpc(plugin) || IsEconomics(plugin) || IsBackpacks(plugin) || IsTruePve(plugin) || IsDynamicPvp(plugin) || IsDangerousTreasures(plugin)) + if (IsZonePlugin(plugin) || IsRunicNpc(plugin) || IsEconomics(plugin) || IsBackpacks(plugin) || IsTruePve(plugin) || IsDynamicPvp(plugin) || IsDangerousTreasures(plugin) || IsGuardedCrate(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(); @@ -9394,6 +9417,687 @@ namespace Oxide.Plugins 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); + } + // ---- the zone's messages (D193) ---- // // ZoneManager can say a zone's enter and leave messages itself, but only to chat unless its @@ -10683,6 +11387,9 @@ namespace Oxide.Plugins { int zones = 0, crates = 0, npcs = 0; + // Guarded Crate's `stop all` ends events without a hook; count what is really running. + SeedGuardedCrates(null); + foreach (WorldEntry e in _world.Values) { if (e.Kind == WorldKindZone) @@ -10699,6 +11406,8 @@ namespace Oxide.Plugins $"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} " + $"wipeId={(_wipeId ?? "none")} calls={_worldCalls.Count} consolePlugins={_consolePlugins} " + $"credits={_vehicleCredit.Count} chinookCrates={_chinookCrates.Count} said={_worldEventsSaid} " + $"worldEvents={(WorldEventsLive() ? "live" : $"quiet (ready={_worldReady} quitting={Rust.Application.isQuitting} loadingSave={Rust.Application.isLoadingSave})")}" + Environment.NewLine + @@ -14311,7 +15020,9 @@ namespace Oxide.Plugins // 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()) } + { "dangerousTreasures", IntegrationBody(DangerousTreasuresPlugin()) }, + // Protocol 14 (§14.3.5): whether a step may place a guarded crate here, and which. + { "guardedCrate", GuardedCrateBody() } }; } @@ -15713,6 +16424,9 @@ namespace Oxide.Plugins // 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; @@ -16873,6 +17587,15 @@ namespace Oxide.Plugins 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); -- 2.49.1