feat(bridge): Dangerous Treasures on protocol 14 (PLAN_REDESIGNS §14.3.4)
All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 14s

Stage 5 of the §14.3 integration plan (docs rust-link/PROTOCOL.md §20.8).

- Three events from DT's own hooks: dt.started { x, z, key? },
  dt.won { steamId, name, invaders (max 32), invaderCount, x, z } and
  dt.ended { x, z }. None while the world is not live; a win still counts.
- The start hook fires inside the call that opens the chest, so the key a
  dt.start is running joins its start exactly, not by a ten-second window.
- dt.start { key, level 0-2 } runs `dtevent <level>` through
  ConsoleSystem.Run and answers dt.opened { key, level, repeat, x, z }. A key
  already answered is answered again and opens nothing (kept seven days in
  dt-starts.json). Refusals: malformed, bad-level, dt-missing,
  console-unavailable (D363), dt-not-ready (nothing opened, DT's own "Manual
  Event Failed" reply, under fifteen minutes since DT loaded, D365),
  dt-no-spot (after), refused (anything else DT said).
- The tally gains treasuresWon (Treasure Hunter, D375); map.live's world gains
  kind "treasure" for each running chest, seeded at load from DT's chests in
  the world (DT has no API that lists them).
- Hello's integrations gain dangerousTreasures, sent again on DT's load and
  unload. rg.world prints dangerousTreasures, treasures, dtStarts and
  dtWarmingUp. README lists Dangerous Treasures.

checkPlugin: 81 hooks, every one void and listed; 18 tests. Compiled and
loaded on both rigs (Rust 25902425, Oxide 2.0.7830, Carbon 2.0.262);
rg.world read dangerousTreasures=False treasures=0, then both went back on
the protocol-13 bridge.

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 06:45:25 -05:00
parent 8e8553c589
commit 72f51c3d9e
2 changed files with 470 additions and 6 deletions

View File

@@ -71,7 +71,10 @@ namespace Oxide.Plugins
/// <c>world.zone</c>'s <c>truePve</c> and the <c>integrations.truePve</c> it needs; and
/// Dynamic PVP (§20.7, PLAN_REDESIGNS.md §14.3.3): <c>pvp.zone.created</c> and
/// <c>pvp.zone.deleted</c>, <c>map.live</c>'s <c>pvp</c> layer and
/// <c>integrations.dynamicPvp</c>.
/// <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>.
/// </para>
/// </summary>
private const int ProtocolVersion = 14;
@@ -277,7 +280,9 @@ namespace Oxide.Plugins
"OnCargoPlaneSignaled", "OnAirdrop", "OnSupplyDropDropped", "OnSupplyDropLanded",
"OnDeepSeaOpened", "OnDeepSeaClosed",
// Protocol 14, Dynamic PVP's zones (§20.7).
"OnCreatedDynamicPVP", "OnDeletedDynamicPVP"
"OnCreatedDynamicPVP", "OnDeletedDynamicPVP",
// Protocol 14, Dangerous Treasures (§20.8).
"OnDangerousEventStarted", "OnDangerousEventWon", "OnDangerousEventEnded"
};
private void Init()
@@ -571,6 +576,7 @@ namespace Oxide.Plugins
LoadWorld();
LoadRewards();
LoadWorldCalls();
LoadDtStarts();
}
// ---- the outbound queue ----
@@ -1069,6 +1075,12 @@ namespace Oxide.Plugins
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 "tally.open":
// Protocol 10 (PLAN.md §29). Who takes part in a run, counted by the game.
HandleTallyOpen(cmd, reqId);
@@ -1635,6 +1647,8 @@ namespace Oxide.Plugins
// The map's sets are seeded here for the same reason: a hotload hears no spawns.
SeedMapSets();
SeedPvpZones("load");
SeedTreasures("load");
NoteDtSeen(DangerousTreasuresPlugin() != null);
// Protocol 14: the console probe (D363) and the world events' sweep. Before hello, which
// carries what the probe found.
@@ -2875,6 +2889,9 @@ namespace Oxide.Plugins
/// <summary>Protocol 14 (D356): airdrops this player looted first (Scavenger).</summary>
public int DropsLooted;
/// <summary>Protocol 14 (D375): Dangerous Treasures chests this player emptied (Treasure Hunter).</summary>
public int TreasuresWon;
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>
@@ -2942,7 +2959,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
&& BestPvpM <= 0f && BestPveM <= 0f && CratesHacked == 0 && DropsLooted == 0 && TreasuresWon == 0
&& Crafted.Count == 0 && Built == 0 && Repaired == 0 && Healed == 0
&& Rockets == 0 && Explosives == 0 && Missions == 0 && NpcProfileKills.Count == 0
&& !HasBalance;
@@ -3047,6 +3064,8 @@ namespace Oxide.Plugins
frame["cratesHacked"] = tally.CratesHacked;
if (tally.DropsLooted > 0)
frame["dropsLooted"] = tally.DropsLooted;
if (tally.TreasuresWon > 0)
frame["treasuresWon"] = tally.TreasuresWon;
if (tally.Crafted.Count > 0)
frame["crafted"] = tally.Crafted;
if (tally.Built > 0)
@@ -5177,7 +5196,7 @@ 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))
if (IsZonePlugin(plugin) || IsRunicNpc(plugin) || IsEconomics(plugin) || IsBackpacks(plugin) || IsTruePve(plugin) || IsDynamicPvp(plugin) || IsDangerousTreasures(plugin))
NextTick(ResendHello);
// Dynamic PVP raises its creates as it opens its zones, which at a load are its
@@ -5185,6 +5204,14 @@ namespace Oxide.Plugins
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"));
}
// Economics loaded after players connected: what they hold now is not otherwise known
// until it moves (D336).
if (IsEconomics(plugin))
@@ -5212,13 +5239,20 @@ namespace Oxide.Plugins
if (plugin != null && plugin.Name == "BetterChat")
_titlesRegistered = false;
if (IsZonePlugin(plugin) || IsRunicNpc(plugin) || IsEconomics(plugin) || IsBackpacks(plugin) || IsTruePve(plugin) || IsDynamicPvp(plugin))
if (IsZonePlugin(plugin) || IsRunicNpc(plugin) || IsEconomics(plugin) || IsBackpacks(plugin) || IsTruePve(plugin) || IsDynamicPvp(plugin) || IsDangerousTreasures(plugin))
NextTick(ResendHello);
// 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);
}
ConfigWriteJob job = _configJob;
if (job == null || plugin == null)
@@ -8943,6 +8977,423 @@ namespace Oxide.Plugins
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";
/// <summary>DT's console command, as its config ships it.</summary>
private const string DtCommand = "dtevent";
/// <summary>How long after DT loads a start that found no spot is "not ready" rather than "no spot" (D365).</summary>
private const float DtWarmUpSeconds = 15f * 60f;
/// <summary>How far from a running chest a winner may stand and still be placed at it.</summary>
private const float DtWinReach = 100f;
/// <summary>The most invaders a win names; the rest are counted in <c>invaderCount</c>.</summary>
private const int DtInvadersMax = 32;
/// <summary>Every DT chest is a large wooden box (`box.wooden.large`), for the seed at load.</summary>
private const uint DtChestPrefabId = 2206646561u;
/// <summary>How long a start's key is remembered: seven days, as a payment's is.</summary>
private const long DtStartMemoryMs = 7L * 24L * 60L * 60L * 1000L;
/// <summary>One running chest: where it is, and the step's key when a step opened it.</summary>
private sealed class Treasure
{
public UnityEngine.Vector3 At;
public string Key;
}
/// <summary>The running chests, oldest first. Main thread only.</summary>
private readonly List<Treasure> _treasures = new List<Treasure>();
/// <summary>One start a step asked for, kept for its key as a world call is. Persisted as it is.</summary>
private sealed class DtStart
{
public string Key;
public int Level;
public float X;
public float Z;
public long AtMs;
}
private Dictionary<string, DtStart> _dtStarts = new Dictionary<string, DtStart>(StringComparer.Ordinal);
/// <summary>The key of the `dtevent` this plugin is running now, for the start hook. Null otherwise.</summary>
private string _dtStarting;
/// <summary>The chest the start hook heard while our `dtevent` ran.</summary>
private Treasure _dtOpened;
/// <summary><c>Time.realtimeSinceStartup</c> when this plugin first saw DT loaded; negative while it is not.</summary>
private float _dtSeenAt = -1f;
private Plugin DangerousTreasuresPlugin()
{
return plugins.Find("DangerousTreasures");
}
private bool IsDangerousTreasures(Plugin plugin)
{
return plugin != null && plugin != this && plugin.Name == "DangerousTreasures";
}
/// <summary>
/// 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.
/// </summary>
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 <level>` opens one chest, so the key is spent on the first.
if (_dtStarting != null)
{
_dtOpened = treasure;
_dtStarting = null;
}
if (!WorldEventsLive())
return;
Dictionary<string, object> frame = Frame(DtStartedKind, "event");
PutPosition(frame, at);
if (treasure.Key != null)
frame["key"] = treasure.Key;
EmitFrame(frame);
}
private void OnDangerousEventWon(BasePlayer looter, List<ulong> invaders)
{
MarkHook("OnDangerousEventWon");
if (looter == null || looter.IsNpc)
return;
TallyFor(looter).TreasuresWon++;
if (!WorldEventsLive())
return;
Treasure chest = NearestTreasure(looter.transform.position, DtWinReach);
Dictionary<string, object> frame = Frame(DtWonKind, "event");
frame["steamId"] = SteamIdOf(looter);
frame["name"] = looter.displayName;
var named = new List<string>();
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<string, object> 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;
}
/// <summary>
/// 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 <c>TreasureChest</c> component.
/// </summary>
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}");
}
/// <summary>The map's <c>treasure</c> rows, for its world layer.</summary>
private void AddTreasureRows(List<object> world)
{
foreach (Treasure t in _treasures)
{
Dictionary<string, object> 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<DtStart>(_dtStarts.Values), "Dangerous Treasures starts");
}
private void LoadDtStarts()
{
_dtStarts = new Dictionary<string, DtStart>(StringComparer.Ordinal);
List<DtStart> starts = ReadDataFile<DtStart>(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<string>();
foreach (KeyValuePair<string, DtStart> 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();
}
/// <summary>
/// Main thread. Open one Dangerous Treasures event for a run's step: <c>{ key, level }</c>,
/// 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.
/// </summary>
private void HandleDtStart(Dictionary<string, object> 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);
}
/// <summary>Why our <c>dtevent</c> opened nothing: no spot (yet), or DT's own refusal in its words.</summary>
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<string, object> 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<string, object> 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);
}
// ---- the zone's messages (D193) ----
//
// ZoneManager can say a zone's enter and leave messages itself, but only to chat unless its
@@ -10247,6 +10698,7 @@ namespace Oxide.Plugins
$"zones={zones} crates={crates} npcs={npcs} zoneManager={(ZoneManagerPlugin() != null)} " +
$"truePve={(TruePvePlugin() != null)} truePveLeftover={_truePvePending.Count} " +
$"dynamicPvp={(DynamicPvpPlugin() != null)} pvpZones={_pvpZones.Count} " +
$"dangerousTreasures={(DangerousTreasuresPlugin() != null)} treasures={_treasures.Count} dtStarts={_dtStarts.Count} dtWarmingUp={DtWarmingUp()} " +
$"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 +
@@ -13857,7 +14309,9 @@ namespace Oxide.Plugins
// 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()) }
{ "dynamicPvp", IntegrationBody(DynamicPvpPlugin()) },
// Protocol 14 (§14.3.4): whether a step may open a Dangerous Treasures event here.
{ "dangerousTreasures", IntegrationBody(DangerousTreasuresPlugin()) }
};
}
@@ -15256,6 +15710,9 @@ 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);
Dictionary<string, object> reply = Frame("map.live", "reply");
reply["mapKey"] = MapKey();
reply["world"] = world;