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