feat(bridge): Guarded Crate on protocol 14 (PLAN_REDESIGNS §14.3.5)
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 9s

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 `<command> position
  <event> 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-10-15 12:26:36 -05:00
parent 7b1eabcd8b
commit 4e83def0dd
2 changed files with 737 additions and 6 deletions

View File

@@ -74,7 +74,10 @@ namespace Oxide.Plugins
/// <c>integrations.dynamicPvp</c>; and Dangerous Treasures (§20.8, PLAN_REDESIGNS.md
/// §14.3.4): <c>dt.started</c>, <c>dt.won</c> and <c>dt.ended</c>, <c>dt.start</c>,
/// <c>map.live</c>'s <c>treasure</c> rows, the tally's <c>treasuresWon</c> and
/// <c>integrations.dangerousTreasures</c>.
/// <c>integrations.dangerousTreasures</c>; and Guarded Crate (§20.9, PLAN_REDESIGNS.md
/// §14.3.5): <c>gc.started</c>, <c>gc.ended</c> and <c>gc.failed</c>, <c>gc.place</c>,
/// <c>map.live</c>'s <c>guarded</c> rows, the tally's <c>guardedWon</c> and
/// <c>integrations.guardedCrate</c> with NpcSpawn beside it.
/// </para>
/// </summary>
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
/// <summary>Protocol 14 (D375): Dangerous Treasures chests this player emptied (Treasure Hunter).</summary>
public int TreasuresWon;
/// <summary>Protocol 14 (D375): guarded crates this player cleared, by killing the last guard (Warden).</summary>
public int GuardedWon;
public readonly Dictionary<string, int> Crafted = new Dictionary<string, int>();
/// <summary>Stage 4 (D247): kills of RunicNPC's NPCs, by profile name. Also in <see cref="NpcKills"/>.</summary>
@@ -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";
/// <summary>Guarded Crate's console command when its config does not say.</summary>
private const string GcDefaultCommand = "gcrate";
/// <summary>How far from a running event its crate or winner may be and still be placed at it.</summary>
private const float GcEndReach = 100f;
/// <summary>How long a placement's key is remembered: seven days, as a payment's is.</summary>
private const long GcPlaceMemoryMs = 7L * 24L * 60L * 60L * 1000L;
/// <summary>One running guarded crate: where, and the step's key when a step placed it.</summary>
private sealed class GuardedCrateEvent
{
public UnityEngine.Vector3 At;
public string Key;
}
/// <summary>The running guarded crates, oldest first. Main thread only.</summary>
private readonly List<GuardedCrateEvent> _guardedCrates = new List<GuardedCrateEvent>();
/// <summary>One placement a step asked for, kept for its key. Persisted as a DT start is.</summary>
private sealed class GcPlacement
{
public string Key;
public string Event;
public float X;
public float Z;
public long AtMs;
}
private Dictionary<string, GcPlacement> _gcPlacements = new Dictionary<string, GcPlacement>(StringComparer.Ordinal);
/// <summary>The key of the `gcrate position` this plugin is running now, for the start hook. Null otherwise.</summary>
private string _gcStarting;
/// <summary>The event the start hook heard while our `gcrate position` ran.</summary>
private GuardedCrateEvent _gcOpened;
/// <summary>Guarded Crate's events (its data file) and console command (its config), each read again only when its file changes.</summary>
private List<string> _gcEventNames;
private DateTime _gcEventNamesAt;
private string _gcCommand;
private DateTime _gcCommandAt;
/// <summary>Whether Guarded Crate's own list of events could be read the last time it was asked.</summary>
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<string, object> 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<string, object> 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<string, object> frame = Frame(GcFailedKind, "event");
PutPosition(frame, at);
EmitFrame(frame);
}
/// <summary>
/// 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.
/// </summary>
private GuardedCrateEvent EndedGuardedCrate(BasePlayer player, HackableLockedCrate crate)
{
List<KeyValuePair<UnityEngine.Vector3, string>> listed = GuardedCrateList();
if (listed != null)
foreach (KeyValuePair<UnityEngine.Vector3, string> 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;
}
/// <summary>
/// Main thread. Guarded Crate's own running events, as (position, state) pairs: its
/// private <c>EventManager.Instances</c>, 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.
/// </summary>
private List<KeyValuePair<UnityEngine.Vector3, string>> 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<KeyValuePair<UnityEngine.Vector3, string>>();
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<UnityEngine.Vector3, string>(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;
}
}
/// <summary>
/// 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.
/// </summary>
private void SeedGuardedCrates(string why)
{
if (GuardedCratePlugin() == null)
{
_guardedCrates.Clear();
return;
}
List<KeyValuePair<UnityEngine.Vector3, string>> listed = GuardedCrateList();
if (listed == null)
return;
var running = new List<GuardedCrateEvent>();
foreach (KeyValuePair<UnityEngine.Vector3, string> 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}");
}
/// <summary>The map's <c>guarded</c> rows, for its world layer.</summary>
private void AddGuardedRows(List<object> 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<string, object> row = At(g.At);
row["kind"] = "guarded";
world.Add(row);
}
}
/// <summary>
/// Hello's <c>integrations.guardedCrate</c>: loaded and version, NpcSpawn's beside it
/// (D364), and <c>events</c>, the event names in Guarded Crate's data file, absent when
/// the file cannot be read.
/// </summary>
private Dictionary<string, object> GuardedCrateBody()
{
Plugin gc = GuardedCratePlugin();
Dictionary<string, object> body = IntegrationBody(gc);
body["npcSpawn"] = IntegrationBody(NpcSpawnPlugin());
if (gc == null)
return body;
List<string> events = GuardedCrateEventNames();
if (events != null)
body["events"] = events;
return body;
}
/// <summary>
/// The event names in Guarded Crate's data file (<c>CrateEventEntries</c>, each row's
/// <c>event display name)</c>, 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).
/// </summary>
private List<string> 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<string> 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<string>();
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;
}
/// <summary>
/// Guarded Crate's console command from its config (<c>command name - (default:
/// gcrate)</c>), or <c>gcrate</c> when the file does not say. Empty when the operator
/// blanked it, which registers no command at all.
/// </summary>
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<GcPlacement>(_gcPlacements.Values), "Guarded Crate placements");
}
private void LoadGcPlacements()
{
_gcPlacements = new Dictionary<string, GcPlacement>(StringComparer.Ordinal);
List<GcPlacement> placed = ReadDataFile<GcPlacement>(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<string>();
foreach (KeyValuePair<string, GcPlacement> 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();
}
/// <summary>
/// Main thread. Place one guarded crate for a run's step: <c>{ key, event }</c> 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.
/// </summary>
private void HandleGcPlace(Dictionary<string, object> 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<string> 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);
}
/// <summary>Why our <c>gcrate position</c> placed nothing, told by Guarded Crate's own messages, read from its lang.</summary>
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)}");
}
/// <summary>One of Guarded Crate's messages, flattened as a reply is, or null.</summary>
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<string, object> 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<string, object> 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<string, object> 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);