diff --git a/README.md b/README.md index df9444e..f420199 100644 --- a/README.md +++ b/README.md @@ -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`). diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index 5d578db..3c66c8b 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -71,7 +71,10 @@ namespace Oxide.Plugins /// world.zone's truePve and the integrations.truePve it needs; and /// Dynamic PVP (§20.7, PLAN_REDESIGNS.md §14.3.3): pvp.zone.created and /// pvp.zone.deleted, map.live's pvp layer and - /// integrations.dynamicPvp. + /// 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. /// /// 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 /// Protocol 14 (D356): airdrops this player looted first (Scavenger). public int DropsLooted; + + /// Protocol 14 (D375): Dangerous Treasures chests this player emptied (Treasure Hunter). + public int TreasuresWon; public readonly Dictionary Crafted = new Dictionary(); /// Stage 4 (D247): kills of RunicNPC's NPCs, by profile name. Also in . @@ -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"; + + /// DT's console command, as its config ships it. + private const string DtCommand = "dtevent"; + + /// How long after DT loads a start that found no spot is "not ready" rather than "no spot" (D365). + private const float DtWarmUpSeconds = 15f * 60f; + + /// How far from a running chest a winner may stand and still be placed at it. + private const float DtWinReach = 100f; + + /// The most invaders a win names; the rest are counted in invaderCount. + private const int DtInvadersMax = 32; + + /// Every DT chest is a large wooden box (`box.wooden.large`), for the seed at load. + private const uint DtChestPrefabId = 2206646561u; + + /// How long a start's key is remembered: seven days, as a payment's is. + private const long DtStartMemoryMs = 7L * 24L * 60L * 60L * 1000L; + + /// One running chest: where it is, and the step's key when a step opened it. + private sealed class Treasure + { + public UnityEngine.Vector3 At; + public string Key; + } + + /// The running chests, oldest first. Main thread only. + private readonly List _treasures = new List(); + + /// One start a step asked for, kept for its key as a world call is. Persisted as it is. + private sealed class DtStart + { + public string Key; + public int Level; + public float X; + public float Z; + public long AtMs; + } + + private Dictionary _dtStarts = new Dictionary(StringComparer.Ordinal); + + /// The key of the `dtevent` this plugin is running now, for the start hook. Null otherwise. + private string _dtStarting; + + /// The chest the start hook heard while our `dtevent` ran. + private Treasure _dtOpened; + + /// Time.realtimeSinceStartup when this plugin first saw DT loaded; negative while it is not. + private float _dtSeenAt = -1f; + + private Plugin DangerousTreasuresPlugin() + { + return plugins.Find("DangerousTreasures"); + } + + private bool IsDangerousTreasures(Plugin plugin) + { + return plugin != null && plugin != this && plugin.Name == "DangerousTreasures"; + } + + /// + /// 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. + /// + 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 ` opens one chest, so the key is spent on the first. + if (_dtStarting != null) + { + _dtOpened = treasure; + _dtStarting = null; + } + + if (!WorldEventsLive()) + return; + + Dictionary frame = Frame(DtStartedKind, "event"); + PutPosition(frame, at); + + if (treasure.Key != null) + frame["key"] = treasure.Key; + + EmitFrame(frame); + } + + private void OnDangerousEventWon(BasePlayer looter, List invaders) + { + MarkHook("OnDangerousEventWon"); + + if (looter == null || looter.IsNpc) + return; + + TallyFor(looter).TreasuresWon++; + + if (!WorldEventsLive()) + return; + + Treasure chest = NearestTreasure(looter.transform.position, DtWinReach); + Dictionary frame = Frame(DtWonKind, "event"); + frame["steamId"] = SteamIdOf(looter); + frame["name"] = looter.displayName; + + var named = new List(); + 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 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; + } + + /// + /// 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 TreasureChest component. + /// + 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}"); + } + + /// The map's treasure rows, for its world layer. + private void AddTreasureRows(List world) + { + foreach (Treasure t in _treasures) + { + Dictionary 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(_dtStarts.Values), "Dangerous Treasures starts"); + } + + private void LoadDtStarts() + { + _dtStarts = new Dictionary(StringComparer.Ordinal); + List starts = ReadDataFile(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(); + + foreach (KeyValuePair 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(); + } + + /// + /// Main thread. Open one Dangerous Treasures event for a run's step: { key, level }, + /// 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. + /// + private void HandleDtStart(Dictionary 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); + } + + /// Why our dtevent opened nothing: no spot (yet), or DT's own refusal in its words. + 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 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 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 reply = Frame("map.live", "reply"); reply["mapKey"] = MapKey(); reply["world"] = world;