feat(bridge): Dangerous Treasures on protocol 14 (PLAN_REDESIGNS §14.3.4) #38

Merged
whitlocktech merged 1 commits from feat/dangerous-treasures into edge 2026-10-15 07:05:04 -05:00
2 changed files with 470 additions and 6 deletions

View File

@@ -119,6 +119,13 @@ HunterZ's `Dynamic PVP` (which needs ZoneManager and TruePVE) is optional too. W
PvP zones it opens around a downed helicopter, a Bradley, cargo and the rest reach the site's feed and
its map. The bridge only reads them: it never makes, maps or erases one of Dynamic PVP's zones.
nivex's `Dangerous Treasures` is optional as well. Where it is loaded, its treasure events reach the
site's feed, its map and the *Treasure Hunter* title, and an event can open one (`dt.start`) through
DT's own `dtevent` console command; DT picks the spot and despawns its own chest. For about ten minutes
after DT loads it is still finding its spots, and a start then is answered "not ready yet". A framework
that cannot run another plugin's console command from code (`rg.world` says `consolePlugins=False`)
cannot open one.
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`).

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;