From 6952fbf12370adde8eed9fd4fb3570a0bc146c15 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 12 Oct 2026 02:15:23 -0500 Subject: [PATCH 01/14] =?UTF-8?q?feat(bridge):=20protocol=2014=20=E2=80=94?= =?UTF-8?q?=20kits.uses=20and=20reward=20notices=20(PLAN=5FREDESIGNS=20?= =?UTF-8?q?=C2=A72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opens protocol 14 (D332): - kits.uses {kit, steamIds} -> {kit, max, cooldown, asked, exhausted, onCooldown}: counts only, bounded by the sync's own bound (§2.6). - perm.sync notices [{key, steamId, message, delivery}] (D333): the site's whole untold set. Told at once to a player who is awake, else on OnPlayerSleepEnded; popup where PopupNotifications is loaded and chosen, chat otherwise. Told keys persist in notices.json until the site stops sending them; the report lists noticesTold. ProtocolVersion and overlay.toml 13 -> 14; OnPlayerSleepEnded joins ExpectedHooks (checkPlugin: 55 hooks, all void). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- overlay.toml | 8 +- overlay/oxide/plugins/RunicGateway.cs | 311 +++++++++++++++++++++++++- 2 files changed, 313 insertions(+), 6 deletions(-) diff --git a/overlay.toml b/overlay.toml index 55d57c2..daa699b 100644 --- a/overlay.toml +++ b/overlay.toml @@ -26,15 +26,17 @@ # the same change that alters the emitters, exactly as the sidecar bumps # PROTOCOL_VERSION and the module bumps its own constant. # -# Current: 13 — the transport, the read path, identity, the permission mirror, +# Current: 14 — the transport, the read path, identity, the permission mirror, # configuration from the site, first-party clans, a raid frame that names who # lives there, the event leases, the world verbs events place and give back, and # the rewards: a run's participation tally, kit credits and chat (PROTOCOL.md §16), # the map: its picture and everything that moves on it (PROTOCOL.md §17), and the # optional mods: BetterChat titles and group styles, and popups (PROTOCOL.md §18), # and the first player walk's fixes (PROTOCOL.md §19) — opened by a configuration -# save that answers at once and reports its reload as a `config.outcome` frame. -protocol = 13 +# save that answers at once and reports its reload as a `config.outcome` frame, +# and the step editor (PROTOCOL.md §20): `kits.uses`, and the one-time reward +# messages a `perm.sync` carries as `notices`. +protocol = 14 # ── Oxide compatibility ────────────────────────────────────────────────────── # diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index 4217de3..1f61bd3 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -56,8 +56,13 @@ namespace Oxide.Plugins /// PROTOCOL_VERSION in the sidecar, and module.json in Module-Rust. The /// overlay's overlay.toml carries this same number so the installer can refuse /// to pair a sidecar and an overlay that disagree. + /// + /// + /// 14 (PROTOCOL.md §20, the module's PLAN_REDESIGNS.md §2): kits.uses, and the + /// one-time reward messages perm.sync carries as notices (D333). + /// /// - private const int ProtocolVersion = 13; + private const int ProtocolVersion = 14; /// /// Loopback reconnects are cheap, so the ceiling is low. A sidecar restart should cost @@ -250,7 +255,8 @@ namespace Oxide.Plugins "OnExplosiveThrown", "OnExplosiveDropped", "OnMissionSucceeded", "OnEnterZone", "OnExitZone", "OnRgDomesReady", "OnRunicNpcDied", "OnRunicNpcHealth", "OnRunicNpcPlacementChanged", - "OnRunicNpcBossSpawned", "OnRunicNpcBossPhase", "OnRunicNpcBossDied" + "OnRunicNpcBossSpawned", "OnRunicNpcBossPhase", "OnRunicNpcBossDied", + "OnPlayerSleepEnded" }; private void Init() @@ -1043,6 +1049,12 @@ namespace Oxide.Plugins HandleKitsList(reqId); break; + case "kits.uses": + // Protocol 14 (the module's PLAN_REDESIGNS.md §2.6). How many of the given + // players have used a kit up, for the warning beside a kit weekend. + HandleKitsUses(cmd, reqId); + break; + case "chat.say": HandleChatSay(cmd, reqId); break; @@ -3609,6 +3621,15 @@ namespace Oxide.Plugins public bool CreditsWaiting; public readonly List CreditsUnresolved = new List(); + /// + /// The site's untold reward messages for this server (protocol 14, D333), or null when + /// the sync carried none — an older website, whose silence must not cancel anything. + /// + public List Notices; + + /// The keys of already told, for the report. + public readonly List NoticesTold = new List(); + /// /// Whether BetterChat was loaded when this sync was compiled (protocol 12). When it /// was not, no style op is compiled and the report says loaded: false and @@ -4394,6 +4415,38 @@ namespace Oxide.Plugins } } + // ── The reward messages (protocol 14, D333) ──────────────────────── + // + // "You earned kit vip", said once to each player, now or when they next wake up. The + // site's WHOLE untold set, like the credits: a notice it stops sending is one it has + // been told about, or one whose reward was withdrawn before anybody heard of it. + if (cmd.ContainsKey("notices")) + { + job.Notices = new List(); + + foreach (Dictionary row in Rows(cmd, "notices")) + { + string key = StringField(row, "key"); + string steamId = StringField(row, "steamId"); + string message = Flatten(StringField(row, "message") ?? ""); + ulong id; + + if (string.IsNullOrEmpty(key) || key.Length > NoticeKeyMax || !ulong.TryParse(steamId, out id) || message.Length == 0) + continue; + + if (job.Notices.Count >= NoticesMax) + break; + + job.Notices.Add(new RewardNotice + { + Key = key, + SteamId = steamId, + Message = message.Length > MaxChat() ? message.Substring(0, MaxChat()) : message, + Delivery = StringField(row, "delivery") == DeliveryPopup ? DeliveryPopup : DeliveryChat + }); + } + } + if (job.Ops.Count > PermMaxOps) { EmitPermError(reqId, "too-large"); @@ -4470,6 +4523,20 @@ namespace Oxide.Plugins } } + if (job.Notices != null) + { + try + { + ApplyNotices(job); + } + catch (Exception ex) + { + // The grants have landed and must still be reported; an untold notice is told + // by the next sync, or when its player wakes. + Puts($"could not tell reward notices: {Flatten(ex.Message)}"); + } + } + SendPermReport(job); } @@ -4817,6 +4884,12 @@ namespace Oxide.Plugins report["creditsWaiting"] = job.CreditsWaiting; } + // Protocol 14 (D333). Every notice of this sync's set that its player has been told — + // at this sync or before it — so the site marks it told and stops sending it. Present + // only when the sync carried `notices`, like the credit counts. + if (job.Notices != null) + report["noticesTold"] = job.NoticesTold; + // Protocol 12. Always present, so a site can tell "BetterChat is absent" from "this // plugin predates styles" — the second has no `chat` at all. var chat = new Dictionary { { "loaded", job.ChatLoaded } }; @@ -9768,9 +9841,41 @@ namespace Oxide.Plugins public int Consumed; } + /// + /// One "you earned a reward" message for one player (protocol 14, D333). The key names + /// the reward — a run and its step — so a message is told once however many syncs carry + /// it. + /// + private sealed class RewardNotice + { + public string Key; + public string SteamId; + public string Message; + public string Delivery; + } + + /// The most notices one sync carries; the rest wait for the next. + private const int NoticesMax = 2000; + + /// The longest notice key. The site's are short (run:N:step:N). + private const int NoticeKeyMax = 128; + private Dictionary _runTallies = new Dictionary(); private Dictionary _credits = new Dictionary(); + /// + /// The site's untold notices, by key, as the last sync carried them. In memory only: the + /// site sends its whole untold set on every sync, so a reload loses nothing it will not + /// hear again. + /// + private Dictionary _notices = new Dictionary(); + + /// + /// The keys already told, kept on disk until the site stops sending them. A reload + /// between telling a player and the report that says so would otherwise tell them twice. + /// + private HashSet _noticesTold = new HashSet(StringComparer.Ordinal); + /// Recently said chat keys, and when. Main thread only. private readonly Dictionary _chatKeys = new Dictionary(); @@ -10303,6 +10408,128 @@ namespace Oxide.Plugins WriteDataFile(CreditsPath(), new List(_credits.Values), "kit credits"); } + // ── Reward notices (protocol 14, PROTOCOL.md §20.2, D333) ────────────────────────────── + // + // "You earned kit vip — type /kit vip", said once to the player a reward step picked, in + // chat or as a popup. The site decides what to say and to whom; this plugin decides only + // WHEN: at once if the player is up and about, otherwise the next time they wake on this + // server. A sleeping or loading player sees no popup and reads no chat, so "online" is not + // enough — awake is. + + private string NoticesPath() + { + return Path.Combine(Interface.Oxide.DataDirectory, Name, "notices.json"); + } + + private void SaveNoticesTold() + { + WriteDataFile(NoticesPath(), new List(_noticesTold), "told reward notices"); + } + + private void LoadNoticesTold() + { + List told = ReadDataFile(NoticesPath(), "told reward notices"); + + if (told == null) + return; + + foreach (string key in told) + { + if (!string.IsNullOrEmpty(key)) + _noticesTold.Add(key); + } + } + + /// + /// Main thread, at the end of a sync that carried notices. Takes the site's set as + /// the whole truth, forgets told keys the site no longer sends (it has heard, or the + /// reward was withdrawn), tells everybody awake now, and lists every told key of the set + /// for the report. + /// + private void ApplyNotices(PermSyncJob job) + { + var next = new Dictionary(StringComparer.Ordinal); + + foreach (RewardNotice n in job.Notices) + next[n.Key] = n; + + _notices = next; + + int before = _noticesTold.Count; + _noticesTold.RemoveWhere(k => !next.ContainsKey(k)); + bool changed = _noticesTold.Count != before; + + foreach (RewardNotice n in next.Values) + { + if (_noticesTold.Contains(n.Key)) + continue; + + BasePlayer player = AwakePlayer(n.SteamId); + + if (player != null && TellNotice(player, n)) + changed = true; + } + + if (changed) + SaveNoticesTold(); + + foreach (string key in _noticesTold) + job.NoticesTold.Add(key); + } + + /// The player with this Steam id if they are connected and awake, else null. + private static BasePlayer AwakePlayer(string steamId) + { + ulong id; + + if (!ulong.TryParse(steamId, out id)) + return null; + + BasePlayer player = BasePlayer.FindByID(id); + return player != null && player.IsConnected && !player.IsSleeping() ? player : null; + } + + /// + /// Main thread. Says one notice to one player and records it told. A popup where + /// PopupNotifications is loaded and the step chose one, chat otherwise (§18) — the reward + /// was granted long before this moment, and the player is told either way. + /// + private bool TellNotice(BasePlayer player, RewardNotice n) + { + Plugin popup = n.Delivery == DeliveryPopup ? PopupPlugin() : null; + + if (popup != null) + popup.Call("CreatePopupNotification", n.Message, player, 0f); + else + player.ChatMessage(n.Message); + + return _noticesTold.Add(n.Key); + } + + /// + /// A player woke — after connecting, or after sleeping in a bag. Their untold notices are + /// said now. The site hears at the next sync's report. + /// + private void OnPlayerSleepEnded(BasePlayer player) + { + MarkHook("OnPlayerSleepEnded"); + + if (player == null || _notices.Count == 0) + return; + + string steamId = SteamIdOf(player); + bool changed = false; + + foreach (RewardNotice n in _notices.Values) + { + if (n.SteamId == steamId && !_noticesTold.Contains(n.Key) && TellNotice(player, n)) + changed = true; + } + + if (changed) + SaveNoticesTold(); + } + private void WriteDataFile(string path, object rows, string what) { try @@ -10377,7 +10604,9 @@ namespace Oxide.Plugins } } - Puts($"rewards: {_runTallies.Count} tallies and {_credits.Count} kit credits on record"); + LoadNoticesTold(); + + Puts($"rewards: {_runTallies.Count} tallies, {_credits.Count} kit credits and {_noticesTold.Count} told notices on record"); EnsureTallyTimer(); } @@ -11333,6 +11562,82 @@ namespace Oxide.Plugins EmitFrame(reply); } + /// + /// Main thread. Protocol 14 (PROTOCOL.md §20.1; the module's PLAN_REDESIGNS.md §2.6). Of + /// the given players, how many have used kit up and how many are waiting out its + /// cooldown — the warning beside a kit weekend ("9 of 41 linked players have used it + /// up"). Counts, never a list: the website asked about players it already knows, + /// and the answer it needs is how many, not who. + /// + /// + /// Two Kits reads per player, so the list is bounded by the sync's own bound: the site + /// sends its linked players for this server, and a sync carries no more than that. + /// + /// + private void HandleKitsUses(Dictionary cmd, string reqId) + { + Plugin kits = KitsPlugin(); + + if (kits == null) + { + EmitRewardError("kits.error", reqId, "kits-missing", "Kits is not loaded on this server"); + return; + } + + string kit = StringField(cmd, "kit"); + + if (string.IsNullOrEmpty(kit) || !(kits.Call("GetKitInfo", kit) is Newtonsoft.Json.Linq.JObject)) + { + EmitRewardError("kits.error", reqId, "unknown-kit", $"this server has no kit named '{Flatten(kit ?? "")}'"); + return; + } + + var ids = new List(); + + foreach (string raw in Strings(cmd, "steamIds")) + { + ulong id; + + if (ulong.TryParse(raw, out id)) + ids.Add(id); + } + + if (ids.Count > PermMaxOps) + { + EmitRewardError("kits.error", reqId, "too-large", $"at most {PermMaxOps} players in one question, not {ids.Count}"); + return; + } + + int max = CallInt(kits, "GetKitMaxUses", kit); + int exhausted = 0; + int onCooldown = 0; + + foreach (ulong id in ids) + { + // A kit with no use limit is never used up. + if (max > 0 && CallInt(kits, "GetPlayerKitUses", id, kit) >= max) + exhausted++; + + object cooldown = kits.Call("GetPlayerKitCooldown", id, kit); + + if (cooldown is double && (double)cooldown > 0) + onCooldown++; + } + + Dictionary reply = Frame("kits.uses", "reply"); + reply["kit"] = kit; + reply["max"] = max; + reply["cooldown"] = CallInt(kits, "GetKitCooldown", kit); + reply["asked"] = ids.Count; + reply["exhausted"] = exhausted; + reply["onCooldown"] = onCooldown; + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + private static string CreditKey(string steamId, string kit) { return steamId + "|" + Lower(kit); -- 2.49.1 From 66fbbd6b571c501734db278d062595eeae34eb95 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 12 Oct 2026 06:52:52 -0500 Subject: [PATCH 02/14] =?UTF-8?q?feat(bridge):=20Economics=20on=20protocol?= =?UTF-8?q?=2014=20=E2=80=94=20pay,=20balances,=20adjust=20(PLAN=5FREDESIG?= =?UTF-8?q?NS=20=C2=A711.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wulf's Economics as an optional integration (D199), as §11.2.2 designs it: - hello/status `integrations.economics` adds startingBalance and wipesWithMap, read from Economics.json (cached by mtime) because its API has neither. - `economics.pay`: coins from an event step, kept once paid (D335). One main- thread call for the list; each deposit measured as after - before, since Deposit answers true while rounding and clamping (`at-limit` otherwise). The key and its reply are kept in data/RunicGateway/payments.json for seven days, so a retry answers `repeat: true` and pays nobody. The optional message ({amount} = what landed) is told now if awake, else on wake. - `economics.balances` (bounded by PermMaxOps) and staff `economics.adjust` (deposit / withdraw / set; a withdrawal above the balance is refused `insufficient` with the balance). - `player.tally` gains `balance`, a value: read at connect (D336) and from OnEconomicsBalanceUpdated, offline players included. - `economics.wiped` from OnEconomicsDataWiped(IPlayer), with `newSave`. Amounts are boxed as double and only Steam ids are sent, per the spike. Compiled on both rigs (Oxide 2.0.7826, Carbon 2.0.262), then restored. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- README.md | 4 + overlay.toml | 4 +- overlay/oxide/plugins/RunicGateway.cs | 816 +++++++++++++++++++++++++- 3 files changed, 810 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 5ba7364..a0181b6 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,10 @@ Clans on the website come from Rust's **own** clan system, not from the uMod `Cl is optional. The two share no data: a server running the plugin has two unrelated clan systems, and only the game's own clans become Teams on the site. +Wulf's `Economics` is optional too, and not in `overlay.toml`. Where it is loaded the site can pay +coins from an event, show balances and a richest column, and let staff adjust a balance; where it is +not, each of those says "Economics is not installed on this server". + ## Diagnosing it ``` diff --git a/overlay.toml b/overlay.toml index daa699b..fc2678b 100644 --- a/overlay.toml +++ b/overlay.toml @@ -35,7 +35,9 @@ # and the first player walk's fixes (PROTOCOL.md §19) — opened by a configuration # save that answers at once and reports its reload as a `config.outcome` frame, # and the step editor (PROTOCOL.md §20): `kits.uses`, and the one-time reward -# messages a `perm.sync` carries as `notices`. +# messages a `perm.sync` carries as `notices`; and Economics (PROTOCOL.md §20.3): +# `economics.pay`, `economics.balances`, `economics.adjust`, the tally's +# `balance` and the `economics.wiped` event. protocol = 14 # ── Oxide compatibility ────────────────────────────────────────────────────── diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index 1f61bd3..edda5ba 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -59,7 +59,10 @@ namespace Oxide.Plugins /// /// /// 14 (PROTOCOL.md §20, the module's PLAN_REDESIGNS.md §2): kits.uses, and the - /// one-time reward messages perm.sync carries as notices (D333). + /// one-time reward messages perm.sync carries as notices (D333); and + /// Economics (§20.3, PLAN_REDESIGNS.md §11.2): economics.pay, + /// economics.balances, economics.adjust, the tally's balance and the + /// economics.wiped event. /// /// private const int ProtocolVersion = 14; @@ -256,7 +259,8 @@ namespace Oxide.Plugins "OnEnterZone", "OnExitZone", "OnRgDomesReady", "OnRunicNpcDied", "OnRunicNpcHealth", "OnRunicNpcPlacementChanged", "OnRunicNpcBossSpawned", "OnRunicNpcBossPhase", "OnRunicNpcBossDied", - "OnPlayerSleepEnded" + "OnPlayerSleepEnded", + "OnEconomicsBalanceUpdated", "OnEconomicsDataWiped" }; private void Init() @@ -1055,6 +1059,21 @@ namespace Oxide.Plugins HandleKitsUses(cmd, reqId); break; + case "economics.pay": + // Protocol 14 (PROTOCOL.md §20.3, the module's PLAN_REDESIGNS.md §11.2). Coins + // from an event step, kept once paid (D335). + HandleEconomicsPay(cmd, reqId); + break; + + case "economics.balances": + HandleEconomicsBalances(cmd, reqId); + break; + + case "economics.adjust": + // Staff, from a player's admin page: deposit, withdraw or set. + HandleEconomicsAdjust(cmd, reqId); + break; + case "chat.say": HandleChatSay(cmd, reqId); break; @@ -1653,6 +1672,10 @@ namespace Oxide.Plugins frame["steamId"] = SteamIdOf(player); frame["name"] = player.displayName; EmitFrame(frame); + + // A balance carried over from last wipe is known at once, without waiting for it to + // move (D336). Nothing when Economics is not loaded. + NoteBalance(player); } private void OnPlayerDisconnected(BasePlayer player, string reason) @@ -2850,6 +2873,14 @@ namespace Oxide.Plugins public int Explosives; public int Missions; + /// + /// Protocol 14 (§11.2.2): the player's Economics balance, a value and not a change, + /// like the bests. Sent only when : it moved, or was read at + /// connect, since the last flush. + /// + public double Balance; + public bool HasBalance; + public void Gather(string shortname, int amount) { Add(Gathered, shortname, amount); @@ -2900,7 +2931,8 @@ namespace Oxide.Plugins && AnimalKills == 0 && WeaponKills.Count == 0 && Headshots == 0 && BestPvpM <= 0f && BestPveM <= 0f && ApcKills == 0 && HeliKills == 0 && Crafted.Count == 0 && Built == 0 && Repaired == 0 && Healed == 0 - && Rockets == 0 && Explosives == 0 && Missions == 0 && NpcProfileKills.Count == 0; + && Rockets == 0 && Explosives == 0 && Missions == 0 && NpcProfileKills.Count == 0 + && !HasBalance; } } @@ -2918,6 +2950,23 @@ namespace Oxide.Plugins return tally; } + /// + /// The tally of a player who need not be online — an Economics balance reaches an offline + /// player too. It carries no name, and the frame it flushes has none. + /// + private Tally TallyForId(ulong userId) + { + Tally tally; + + if (!_tallies.TryGetValue(userId, out tally)) + { + tally = new Tally(); + _tallies[userId] = tally; + } + + return tally; + } + /// /// Emits one player.tally per player with anything to report, and clears what it /// emitted. @@ -3002,6 +3051,10 @@ namespace Oxide.Plugins if (tally.NpcProfileKills.Count > 0) frame["npcProfileKills"] = tally.NpcProfileKills; + // Protocol 14: the current balance, a value the site stores rather than sums. + if (tally.HasBalance) + frame["balance"] = Math.Round(tally.Balance, 2); + EmitFrame(frame); } @@ -5103,9 +5156,15 @@ namespace Oxide.Plugins // Hello carries ZoneManager's flag list and both helpers' states, so a change to any // of the four is said again rather than waiting for the next connect (§3.1, §3.2). - if (IsZonePlugin(plugin) || IsRunicNpc(plugin)) + // Economics too: hello carries its starting balance, which the richest column needs. + if (IsZonePlugin(plugin) || IsRunicNpc(plugin) || IsEconomics(plugin)) NextTick(ResendHello); + // Economics loaded after players connected: what they hold now is not otherwise known + // until it moves (D336). + if (IsEconomics(plugin)) + NextTick(NoteOnlineBalances); + // BetterChat keeps third-party titles on its INSTANCE, so a reloaded BetterChat has // forgotten our getter. Registering again is the only way back (§33.1). if (plugin != null && plugin != this && plugin.Name == "BetterChat") @@ -5128,7 +5187,7 @@ namespace Oxide.Plugins if (plugin != null && plugin.Name == "BetterChat") _titlesRegistered = false; - if (IsZonePlugin(plugin) || IsRunicNpc(plugin)) + if (IsZonePlugin(plugin) || IsRunicNpc(plugin) || IsEconomics(plugin)) NextTick(ResendHello); ConfigWriteJob job = _configJob; @@ -10496,14 +10555,19 @@ namespace Oxide.Plugins /// private bool TellNotice(BasePlayer player, RewardNotice n) { - Plugin popup = n.Delivery == DeliveryPopup ? PopupPlugin() : null; + SayTo(player, n.Message, n.Delivery); + return _noticesTold.Add(n.Key); + } + + /// Main thread. One line to one player, as a popup where one was asked for and can be shown. + private void SayTo(BasePlayer player, string message, string delivery) + { + Plugin popup = delivery == DeliveryPopup ? PopupPlugin() : null; if (popup != null) - popup.Call("CreatePopupNotification", n.Message, player, 0f); + popup.Call("CreatePopupNotification", message, player, 0f); else - player.ChatMessage(n.Message); - - return _noticesTold.Add(n.Key); + player.ChatMessage(message); } /// @@ -10514,7 +10578,13 @@ namespace Oxide.Plugins { MarkHook("OnPlayerSleepEnded"); - if (player == null || _notices.Count == 0) + if (player == null) + return; + + // Coins paid while they were away (protocol 14, §20.3). + TellPayments(player); + + if (_notices.Count == 0) return; string steamId = SteamIdOf(player); @@ -10605,8 +10675,9 @@ namespace Oxide.Plugins } LoadNoticesTold(); + LoadPayments(); - Puts($"rewards: {_runTallies.Count} tallies, {_credits.Count} kit credits and {_noticesTold.Count} told notices on record"); + Puts($"rewards: {_runTallies.Count} tallies, {_credits.Count} kit credits, {_noticesTold.Count} told notices and {_payments.Count} Economics payments on record"); EnsureTallyTimer(); } @@ -11871,6 +11942,724 @@ namespace Oxide.Plugins Puts($"rewards: Kits wipes use counts at a new save, so {_credits.Count} kit credits start over"); } + // ── Economics (protocol 14, PROTOCOL.md §20.3; the module's PLAN_REDESIGNS.md §11.2) ──── + // + // Wulf's Economics, optional like the other mods (D199): every verb below answers + // `economics-missing` without it, and nothing else in this plugin needs it. Its API is five + // private methods, each with a `string` and an `object` overload, called by name. What the + // spike found (§11.2.1) shapes every call here: + // + // * an amount is boxed as a `double` — an `int` matches no overload, and `Call` answers + // null having done nothing; + // * only ids parsed as Steam ids are sent, because Economics makes an account of any + // string it is given; + // * what a deposit paid is the balance after less the balance before, because Deposit + // answers `true` while rounding to two places and clamping to its balance limit. + // + // Coins paid are kept (D335): there is no revert. A payment or adjustment is remembered by + // its key for seven days, so a retried step or a resent form is answered from the first + // reply and never pays twice. + + private const double EconomicsAmountMin = 0.01; + private const double EconomicsAmountMax = 1000000d; + + /// A deposit that moved the balance by less than this moved nothing: it rounds to two places. + private const double EconomicsMovedMin = 0.005; + + /// How long a payment's key and reply are kept: seven days, the tally ceiling (D108). + private const long PaymentMemoryMs = TallyMaxHoldMs; + + /// The longest payment key. The site's are short (runId:stepIndex). + private const int PaymentKeyMax = 128; + + /// The lowest individual Steam id. Anything below it is not a player. + private const ulong SteamIdBase = 76561197960265728UL; + + private const string EconomicsPay = "pay"; + private const string EconomicsDeposit = "deposit"; + private const string EconomicsWithdraw = "withdraw"; + private const string EconomicsSet = "set"; + + /// + /// One payment (economics.pay) or one staff adjustment (economics.adjust), + /// kept with what it did so a repeat of its key is answered the same. Persisted as it is. + /// + private sealed class EconomicsPayment + { + public string Key; + + /// pay, or the adjustment's deposit, withdraw or set. + public string Op; + + public long AtMs; + public double Amount; + + /// A payment's message, {amount} not yet filled in. Blank tells nobody. + public string Message; + + public string Delivery; + public List Paid = new List(); + public List Failed = new List(); + } + + private sealed class EconomicsPaid + { + public string SteamId; + public double Before; + public double After; + + /// Whether the payment's message has been said to this player (always, when there is none). + public bool Told; + } + + private sealed class EconomicsFailed + { + public string SteamId; + public string Reason; + } + + /// What Economics' own config says, read from its file because its API does not. + private sealed class EconomicsSettings + { + public double? StartingBalance; + public bool? WipesWithMap; + } + + /// Payments and adjustments of the last seven days, by . + private Dictionary _payments = new Dictionary(StringComparer.Ordinal); + + private EconomicsSettings _economicsSettings; + private DateTime _economicsSettingsAt; + + private Plugin EconomicsPlugin() + { + return plugins.Find("Economics"); + } + + private bool IsEconomics(Plugin plugin) + { + return plugin != null && plugin != this && plugin.Name == "Economics"; + } + + /// + /// Hello's integrations.economics: loaded and version like every optional mod, and + /// the two settings the site needs from Economics' config. Each is absent when the file + /// does not say, and the site then assumes Economics' defaults. + /// + private Dictionary EconomicsBody() + { + Plugin eco = EconomicsPlugin(); + Dictionary body = IntegrationBody(eco); + + if (eco == null) + return body; + + EconomicsSettings settings = EconomicsConfig(); + + if (settings != null && settings.StartingBalance.HasValue) + body["startingBalance"] = settings.StartingBalance.Value; + + if (settings != null && settings.WipesWithMap.HasValue) + body["wipesWithMap"] = settings.WipesWithMap.Value; + + return body; + } + + /// + /// Economics' config, read again only when the file changes: every status frame asks. + /// A file that will not parse is said once per version of it, and answers nothing. On + /// Carbon the file is under carbon/configs, so the directory comes from the + /// framework (R19), as Kits' does. + /// + private EconomicsSettings EconomicsConfig() + { + string path = Path.Combine(Interface.Oxide.ConfigDirectory, "Economics.json"); + DateTime at; + + try + { + if (!File.Exists(path)) + return null; + + at = File.GetLastWriteTimeUtc(path); + } + catch (Exception) + { + return null; + } + + if (_economicsSettings != null && at == _economicsSettingsAt) + return _economicsSettings; + + var settings = new EconomicsSettings(); + + try + { + var config = Newtonsoft.Json.Linq.JObject.Parse(File.ReadAllText(path)); + Newtonsoft.Json.Linq.JToken start = config["Starting account balance (0 or higher)"]; + Newtonsoft.Json.Linq.JToken wipe = config["Wipe balances on new save file"]; + + if (start != null && (start.Type == Newtonsoft.Json.Linq.JTokenType.Integer || start.Type == Newtonsoft.Json.Linq.JTokenType.Float)) + settings.StartingBalance = (double)start; + + if (wipe != null && wipe.Type == Newtonsoft.Json.Linq.JTokenType.Boolean) + settings.WipesWithMap = (bool)wipe; + } + catch (Exception ex) + { + Puts($"could not read Economics' config: {Flatten(ex.Message)}"); + } + + _economicsSettings = settings; + _economicsSettingsAt = at; + return settings; + } + + /// Main thread. A balance to two places, or null when Economics did not answer with one. + private static double? ReadBalance(Plugin eco, string steamId) + { + object value = eco.Call("Balance", steamId); + return value is double ? (double?)Math.Round((double)value, 2) : null; + } + + /// A Steam id as Economics should see it, or false. Economics checks none itself. + private static bool TryParseSteamId(string raw, out ulong id) + { + return ulong.TryParse(raw, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out id) + && id >= SteamIdBase + && raw.Length == 17; + } + + private static string Coins(double amount) + { + return amount.ToString("#,0.##", System.Globalization.CultureInfo.InvariantCulture); + } + + private static bool AmountIn(double amount, double min) + { + return !double.IsNaN(amount) && !double.IsInfinity(amount) && amount >= min && amount <= EconomicsAmountMax; + } + + /// + /// Where a payment or an adjustment is remembered. The two keep apart, so a site's step + /// key can never be answered with a staff adjustment's reply; the three adjustments share + /// one, so a key is one adjustment whatever its op. + /// + private static string PaymentSlot(string op, string key) + { + return (op == EconomicsPay ? "pay|" : "adjust|") + key; + } + + /// Main thread. One player's balance into their tally, from their connect or Economics loading. + private void NoteBalance(BasePlayer player) + { + Plugin eco = EconomicsPlugin(); + + if (eco == null || player == null) + return; + + double? balance = ReadBalance(eco, SteamIdOf(player)); + + if (!balance.HasValue) + return; + + Tally tally = TallyFor(player); + tally.Balance = balance.Value; + tally.HasBalance = true; + } + + private void NoteOnlineBalances() + { + foreach (BasePlayer player in BasePlayer.activePlayerList) + NoteBalance(player); + } + + /// + /// Every balance change, deposits and withdrawals included, raised before their own hooks. + /// Folded into the tally as a value (rule 2: aggregates only). The player need not be + /// online: a payment or a staff adjustment reaches an offline player, and their frame + /// then has no name. + /// + private void OnEconomicsBalanceUpdated(string playerId, double balance) + { + MarkHook("OnEconomicsBalanceUpdated"); + + ulong id; + + if (!TryParseSteamId(playerId, out id)) + return; + + // Named only while connected: a sleeper is found too, and a name on the frame is what + // tells the site a player was here (it moves their last-seen). A balance is not play. + BasePlayer player = BasePlayer.FindByID(id); + Tally tally = player != null && player.IsConnected ? TallyFor(player) : TallyForId(id); + tally.Balance = Math.Round(balance, 2); + tally.HasBalance = true; + } + + /// + /// Raised with no arguments when a new save wipes the balances (Economics' own setting), + /// and with the caller after ecowipe. One method taking an IPlayer binds + /// both on both frameworks, with null for the first (§11.2.1). A balance not yet flushed + /// is from before the wipe, so it is dropped. + /// + private void OnEconomicsDataWiped(Oxide.Core.Libraries.Covalence.IPlayer player) + { + MarkHook("OnEconomicsDataWiped"); + + foreach (Tally tally in _tallies.Values) + tally.HasBalance = false; + + Dictionary frame = Frame("economics.wiped", "event"); + + // A new save's wipe opens a new map, whose balances the site has not seen yet; the + // last wipe's are a record of how it ended. `ecowipe` clears the wipe in play. + frame["newSave"] = player == null; + + EmitFrame(frame); + Puts($"Economics wiped every balance ({(player == null ? "new save" : "ecowipe")})"); + } + + /// + /// Main thread. Coins from an event step to a list of players, kept once paid (D335). One + /// call for the whole list. A key already paid is answered from its first reply with + /// repeat: true, and pays nobody. + /// + private void HandleEconomicsPay(Dictionary cmd, string reqId) + { + Plugin eco = EconomicsPlugin(); + + if (eco == null) + { + EmitEconomicsError(reqId, "economics-missing", "Economics is not installed on this server", null); + return; + } + + if (_config == null || !_config.EventsEnabled) + { + EmitEconomicsError(reqId, "events-disabled", + "events are switched off on this server — set EventsEnabled in the RunicGateway plugin config", null); + return; + } + + string key = StringField(cmd, "key"); + + if (string.IsNullOrEmpty(key) || key.Length > PaymentKeyMax) + { + EmitEconomicsError(reqId, "malformed", "a payment has a key", null); + return; + } + + ForgetOldPayments(); + + EconomicsPayment done; + + if (_payments.TryGetValue(PaymentSlot(EconomicsPay, key), out done)) + { + EmitPaid(done, reqId, true); + return; + } + + double amount = DoubleField(cmd, "amount") ?? double.NaN; + + if (!AmountIn(amount, EconomicsAmountMin)) + { + EmitEconomicsError(reqId, "bad-amount", + $"a payment is {Coins(EconomicsAmountMin)} to {Coins(EconomicsAmountMax)} coins", null); + return; + } + + var payment = new EconomicsPayment + { + Key = key, + Op = EconomicsPay, + AtMs = NowMs(), + Amount = amount, + Message = Flatten(StringField(cmd, "message") ?? "").Trim(), + Delivery = StringField(cmd, "delivery") == DeliveryPopup ? DeliveryPopup : DeliveryChat + }; + + var ids = new List(); + var seen = new HashSet(StringComparer.Ordinal); + + foreach (string raw in Strings(cmd, "steamIds")) + { + ulong id; + + if (!seen.Add(raw)) + continue; + + if (TryParseSteamId(raw, out id)) + ids.Add(raw); + else + payment.Failed.Add(new EconomicsFailed { SteamId = raw, Reason = "bad-id" }); + } + + if (seen.Count > MaxRecipients()) + { + EmitEconomicsError(reqId, "too-large", $"this server pays at most {MaxRecipients()} players in one step, not {seen.Count}", null); + return; + } + + foreach (string id in ids) + { + double? before = ReadBalance(eco, id); + object ok = eco.Call("Deposit", id, amount); + double? after = ReadBalance(eco, id); + + if (!(ok is bool) || !(bool)ok) + { + payment.Failed.Add(new EconomicsFailed { SteamId = id, Reason = "refused" }); + continue; + } + + if (!before.HasValue || !after.HasValue) + { + // Deposited, but not measured. Listed as failed so nobody is told an amount, + // and remembered with the key so a retry still pays nothing more. + payment.Failed.Add(new EconomicsFailed { SteamId = id, Reason = "unmeasured" }); + continue; + } + + if (after.Value - before.Value < EconomicsMovedMin) + { + payment.Failed.Add(new EconomicsFailed { SteamId = id, Reason = "at-limit" }); + continue; + } + + var paid = new EconomicsPaid { SteamId = id, Before = before.Value, After = after.Value, Told = payment.Message.Length == 0 }; + payment.Paid.Add(paid); + + BasePlayer awake = AwakePlayer(id); + + if (awake != null && !paid.Told) + TellPaid(awake, payment, paid); + } + + _payments[PaymentSlot(EconomicsPay, key)] = payment; + SavePayments(); + + _lastRewardSummary = $"paid {Coins(amount)} coins to {payment.Paid.Count} player(s), {payment.Failed.Count} failed"; + EmitPaid(payment, reqId, false); + } + + /// Main thread. Of the given players, each one's balance, for the account and admin pages. + private void HandleEconomicsBalances(Dictionary cmd, string reqId) + { + Plugin eco = EconomicsPlugin(); + + if (eco == null) + { + EmitEconomicsError(reqId, "economics-missing", "Economics is not installed on this server", null); + return; + } + + var ids = new List(); + var seen = new HashSet(StringComparer.Ordinal); + + foreach (string raw in Strings(cmd, "steamIds")) + { + ulong id; + + if (TryParseSteamId(raw, out id) && seen.Add(raw)) + ids.Add(raw); + } + + if (ids.Count > PermMaxOps) + { + EmitEconomicsError(reqId, "too-large", $"at most {PermMaxOps} players in one question, not {ids.Count}", null); + return; + } + + var rows = new List(); + + foreach (string id in ids) + { + double? balance = ReadBalance(eco, id); + + if (balance.HasValue) + rows.Add(new Dictionary { { "steamId", id }, { "balance", balance.Value } }); + } + + Dictionary reply = Frame("economics.balances", "reply"); + reply["balances"] = rows; + + // A balance at the starting value may be an account Economics has never seen (§11.2.1). + EconomicsSettings settings = EconomicsConfig(); + + if (settings != null && settings.StartingBalance.HasValue) + reply["startingBalance"] = settings.StartingBalance.Value; + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + /// + /// Main thread. Staff: deposit, withdraw or set one player's balance. A withdrawal above the + /// balance is refused with the balance, because Economics takes all or nothing. + /// + private void HandleEconomicsAdjust(Dictionary cmd, string reqId) + { + Plugin eco = EconomicsPlugin(); + + if (eco == null) + { + EmitEconomicsError(reqId, "economics-missing", "Economics is not installed on this server", null); + return; + } + + string key = StringField(cmd, "key"); + + if (string.IsNullOrEmpty(key) || key.Length > PaymentKeyMax) + { + EmitEconomicsError(reqId, "malformed", "an adjustment has a key", null); + return; + } + + string op = StringField(cmd, "op"); + string method = op == EconomicsDeposit ? "Deposit" : op == EconomicsWithdraw ? "Withdraw" : op == EconomicsSet ? "SetBalance" : null; + + if (method == null) + { + EmitEconomicsError(reqId, "malformed", "an adjustment deposits, withdraws or sets", null); + return; + } + + ForgetOldPayments(); + + EconomicsPayment done; + + if (_payments.TryGetValue(PaymentSlot(op, key), out done)) + { + EmitAdjusted(done, reqId, true); + return; + } + + string steamId = StringField(cmd, "steamId"); + ulong parsed; + + if (!TryParseSteamId(steamId, out parsed)) + { + EmitEconomicsError(reqId, "malformed", "an adjustment names one player by Steam id", null); + return; + } + + double amount = DoubleField(cmd, "amount") ?? double.NaN; + + if (!AmountIn(amount, op == EconomicsSet ? 0d : EconomicsAmountMin)) + { + EmitEconomicsError(reqId, "bad-amount", + $"an adjustment is {(op == EconomicsSet ? "0" : Coins(EconomicsAmountMin))} to {Coins(EconomicsAmountMax)} coins", null); + return; + } + + double? before = ReadBalance(eco, steamId); + object ok = eco.Call(method, steamId, amount); + double? after = ReadBalance(eco, steamId); + + if (!(ok is bool) || !(bool)ok) + { + if (op == EconomicsWithdraw && before.HasValue && amount > before.Value) + EmitEconomicsError(reqId, "insufficient", $"this player has only {Coins(before.Value)} coins", before); + else + EmitEconomicsError(reqId, "refused", $"Economics refused to {op} {Coins(amount)} coins", before); + + return; + } + + if (!before.HasValue || !after.HasValue) + { + EmitEconomicsError(reqId, "refused", "Economics did not report this player's balance", null); + return; + } + + if (op == EconomicsDeposit && after.Value - before.Value < EconomicsMovedMin) + { + EmitEconomicsError(reqId, "at-limit", "this player's balance is at Economics' limit", before); + return; + } + + var adjustment = new EconomicsPayment + { + Key = key, + Op = op, + AtMs = NowMs(), + Amount = amount + }; + + adjustment.Paid.Add(new EconomicsPaid { SteamId = steamId, Before = before.Value, After = after.Value, Told = true }); + + _payments[PaymentSlot(op, key)] = adjustment; + SavePayments(); + + _lastRewardSummary = $"{op} {Coins(amount)} coins for {steamId}: {Coins(before.Value)} → {Coins(after.Value)}"; + EmitAdjusted(adjustment, reqId, false); + } + + private void EmitPaid(EconomicsPayment payment, string reqId, bool repeat) + { + var paid = new List(); + var failed = new List(); + + foreach (EconomicsPaid row in payment.Paid) + paid.Add(new Dictionary { { "steamId", row.SteamId }, { "before", row.Before }, { "after", row.After } }); + + foreach (EconomicsFailed row in payment.Failed) + failed.Add(new Dictionary { { "steamId", row.SteamId }, { "reason", row.Reason } }); + + Dictionary reply = Frame("economics.paid", "reply"); + reply["key"] = payment.Key; + reply["repeat"] = repeat; + reply["amount"] = payment.Amount; + reply["paid"] = paid; + reply["failed"] = failed; + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + private void EmitAdjusted(EconomicsPayment adjustment, string reqId, bool repeat) + { + EconomicsPaid row = adjustment.Paid.Count > 0 ? adjustment.Paid[0] : null; + + Dictionary reply = Frame("economics.adjusted", "reply"); + reply["key"] = adjustment.Key; + reply["repeat"] = repeat; + reply["op"] = adjustment.Op; + reply["amount"] = adjustment.Amount; + + if (row != null) + { + reply["steamId"] = row.SteamId; + reply["before"] = row.Before; + reply["after"] = row.After; + } + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + private void EmitEconomicsError(string reqId, string reason, string message, double? balance) + { + Dictionary frame = Frame("economics.error", "reply"); + frame["reason"] = reason; + frame["message"] = message; + + if (balance.HasValue) + frame["balance"] = balance.Value; + + if (reqId != null) + frame["reqId"] = reqId; + + _lastRewardSummary = $"refused ({reason}): {message}"; + EmitFrame(frame); + } + + /// Main thread. Says a payment's message to one player, with what they received, and marks it told. + private void TellPaid(BasePlayer player, EconomicsPayment payment, EconomicsPaid row) + { + string line = payment.Message.Replace("{amount}", Coins(row.After - row.Before)); + + if (line.Length > MaxChat()) + line = line.Substring(0, MaxChat()); + + SayTo(player, line, payment.Delivery); + row.Told = true; + } + + /// Main thread, as a player wakes. Every payment they have not been told of yet. + private void TellPayments(BasePlayer player) + { + if (_payments.Count == 0) + return; + + string steamId = SteamIdOf(player); + bool changed = false; + + foreach (EconomicsPayment payment in _payments.Values) + { + if (payment.Op != EconomicsPay || string.IsNullOrEmpty(payment.Message)) + continue; + + foreach (EconomicsPaid row in payment.Paid) + { + if (row.SteamId == steamId && !row.Told) + { + TellPaid(player, payment, row); + changed = true; + } + } + } + + if (changed) + SavePayments(); + } + + /// Forgets payments seven days old, with any message nobody woke up to hear. True when any went. + private bool ForgetOldPayments() + { + long now = NowMs(); + var stale = new List(); + + foreach (KeyValuePair kv in _payments) + { + if (now >= kv.Value.AtMs + PaymentMemoryMs) + stale.Add(kv.Key); + } + + foreach (string slot in stale) + _payments.Remove(slot); + + if (stale.Count > 0) + SavePayments(); + + return stale.Count > 0; + } + + private string PaymentsPath() + { + return Path.Combine(Interface.Oxide.DataDirectory, Name, "payments.json"); + } + + private void SavePayments() + { + WriteDataFile(PaymentsPath(), new List(_payments.Values), "Economics payments"); + } + + private void LoadPayments() + { + _payments = new Dictionary(StringComparer.Ordinal); + List payments = ReadDataFile(PaymentsPath(), "Economics payments"); + + if (payments == null) + return; + + foreach (EconomicsPayment p in payments) + { + if (p == null || string.IsNullOrEmpty(p.Key) || string.IsNullOrEmpty(p.Op)) + continue; + + if (p.Paid == null) + p.Paid = new List(); + + if (p.Failed == null) + p.Failed = new List(); + + if (p.Message == null) + p.Message = ""; + + _payments[PaymentSlot(p.Op, p.Key)] = p; + } + + ForgetOldPayments(); + } + // ── Chat ────────────────────────────────────────────────────────────────── /// @@ -12308,7 +13097,8 @@ namespace Oxide.Plugins { "betterChat", IntegrationBody(BetterChatPlugin()) }, { "popupNotifications", IntegrationBody(PopupPlugin()) }, { "zoneDomes", ZoneDomesBody() }, - { "runicNpc", RunicNpcBody() } + { "runicNpc", RunicNpcBody() }, + { "economics", EconomicsBody() } }; } -- 2.49.1 From e3dc9184004e2c90baa425cca4a569f42184132c Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 12 Oct 2026 14:35:54 -0500 Subject: [PATCH 03/14] =?UTF-8?q?feat(bridge):=20Backpacks=20on=20protocol?= =?UTF-8?q?=2014=20=E2=80=94=20read=20a=20backpack=20(PLAN=5FREDESIGNS=20?= =?UTF-8?q?=C2=A711.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WhiteThunder's Backpacks as an optional integration (D199), as §11.3.2 designs it. Read, never write (R2): - hello/status `integrations.backpacks` adds `sizes`: the N of every registered backpacks.size.N, ascending (dynamic-size profiles left out). Hello is resent when Backpacks loads or unloads. - `backpacks.read {steamId}`: API_ReadBackpackContentsAsJson, and only when that answered a backpack, API_GetBackpackCapacityById — the backpack is loaded by then, so the capacity follows a later grant (§11.3.1's trap). Each stack is reduced to slot, shortname, display (the game's English name, D340), amount, skin and name when set; a blueprint carries its target as `blueprint` (D341); contents one level deep; `overflow` past the capacity. `pageSize` is Backpacks' "Max size per page" from its config, cached by mtime. - Refusals: backpacks-missing, bad-id, unreadable. The bigger backpack an event lends is a backpacks.size.N permission pushed by perm.sync (D337), so it needs no code here. Still protocol 14 (D332). Compiled on both rigs (Rust changeset 167584; Oxide 2.0.7826, Carbon 2.0.262), then restored to the bridge they ran before. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- README.md | 5 + overlay.toml | 3 +- overlay/oxide/plugins/RunicGateway.cs | 296 +++++++++++++++++++++++++- 3 files changed, 298 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index a0181b6..1b63818 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,11 @@ Wulf's `Economics` is optional too, and not in `overlay.toml`. Where it is loade coins from an event, show balances and a richest column, and let staff adjust a balance; where it is not, each of those says "Economics is not installed on this server". +WhiteThunder's `Backpacks` is optional in the same way. Where it is loaded staff can open a player's +backpack from the site, a player can see their own, and an event can lend a bigger one for its run; +the bridge only ever reads a backpack, and the bigger size is one of Backpacks' own `backpacks.size.N` +permissions, pushed like any other grant. + ## Diagnosing it ``` diff --git a/overlay.toml b/overlay.toml index fc2678b..33cd1eb 100644 --- a/overlay.toml +++ b/overlay.toml @@ -37,7 +37,8 @@ # and the step editor (PROTOCOL.md §20): `kits.uses`, and the one-time reward # messages a `perm.sync` carries as `notices`; and Economics (PROTOCOL.md §20.3): # `economics.pay`, `economics.balances`, `economics.adjust`, the tally's -# `balance` and the `economics.wiped` event. +# `balance` and the `economics.wiped` event; and Backpacks (PROTOCOL.md §20.4): +# `backpacks.read` and hello's `integrations.backpacks.sizes`. protocol = 14 # ── Oxide compatibility ────────────────────────────────────────────────────── diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index edda5ba..e89531f 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -62,7 +62,8 @@ namespace Oxide.Plugins /// one-time reward messages perm.sync carries as notices (D333); and /// Economics (§20.3, PLAN_REDESIGNS.md §11.2): economics.pay, /// economics.balances, economics.adjust, the tally's balance and the - /// economics.wiped event. + /// economics.wiped event; and Backpacks (§20.4, PLAN_REDESIGNS.md §11.3): + /// backpacks.read and hello's integrations.backpacks.sizes. /// /// private const int ProtocolVersion = 14; @@ -1074,6 +1075,12 @@ namespace Oxide.Plugins HandleEconomicsAdjust(cmd, reqId); break; + case "backpacks.read": + // Protocol 14 (PROTOCOL.md §20.4, the module's PLAN_REDESIGNS.md §11.3). One + // player's backpack, read and never written (R2). + HandleBackpacksRead(cmd, reqId); + break; + case "chat.say": HandleChatSay(cmd, reqId); break; @@ -5156,8 +5163,9 @@ namespace Oxide.Plugins // Hello carries ZoneManager's flag list and both helpers' states, so a change to any // of the four is said again rather than waiting for the next connect (§3.1, §3.2). - // Economics too: hello carries its starting balance, which the richest column needs. - if (IsZonePlugin(plugin) || IsRunicNpc(plugin) || IsEconomics(plugin)) + // Economics too: hello carries its starting balance, which the richest column needs; + // and Backpacks, whose sizes are what a Bigger backpack step offers. + if (IsZonePlugin(plugin) || IsRunicNpc(plugin) || IsEconomics(plugin) || IsBackpacks(plugin)) NextTick(ResendHello); // Economics loaded after players connected: what they hold now is not otherwise known @@ -5187,7 +5195,7 @@ namespace Oxide.Plugins if (plugin != null && plugin.Name == "BetterChat") _titlesRegistered = false; - if (IsZonePlugin(plugin) || IsRunicNpc(plugin) || IsEconomics(plugin)) + if (IsZonePlugin(plugin) || IsRunicNpc(plugin) || IsEconomics(plugin) || IsBackpacks(plugin)) NextTick(ResendHello); ConfigWriteJob job = _configJob; @@ -12660,6 +12668,283 @@ namespace Oxide.Plugins ForgetOldPayments(); } + // ── Backpacks (protocol 14, PROTOCOL.md §20.4; the module's PLAN_REDESIGNS.md §11.3) ──── + // + // WhiteThunder's Backpacks, optional like the other mods (D199). **Read, never write (R2):** + // `backpacks.read` is the only verb, and nothing here calls a Backpacks method that changes + // a backpack or a capacity. The extra space an event gives is a `backpacks.size.N` + // permission the site leases through `perm.sync` like a kit reward (D337), so it needs no + // code here at all. What the spike found (§11.3.1) shapes the read: + // + // * `API_ReadBackpackContentsAsJson` answers Backpacks' own item data — item ids, slots + // that run across pages, nested contents — and null for a player who never had one; + // * a capacity read while the backpack is not loaded is cached by Backpacks and pins it + // until the player reconnects, so the capacity is read only after the contents read + // has loaded the backpack. + + private const string BackpacksSizePrefix = "backpacks.size."; + + /// Backpacks' own bound on a page, and its default "Max size per page". + private const int BackpacksPageMax = 48; + + private int _backpacksPageSize = BackpacksPageMax; + private DateTime _backpacksConfigAt; + + private Plugin BackpacksPlugin() + { + return plugins.Find("Backpacks"); + } + + private bool IsBackpacks(Plugin plugin) + { + return plugin != null && plugin != this && plugin.Name == "Backpacks"; + } + + /// + /// Hello's integrations.backpacks: loaded and version like every optional mod, and + /// sizes, the N of every registered backpacks.size.N, ascending. They are + /// the operator's own Permission sizes, and what the site may lease (D337). + /// + private Dictionary BackpacksBody() + { + Plugin backpacks = BackpacksPlugin(); + Dictionary body = IntegrationBody(backpacks); + + if (backpacks != null) + body["sizes"] = BackpackSizes(); + + return body; + } + + private List BackpackSizes() + { + var sizes = new SortedSet(); + + foreach (string name in permission.GetPermissions() ?? new string[0]) + { + if (name == null || !name.StartsWith(BackpacksSizePrefix, StringComparison.OrdinalIgnoreCase)) + continue; + + int n; + + // `backpacks.size.profile.6-48` is a dynamic-size profile, not a size. + if (int.TryParse(name.Substring(BackpacksSizePrefix.Length), System.Globalization.NumberStyles.None, + System.Globalization.CultureInfo.InvariantCulture, out n) && n > 0) + sizes.Add(n); + } + + return new List(sizes); + } + + /// + /// Backpacks' "Max size per page" from its config, read again only when the file + /// changes; 48, its default, when the file does not say. The directory comes from the + /// framework, as Economics' does (R19). + /// + private int BackpacksPageSize() + { + string path = Path.Combine(Interface.Oxide.ConfigDirectory, "Backpacks.json"); + DateTime at; + + try + { + if (!File.Exists(path)) + return BackpacksPageMax; + + at = File.GetLastWriteTimeUtc(path); + } + catch (Exception) + { + return BackpacksPageMax; + } + + if (at == _backpacksConfigAt) + return _backpacksPageSize; + + int size = BackpacksPageMax; + + try + { + var config = Newtonsoft.Json.Linq.JObject.Parse(File.ReadAllText(path)); + var section = config["Backpack size"] as Newtonsoft.Json.Linq.JObject; + Newtonsoft.Json.Linq.JToken page = section != null ? section["Max size per page"] : null; + + // Clamped as Backpacks clamps it. + if (page != null && page.Type == Newtonsoft.Json.Linq.JTokenType.Integer) + size = Math.Max(1, Math.Min(BackpacksPageMax, (int)page)); + } + catch (Exception ex) + { + Puts($"could not read Backpacks' config: {Flatten(ex.Message)}"); + } + + _backpacksPageSize = size; + _backpacksConfigAt = at; + return size; + } + + /// + /// Main thread. One player's backpack, for a staff view or the player's own page. Live: + /// nothing is kept here, and the site stores nothing either. + /// + private void HandleBackpacksRead(Dictionary cmd, string reqId) + { + Plugin backpacks = BackpacksPlugin(); + + if (backpacks == null) + { + EmitBackpacksError(reqId, "backpacks-missing", "Backpacks is not installed on this server"); + return; + } + + string raw = StringField(cmd, "steamId"); + ulong id; + + if (raw == null || !TryParseSteamId(raw, out id)) + { + EmitBackpacksError(reqId, "bad-id", $"'{Flatten(raw ?? "")}' is not a Steam id"); + return; + } + + Dictionary reply = Frame("backpacks.contents", "reply"); + reply["steamId"] = raw; + + // Loads an offline player's backpack from disk; null when they have never had one. + string json = backpacks.Call("API_ReadBackpackContentsAsJson", id) as string; + + if (json == null) + { + reply["exists"] = false; + } + else + { + List> items; + + try + { + items = BackpackStacks(Newtonsoft.Json.Linq.JArray.Parse(json), true); + } + catch (Exception ex) + { + EmitBackpacksError(reqId, "unreadable", $"Backpacks answered contents this bridge could not read: {Flatten(ex.Message)}"); + return; + } + + // Only now, through the backpack the read just loaded, so the capacity follows a + // size granted later (§11.3.1's trap). + int capacity = CallInt(backpacks, "API_GetBackpackCapacityById", id, raw); + + // Past the capacity is what waits for the owner's next open to be moved (D338). + foreach (Dictionary stack in items) + { + if ((int)stack["slot"] >= capacity) + stack["overflow"] = true; + } + + reply["exists"] = true; + reply["capacity"] = capacity; + reply["pageSize"] = BackpacksPageSize(); + reply["items"] = items; + } + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + /// + /// Backpacks' item data reduced to what a grid shows, by slot. + /// keeps one level of contents — a weapon's attachments, a container's items — + /// and no deeper. + /// + private static List> BackpackStacks(Newtonsoft.Json.Linq.JArray data, bool nest) + { + var stacks = new List>(); + + foreach (Newtonsoft.Json.Linq.JToken token in data) + { + var item = token as Newtonsoft.Json.Linq.JObject; + + if (item != null) + stacks.Add(BackpackStack(item, nest)); + } + + stacks.Sort((a, b) => ((int)a["slot"]).CompareTo((int)b["slot"])); + return stacks; + } + + /// + /// One stack: slot, shortname, the game's English name (D340, so the site needs no item + /// catalogue), amount, and skin and name when set. An id no item definition knows is sent + /// with a null shortname and its itemId. A blueprint names what it is for (D341): + /// blueprint is the target's shortname and display the target's name. + /// + private static Dictionary BackpackStack(Newtonsoft.Json.Linq.JObject item, bool nest) + { + int itemId = JsonInt(item["ID"]); + ItemDefinition def = ItemManager.FindItemDefinition(itemId); + + var stack = new Dictionary + { + { "slot", JsonInt(item["Position"]) }, + { "shortname", def != null ? def.shortname : null }, + { "amount", JsonInt(item["Amount"]) } + }; + + if (def == null) + stack["itemId"] = itemId; + else + stack["display"] = def.displayName != null ? def.displayName.english : def.shortname; + + Newtonsoft.Json.Linq.JToken blueprint = item["IsBlueprint"]; + + if (blueprint != null && blueprint.Type == Newtonsoft.Json.Linq.JTokenType.Boolean && (bool)blueprint) + { + ItemDefinition target = ItemManager.FindItemDefinition(JsonInt(item["BlueprintTarget"])); + + if (target != null) + { + stack["blueprint"] = target.shortname; + stack["display"] = target.displayName != null ? target.displayName.english : target.shortname; + } + } + + Newtonsoft.Json.Linq.JToken skin = item["Skin"]; + + if (skin != null && skin.Type == Newtonsoft.Json.Linq.JTokenType.Integer && (ulong)skin != 0UL) + stack["skin"] = (ulong)skin; + + Newtonsoft.Json.Linq.JToken name = item["Name"]; + + if (name != null && name.Type == Newtonsoft.Json.Linq.JTokenType.String && ((string)name).Length > 0) + stack["name"] = (string)name; + + var contents = item["Contents"] as Newtonsoft.Json.Linq.JArray; + + if (nest && contents != null && contents.Count > 0) + stack["contents"] = BackpackStacks(contents, false); + + return stack; + } + + private static int JsonInt(Newtonsoft.Json.Linq.JToken token) + { + return token != null && token.Type == Newtonsoft.Json.Linq.JTokenType.Integer ? (int)token : 0; + } + + private void EmitBackpacksError(string reqId, string reason, string message) + { + Dictionary frame = Frame("backpacks.error", "reply"); + frame["reason"] = reason; + frame["message"] = message; + + if (reqId != null) + frame["reqId"] = reqId; + + EmitFrame(frame); + } + // ── Chat ────────────────────────────────────────────────────────────────── /// @@ -13098,7 +13383,8 @@ namespace Oxide.Plugins { "popupNotifications", IntegrationBody(PopupPlugin()) }, { "zoneDomes", ZoneDomesBody() }, { "runicNpc", RunicNpcBody() }, - { "economics", EconomicsBody() } + { "economics", EconomicsBody() }, + { "backpacks", BackpacksBody() } }; } -- 2.49.1 From adb9df6a2ea38c1918fda448c3429e55044bbd2d Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 15 Oct 2026 00:39:00 -0500 Subject: [PATCH 04/14] =?UTF-8?q?feat(bridge):=20Rust's=20own=20world=20ev?= =?UTF-8?q?ents=20on=20protocol=2014=20(PLAN=5FREDESIGNS=20=C2=A714.3.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 1 of the §14.3 integration plan: the patrol helicopter, Bradley, the cargo ship, the Chinook, airdrops, locked crates, the Travelling Vendor, the satellite and the Deep Sea, with no plugin (D349). - `world.event`, one frame kind whose `event` names which of 24 types (PROTOCOL.md §20.5). The type is in `event`, never `type`: `type` is the envelope's, and the rig's sidecar dropped every world event as untyped when the plan's example put it there. Nothing is said before the world loads or while the server quits. Positions are x/z; the site works out the grid. - Credit (D372, D379): a damage table for the patrol helicopter, the Chinook and Bradley; `heli.downed`, `chinook.downed` and `bradley.destroyed` carry every candidate (blow, last, most, damagers) and the plugin credits nobody. D174's `CreditVehicle` and the tally's apcKills/heliKills are gone. - Locked crates told positively (oil rigs, cargo, ghost ship, a Chinook drop joined at the crate's landing); anything else says nothing. A Bradley's `where` comes from the game's spawners, not `RoadSpawned` (which defaults to true). `airdrop.called` comes from the cargo plane's spawn: `supply.call` raised neither OnAirdrop nor OnCargoPlaneSignaled. - The tally gains cratesHacked and dropsLooted (Safecracker, Scavenger, D356). - `world.call` (D354, D367, D378): one of a schedule's TriggeredEvents at random, without Trigger (which would reset its clock); satellite.random for the satellite. A position for the airdrop (InitDropPosition) and the patrol helicopter (SetInitialDestination) only, both set before the spawn, as the stage-1 spike measured (0-1 m and 10-54 m). Kept; the key is remembered seven days in world-calls.json; EventsMaxWorldCalls (default and ceiling 10) per run. - Hello/status `console.plugins` (D363): the bridge runs its own rg.probe through ConsoleSystem.Run from a timer. Carbon 2.0.262 refuses plugin commands only inside a console command (or a NextTick one queued), which is how §14.2's spike tested; from a timer, and from the link reader's NextTick, it finds them. Reads true on both rigs. - checkPlugin.js: the probe must name a [ConsoleCommand] the plugin declares. Compiled and loaded on both rigs (Rust 25902425, Oxide 2.0.7830, Carbon 2.0.262); heli.arrived, cargo.arrived and airdrop.called read back from each rig's sidecar store. Rigs restored. 18 check tests pass. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- README.md | 5 + overlay.toml | 5 +- overlay/oxide/plugins/RunicGateway.cs | 1555 ++++++++++++++++++++++++- scripts/checkPlugin.js | 27 +- scripts/checkPlugin.test.js | 54 +- 5 files changed, 1578 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index 1b63818..faf0d7e 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,11 @@ backpack from the site, a player can see their own, and an event can lend a bigg the bridge only ever reads a backpack, and the bigger size is one of Backpacks' own `backpacks.size.N` permissions, pushed like any other grant. +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`). +What a call starts is kept when the event ends; `EventsMaxWorldCalls` caps how many one run may call. + ## Diagnosing it ``` diff --git a/overlay.toml b/overlay.toml index 33cd1eb..568138a 100644 --- a/overlay.toml +++ b/overlay.toml @@ -38,7 +38,10 @@ # messages a `perm.sync` carries as `notices`; and Economics (PROTOCOL.md §20.3): # `economics.pay`, `economics.balances`, `economics.adjust`, the tally's # `balance` and the `economics.wiped` event; and Backpacks (PROTOCOL.md §20.4): -# `backpacks.read` and hello's `integrations.backpacks.sizes`. +# `backpacks.read` and hello's `integrations.backpacks.sizes`; and Rust's own world +# events (PROTOCOL.md §20.5): `world.event` with its `event`, `world.call`, hello's +# `console`, and the tally's `cratesHacked` and `dropsLooted` in place of `apcKills` +# and `heliKills`. protocol = 14 # ── Oxide compatibility ────────────────────────────────────────────────────── diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index e89531f..989d3e3 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -63,7 +63,11 @@ namespace Oxide.Plugins /// Economics (§20.3, PLAN_REDESIGNS.md §11.2): economics.pay, /// economics.balances, economics.adjust, the tally's balance and the /// economics.wiped event; and Backpacks (§20.4, PLAN_REDESIGNS.md §11.3): - /// backpacks.read and hello's integrations.backpacks.sizes. + /// backpacks.read and hello's integrations.backpacks.sizes; and Rust's own + /// world events (§20.5, PLAN_REDESIGNS.md §14.3.1): the world.event kind and its + /// 24 types, world.call, hello's console.plugins, and the tally's + /// cratesHacked and dropsLooted, where apcKills and + /// heliKills are no longer sent. /// /// private const int ProtocolVersion = 14; @@ -261,7 +265,13 @@ namespace Oxide.Plugins "OnRunicNpcDied", "OnRunicNpcHealth", "OnRunicNpcPlacementChanged", "OnRunicNpcBossSpawned", "OnRunicNpcBossPhase", "OnRunicNpcBossDied", "OnPlayerSleepEnded", - "OnEconomicsBalanceUpdated", "OnEconomicsDataWiped" + "OnEconomicsBalanceUpdated", "OnEconomicsDataWiped", + // Protocol 14, Rust's own world events (§20.5). + "OnPatrolHelicopterKill", "OnHelicopterRetire", "OnBradleyApcInitialize", + "OnCargoShipHarborApproach", "OnCargoShipHarborArrived", "OnCargoShipHarborLeave", "OnCargoShipEgress", + "OnHelicopterDropCrate", "OnCrateLanded", "CanHackCrate", "OnCrateHack", "OnCrateHackEnd", "OnLootEntity", + "OnCargoPlaneSignaled", "OnAirdrop", "OnSupplyDropDropped", "OnSupplyDropLanded", + "OnDeepSeaOpened", "OnDeepSeaClosed" }; private void Init() @@ -444,6 +454,13 @@ namespace Oxide.Plugins /// The longest line an event or a news post may say in chat, in characters. public int EventsMaxChat = ChatHardMax; + /// + /// The most world events one run may call on this server (protocol 14, D354): patrol + /// helicopters, cargo ships, airdrops and the rest, each counted. Lowerable, like the + /// bounds above; is the ceiling. + /// + public int EventsMaxWorldCalls = WorldCallsHardMax; + /// /// The most offline sleepers one map answer carries (protocol 11). Over it, the layer /// answers the first this many and says it was cut short. @@ -526,12 +543,13 @@ namespace Oxide.Plugins _config.EventsMaxRecipients = Math.Max(0, Math.Min(_config.EventsMaxRecipients, RecipientsHardMax)); _config.EventsMaxTallies = Math.Max(0, Math.Min(_config.EventsMaxTallies, TallyHardMax)); _config.EventsMaxChat = Math.Max(0, Math.Min(_config.EventsMaxChat, ChatHardMax)); + _config.EventsMaxWorldCalls = Math.Max(0, Math.Min(_config.EventsMaxWorldCalls, WorldCallsHardMax)); _config.MapMaxSleepers = Math.Max(0, _config.MapMaxSleepers); _config.MapMaxBases = Math.Max(0, _config.MapMaxBases); if (Config["EventsEnabled"] == null || Config["EventsMaxCrates"] == null || Config["EventsMaxNpcs"] == null || Config["EventsMaxRecipients"] == null || Config["EventsMaxTallies"] == null || Config["EventsMaxChat"] == null || - Config["MapMaxSleepers"] == null || Config["MapMaxBases"] == null) + Config["EventsMaxWorldCalls"] == null || Config["MapMaxSleepers"] == null || Config["MapMaxBases"] == null) Config.WriteObject(_config, true); } @@ -545,6 +563,7 @@ namespace Oxide.Plugins LoadLeases(); LoadWorld(); LoadRewards(); + LoadWorldCalls(); } // ---- the outbound queue ---- @@ -1037,6 +1056,12 @@ namespace Oxide.Plugins HandleWorldOwned(cmd, reqId); break; + case "world.call": + // Protocol 14 (PROTOCOL.md §20.5, D354). One of the game's own world events, + // now: kept, never reverted. + HandleWorldCall(cmd, reqId); + break; + case "tally.open": // Protocol 10 (PLAN.md §29). Who takes part in a run, counted by the game. HandleTallyOpen(cmd, reqId); @@ -1327,6 +1352,9 @@ namespace Oxide.Plugins // a title or a popup is not showing before anybody goes looking (§33.2). status["integrations"] = IntegrationsBody(); + // Protocol 14 (D363): whether another plugin's console command can be run from here. + status["console"] = ConsoleBody(); + return status; } @@ -1596,6 +1624,10 @@ namespace Oxide.Plugins // The map's sets are seeded here for the same reason: a hotload hears no spawns. SeedMapSets(); + // Protocol 14: the console probe (D363) and the world events' sweep. Before hello, which + // carries what the probe found. + StartWorldEvents(); + // The titles this plugin last held, if they are this wipe's (D144), and then the getter // handed to BetterChat, if it is here. When it loads later, or reloads and forgets us, // OnPluginLoaded does this again (§33.1). @@ -1654,6 +1686,7 @@ namespace Oxide.Plugins // this plugin holds. _tallies.Clear(); _connectedAt.Clear(); + ClearWorldEvents(); // Nor is anything an event placed: the old map, and everything on it, is gone. DropWorldFromOtherWipes(); @@ -1877,10 +1910,11 @@ namespace Oxide.Plugins BasePlayer attacker = info != null ? info.InitiatorPlayer : null; // Before the early return below: a patrol helicopter usually dies by crashing, and a - // crash's HitInfo names no player (§5.4). + // crash's HitInfo names no player (§5.4). Protocol 14: the death is a world event that + // carries every candidate, and the site credits Rotorbane and Tankbreaker (D372). if (IsVehicleKill(entity)) { - CreditVehicle(entity, attacker); + WorldVehicleDied(entity, attacker); return; } @@ -1991,13 +2025,6 @@ namespace Oxide.Plugins // `OnStructureRepair`, `OnHealingItemUse`, `OnPlayerRevive` — and `OnEntityTakeDamage` // can cancel the damage outright, which is exactly why none of them returns anything. - /// How long a player's hit on a vehicle still earns its kill when the blow that ends it names nobody (§5.4). - private const float VehicleCreditSeconds = 60f; - - /// The last player to hurt each APC or helicopter, by net id. A handful at most. - private readonly Dictionary> _vehicleHits = - new Dictionary>(); - /// The APC and the two helicopters: the three things a title credits a kill of (D174). private static bool IsVehicleKill(BaseEntity entity) { @@ -2007,7 +2034,9 @@ namespace Oxide.Plugins /// /// Void: a non-null return cancels the damage. It fires for every hit on everything, /// so the type test is the first thing it does; only a hit on one of the three - /// vehicles is remembered, and only who dealt it and when. + /// vehicles is remembered — who dealt it and how much — for the credit block its death + /// carries (protocol 14, D372). A helicopter that flies off the map is never killed; + /// the world events' sweep forgets its row. /// private void OnEntityTakeDamage(BaseCombatEntity entity, HitInfo info) { @@ -2020,49 +2049,7 @@ namespace Oxide.Plugins if (player == null || player.IsNpc) return; - float now = UnityEngine.Time.realtimeSinceStartup; - - // A helicopter that flies off the map is never killed, so its row is never removed - // at death; a stale one is dropped here instead. - if (_vehicleHits.Count > 16) - { - var stale = new List(); - foreach (KeyValuePair> hit in _vehicleHits) - if (now - hit.Value.Value > VehicleCreditSeconds) - stale.Add(hit.Key); - foreach (ulong id in stale) - _vehicleHits.Remove(id); - } - - _vehicleHits[entity.net.ID.Value] = new KeyValuePair(player.userID, now); - } - - /// - /// The killing blow's player (D174), or, when the blow names nobody — a helicopter - /// crashing — the last player who hurt it within . - /// - private void CreditVehicle(BaseCombatEntity entity, BasePlayer attacker) - { - KeyValuePair last = default(KeyValuePair); - bool hit = entity.net != null && _vehicleHits.TryGetValue(entity.net.ID.Value, out last); - - if (entity.net != null) - _vehicleHits.Remove(entity.net.ID.Value); - - BasePlayer credited = attacker != null && !attacker.IsNpc ? attacker : null; - - if (credited == null && hit && UnityEngine.Time.realtimeSinceStartup - last.Value <= VehicleCreditSeconds) - credited = BasePlayer.FindByID(last.Key) ?? BasePlayer.FindSleeping(last.Key); - - if (credited == null) - return; - - Tally tally = TallyFor(credited); - - if (entity is BradleyAPC) - tally.ApcKills++; - else - tally.HeliKills++; + NoteVehicleHit(entity, player, info); } /// @@ -2867,8 +2854,15 @@ namespace Oxide.Plugins public float BestPvpM; /// The longest NPC or animal kill this interval: a best, not a sum. public float BestPveM; - public int ApcKills; - public int HeliKills; + + // APC and helicopter kills left the tally at protocol 14: they are counted by the site + // from `world.event`'s credit block, by the server's own rule (D372). + + /// Protocol 14 (D356): locked crates this player started hacking, once per crate (Safecracker). + public int CratesHacked; + + /// Protocol 14 (D356): airdrops this player looted first (Scavenger). + public int DropsLooted; public readonly Dictionary Crafted = new Dictionary(); /// Stage 4 (D247): kills of RunicNPC's NPCs, by profile name. Also in . @@ -2936,7 +2930,7 @@ namespace Oxide.Plugins { return Gathered.Count == 0 && NpcKills == 0 && Structures == 0 && AnimalKills == 0 && WeaponKills.Count == 0 && Headshots == 0 - && BestPvpM <= 0f && BestPveM <= 0f && ApcKills == 0 && HeliKills == 0 + && BestPvpM <= 0f && BestPveM <= 0f && CratesHacked == 0 && DropsLooted == 0 && Crafted.Count == 0 && Built == 0 && Repaired == 0 && Healed == 0 && Rockets == 0 && Explosives == 0 && Missions == 0 && NpcProfileKills.Count == 0 && !HasBalance; @@ -3037,10 +3031,10 @@ namespace Oxide.Plugins frame["bestPvpM"] = (float)Math.Round(tally.BestPvpM, 1); if (tally.BestPveM > 0f) frame["bestPveM"] = (float)Math.Round(tally.BestPveM, 1); - if (tally.ApcKills > 0) - frame["apcKills"] = tally.ApcKills; - if (tally.HeliKills > 0) - frame["heliKills"] = tally.HeliKills; + if (tally.CratesHacked > 0) + frame["cratesHacked"] = tally.CratesHacked; + if (tally.DropsLooted > 0) + frame["dropsLooted"] = tally.DropsLooted; if (tally.Crafted.Count > 0) frame["crafted"] = tally.Crafted; if (tally.Built > 0) @@ -8855,6 +8849,7 @@ namespace Oxide.Plugins reply["eventsEnabled"] = _config != null && _config.EventsEnabled; reply["maxCrates"] = _config != null ? _config.EventsMaxCrates : WorldHardMaxCrates; reply["maxNpcs"] = _config != null ? _config.EventsMaxNpcs : WorldHardMaxNpcs; + reply["maxWorldCalls"] = WorldCallsLimit(); reply["zoneManager"] = ZoneManagerPlugin() != null; if (reqId != null) @@ -9780,7 +9775,9 @@ namespace Oxide.Plugins arg.ReplyWith( $"protocol={ProtocolVersion} eventsEnabled={(_config != null && _config.EventsEnabled)} " + $"zones={zones} crates={crates} npcs={npcs} zoneManager={(ZoneManagerPlugin() != null)} " + - $"wipeId={(_wipeId ?? "none")}" + Environment.NewLine + + $"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 + $"last: {_lastWorldSummary}"); } @@ -14423,6 +14420,9 @@ namespace Oxide.Plugins _mapWorld.Add((BaseEntity)entity); else if (IsMapBase(entity)) _mapBases.Add((BaseEntity)entity); + + // Protocol 14: the world events a spawn says (§20.5). Its own type tests, all cheap. + WorldEventSpawned(entity); } private void OnEntityKill(BaseNetworkable entity) @@ -14436,6 +14436,8 @@ namespace Oxide.Plugins if (!_mapWorld.Remove(e)) _mapBases.Remove(e); + + WorldEventKilled(entity); } /// @@ -14930,6 +14932,1431 @@ namespace Oxide.Plugins return value == null ? null : value.Trim().ToLowerInvariant(); } + // ---- Rust's own world events (protocol 14, PROTOCOL.md §20.5; PLAN_REDESIGNS §14.3.1) ---- + // + // The patrol helicopter, Bradley, the cargo ship, the Chinook, airdrops, locked crates, the + // Travelling Vendor, the satellite and the Deep Sea, as one `world.event` frame whose + // `event` says which (D349, D353). Every hook here is void and never vetoes (rule 1), the + // `Can` one included. What the test phase (§14.2.1) and stage 1's spike found shapes all of it: + // + // * **Nothing is said until the world is loaded.** A booting server spawns the cargo ship + // and the vendor it saved, and that is not either of them arriving. + // * **A crate is Rust's own only when where it is can be told positively:** an oil rig's or + // a ghost ship's prefab, a cargo ship for a parent, or a Chinook seen dropping it. Anything + // else is `other` and says nothing — Guarded Crate's crates are ordinary locked crates — + // and a crate one of our own steps placed is skipped by the world ledger. + // * **A Bradley is Launch Site's when the game's spawner holds it, a road one when the road + // event's list does.** `RoadSpawned` cannot say: it defaults to true, so any plugin's + // Bradley would read as a road one. A Bradley neither holds is `other`; its spawn is not + // news, but its death still counts toward Tankbreaker, as it did under D174. + // * **Who downed a helicopter, a Chinook or a Bradley is the site's to decide** (D372, + // D379). The plugin keeps a damage table for those three classes and sends every + // candidate; it credits nobody itself. + // * **The joins are memory only** — plane to thrower, crate to hacker, looted once — keyed + // by the entity, dropped when it goes, and swept every ten minutes. Nothing is written + // to disk: a reload forgets a crate's hacker, which costs one feed line its name. + + private const string WorldEventKind = "world.event"; + + /// How often the joins below are swept of entities the world no longer holds. + private const float WorldEventSweepSeconds = 600f; + + /// The most damagers one credit block names, by damage. A helicopter fight is rarely a crowd. + private const int CreditMaxDamagers = 32; + + /// How long a CanHackCrate may wait for the OnCrateHack it belongs to. + private const float HackJoinSeconds = 2f; + + /// + /// How long after a Chinook opens its door the crate under it may land and still be its + /// drop. The crate falls from the Chinook's height, which takes well under a minute. + /// + private const float ChinookDropJoinSeconds = 120f; + + /// How far, across the ground, a landed crate may be from the Chinook that dropped it. + private const float ChinookDropJoinMetres = 40f; + + /// A Chinook that spawns this soon after an oil rig's hack is that rig's reinforcement. + private const float RigReinforcementSeconds = 10f; + + /// Road Bradleys spawned this close together are one line: the game sends twelve at once. + private const float RoadBradleyBatchSeconds = 1f; + + /// One player, as a world event names them. + private sealed class WorldWho + { + public ulong Id; + public string Name; + + /// Time.realtimeSinceStartup when it was seen, for joins that must be close in time. + public float At; + } + + /// + /// What hurt one patrol helicopter, Chinook or Bradley. Kept until it dies or the sweep + /// finds it gone, which is how a helicopter that flies off the map is forgotten. + /// + private sealed class VehicleCredit + { + public readonly Dictionary Damage = new Dictionary(); + public readonly Dictionary Names = new Dictionary(); + + /// The last player to hurt it. + public ulong Last; + + /// + /// The killing blow, when the game named one before the death: a patrol helicopter's + /// crippling hit (OnPatrolHelicopterKill), which comes seconds before a crash + /// that names nobody. + /// + public ulong Blow; + } + + private readonly Dictionary _vehicleCredit = new Dictionary(); + + // Airdrops: who threw the signal, by plane (a plane may not have a net id when the game first + // names it), then by drop. A schedule's plane has a null caller. + private readonly Dictionary _planeCaller = new Dictionary(); + private readonly Dictionary _planeDropPoint = new Dictionary(); + private readonly HashSet _planeAnnounced = new HashSet(); + private readonly Dictionary _dropCaller = new Dictionary(); + private readonly HashSet _dropLanded = new HashSet(); + private readonly HashSet _dropLooted = new HashSet(); + + // Locked crates: the last player to try a hack, by crate; the crates whose hack has been + // said; those looted; and the crates a Chinook was seen dropping. + private readonly Dictionary _crateHacker = new Dictionary(); + private readonly HashSet _crateHackSaid = new HashSet(); + private readonly HashSet _crateLooted = new HashSet(); + private readonly HashSet _chinookCrates = new HashSet(); + private readonly List> _chinookDrops = new List>(); + + /// Where each Bradley came from, decided at its spawn, by net id. + private readonly Dictionary _bradleyWhere = new Dictionary(); + + private int _roadBradleysPending; + private Timer _roadBradleysFlush; + + /// Time.realtimeSinceStartup of the last hack started on an oil rig, or a long time ago. + private float _rigHackAt = -1000f; + + private Timer _worldEventSweep; + + /// + /// Whether a plugin's console command can be run through ConsoleSystem.Run on this + /// server (D363). Measured at every load by running this plugin's own rg.probe; + /// Carbon 2.0.262 answers "not found" to every plugin command that way (§14.2.1), and the + /// steps that drive another plugin through its console command are refused while it does. + /// + private bool _consolePlugins; + + /// Set by rg.probe while the probe runs. + private bool _consoleProbeHit; + + private static System.Reflection.FieldInfo _roadBradleysList; + + private static bool InWorld(BaseNetworkable entity) + { + return entity != null && !entity.IsDestroyed; + } + + private static WorldWho WhoOf(BasePlayer player) + { + if (player == null || player.IsNpc) + return null; + + return new WorldWho { Id = (ulong)player.userID, Name = player.displayName, At = UnityEngine.Time.realtimeSinceStartup }; + } + + /// + /// Whether world events may be said now: the world is loaded, and the server is not on its + /// way down — a shutdown is not the vendor leaving. + /// + private bool WorldEventsLive() + { + return _worldReady && !Rust.Application.isQuitting && !Rust.Application.isLoadingSave; + } + + /// World events said since this load, for rg.world. + private int _worldEventsSaid; + + private Dictionary WorldEventFrame(string type, BaseEntity at) + { + _worldEventsSaid++; + // `event`, never `type`: `type` is the envelope's, and the sidecar files on it. The + // plan's own example wrote the world event's type there, and the sidecar dropped + // every one as untyped (stage 1) — protocol 13's `world.expired` lesson again. + Dictionary frame = Frame(WorldEventKind, "event"); + frame["event"] = type; + + if (at != null && at.transform != null) + PutPosition(frame, at.transform.position); + + return frame; + } + + /// The site computes the grid from these, as the map does, so the grid has one source. + private static void PutPosition(Dictionary frame, UnityEngine.Vector3 at) + { + frame["x"] = Math.Round((double)at.x, 1); + frame["z"] = Math.Round((double)at.z, 1); + } + + private static void PutWho(Dictionary frame, WorldWho who) + { + if (who == null) + return; + + frame["steamId"] = who.Id.ToString(System.Globalization.CultureInfo.InvariantCulture); + + if (!string.IsNullOrEmpty(who.Name)) + frame["name"] = who.Name; + } + + /// + /// The monument nearest a point whose kind starts with (any, when + /// empty), onto the frame as field (its kind, the world-verb vocabulary) and + /// fieldName (the game's English name for it). Nothing when the map has none. + /// + private static void PutMonument(Dictionary frame, string field, UnityEngine.Vector3 at, string prefix) + { + string kind; + MonumentInfo near = NearestMonument(at, prefix, out kind); + + if (near == null) + return; + + frame[field] = kind; + string phrase = near.displayPhrase != null ? near.displayPhrase.english : null; + + if (!string.IsNullOrEmpty(phrase)) + frame[field + "Name"] = phrase; + } + + /// How far apart two points are across the ground, ignoring height. + private static float Distance2D(UnityEngine.Vector3 a, UnityEngine.Vector3 b) + { + float dx = a.x - b.x; + float dz = a.z - b.z; + return (float)Math.Sqrt(dx * dx + dz * dz); + } + + private static MonumentInfo NearestMonument(UnityEngine.Vector3 at, string prefix, out string kind) + { + kind = null; + MonumentInfo best = null; + float bestDistance = float.MaxValue; + List all = TerrainMeta.Path != null ? TerrainMeta.Path.Monuments : null; + + if (all == null) + return null; + + foreach (MonumentInfo m in all) + { + if (m == null || string.IsNullOrEmpty(m.name)) + continue; + + string k = Path.GetFileNameWithoutExtension(m.name).ToLowerInvariant(); + + if (!string.IsNullOrEmpty(prefix) && !k.StartsWith(prefix, StringComparison.Ordinal)) + continue; + + float d = Distance2D(m.transform.position, at); + + if (d < bestDistance) + { + bestDistance = d; + best = m; + kind = k; + } + } + + return best; + } + + /// Main thread, at every load once the world is there: the console probe, and the sweep. + private void StartWorldEvents() + { + // From a timer, not from here: this hook runs INSIDE a console command when an operator + // types `c.reload RunicGateway`, and Carbon 2.0.262 answers "not found" to every plugin + // command run from inside one — and from a NextTick queued by one. Run from a timer, or + // from a NextTick the link's reader queues (every website command's path), it finds + // them (stage 1, 2026-10-15). The probe measures the context the steps run in. + timer.Once(1f, RunConsoleProbe); + + if (_worldEventSweep == null) + _worldEventSweep = timer.Every(WorldEventSweepSeconds, SweepWorldEvents); + } + + /// + /// D363: can a plugin's console command be run from code here? Our own is the test, + /// because it is the one command this plugin knows is registered. Quiet, so a server where + /// it is not found does not print the refusal at every load. Hello is said again when the + /// answer changed, so the site does not wait a minute for it. + /// + private void RunConsoleProbe() + { + _consoleProbeHit = false; + + try + { + ConsoleSystem.Run(ConsoleSystem.Option.Server.Quiet(), "rg.probe"); + } + catch (Exception ex) + { + Puts($"could not run the console probe: {Flatten(ex.Message)}"); + } + + bool before = _consolePlugins; + _consolePlugins = _consoleProbeHit; + + if (!_consolePlugins) + Puts("console probe: this framework does not run a plugin's console command from code; steps that need one are refused (D363)"); + + if (before != _consolePlugins && _connected) + SendHello(); + } + + /// + /// The probe's target. Only the server itself may run it; a player typing it in F1 changes + /// nothing, because the flag is read only while the probe is running. + /// + [ConsoleCommand("rg.probe")] + private void CmdProbe(ConsoleSystem.Arg arg) + { + if (arg.Connection != null) + return; + + _consoleProbeHit = true; + } + + /// Hello's and status's console: what the probe found at this load. + private Dictionary ConsoleBody() + { + return new Dictionary { { "plugins", _consolePlugins } }; + } + + /// Every ten minutes: what the world no longer holds is forgotten (bounded state). + private void SweepWorldEvents() + { + SweepIds(_vehicleCredit); + SweepIds(_dropCaller); + SweepIds(_dropLanded); + SweepIds(_dropLooted); + SweepIds(_crateHacker); + SweepIds(_crateHackSaid); + SweepIds(_crateLooted); + SweepIds(_chinookCrates); + SweepIds(_bradleyWhere); + + var planes = new List(); + + foreach (BaseEntity plane in _planeCaller.Keys) + if (plane == null || plane.IsDestroyed) + planes.Add(plane); + + foreach (BaseEntity plane in _planeAnnounced) + if (plane == null || plane.IsDestroyed) + planes.Add(plane); + + foreach (BaseEntity plane in planes) + ForgetPlane(plane); + + float now = UnityEngine.Time.realtimeSinceStartup; + _chinookDrops.RemoveAll(d => now - d.Value > ChinookDropJoinSeconds); + } + + private static void SweepIds(HashSet ids) + { + ids.RemoveWhere(id => BaseNetworkable.serverEntities.Find(new NetworkableId(id)) == null); + } + + private static void SweepIds(Dictionary byId) + { + var gone = new List(); + + foreach (ulong id in byId.Keys) + if (BaseNetworkable.serverEntities.Find(new NetworkableId(id)) == null) + gone.Add(id); + + foreach (ulong id in gone) + byId.Remove(id); + } + + private void ForgetPlane(BaseEntity plane) + { + _planeCaller.Remove(plane); + _planeDropPoint.Remove(plane); + _planeAnnounced.Remove(plane); + } + + /// A wipe: no entity of the old map is coming back, so nothing joined to one is kept. + private void ClearWorldEvents() + { + _vehicleCredit.Clear(); + _planeCaller.Clear(); + _planeDropPoint.Clear(); + _planeAnnounced.Clear(); + _dropCaller.Clear(); + _dropLanded.Clear(); + _dropLooted.Clear(); + _crateHacker.Clear(); + _crateHackSaid.Clear(); + _crateLooted.Clear(); + _chinookCrates.Clear(); + _chinookDrops.Clear(); + _bradleyWhere.Clear(); + } + + /// + /// From OnEntitySpawned, which runs for every spawn on the server: the type tests + /// come first and are all this costs for anything that is not one of these. + /// + private void WorldEventSpawned(BaseNetworkable entity) + { + if (entity is PatrolHelicopter) + { + if (WorldEventsLive()) + EmitFrame(WorldEventFrame("heli.arrived", (BaseEntity)entity)); + } + else if (entity is CargoShip) + { + if (WorldEventsLive()) + EmitFrame(WorldEventFrame("cargo.arrived", (BaseEntity)entity)); + } + else if (entity is CH47HelicopterAIController) + { + if (WorldEventsLive()) + ChinookSpawned((CH47HelicopterAIController)entity); + } + else if (entity is TravellingVendor) + { + if (WorldEventsLive()) + EmitFrame(WorldEventFrame("vendor.arrived", (BaseEntity)entity)); + } + else if (entity is CargoPlane) + { + if (WorldEventsLive()) + AnnounceAirdropSoon((BaseEntity)entity); + } + else if (entity is SatelliteCrashRemains) + { + // The satellite has no hook of its own; its remains appear at the impact (§14.2.1). + if (WorldEventsLive()) + EmitFrame(WorldEventFrame("satellite.crashed", (BaseEntity)entity)); + } + } + + /// From OnEntityKill. The vendor leaving is the only kill that is news. + private void WorldEventKilled(BaseNetworkable entity) + { + if (entity is TravellingVendor) + { + if (WorldEventsLive()) + EmitFrame(WorldEventFrame("vendor.left", (BaseEntity)entity)); + } + else if (entity is CargoPlane) + { + ForgetPlane((BaseEntity)entity); + } + } + + /// + /// A Chinook, a second later: by then an oil rig's reinforcement has its landing target, + /// and it is told by the crate hack it answers rather than said as a Chinook arriving. + /// + private void ChinookSpawned(CH47HelicopterAIController chinook) + { + timer.Once(1f, () => + { + if (!InWorld(chinook) || !WorldEventsLive()) + return; + + if (chinook.ShouldLand() || UnityEngine.Time.realtimeSinceStartup - _rigHackAt < RigReinforcementSeconds) + return; + + EmitFrame(WorldEventFrame("chinook.arrived", chinook)); + }); + } + + // ── The helicopter, the Chinook and the Bradley: credit (D372, D379) ────────────────── + + /// Main thread. One hit on one of the three, from OnEntityTakeDamage. + private void NoteVehicleHit(BaseCombatEntity entity, BasePlayer player, HitInfo info) + { + VehicleCredit credit = CreditFor(entity); + ulong id = (ulong)player.userID; + float damage = info.damageTypes != null ? info.damageTypes.Total() : 0f; + float had; + + credit.Damage.TryGetValue(id, out had); + credit.Damage[id] = had + Math.Max(0f, damage); + credit.Names[id] = player.displayName; + credit.Last = id; + } + + private VehicleCredit CreditFor(BaseEntity entity) + { + VehicleCredit credit; + ulong id = entity.net.ID.Value; + + if (!_vehicleCredit.TryGetValue(id, out credit)) + { + credit = new VehicleCredit(); + _vehicleCredit[id] = credit; + } + + return credit; + } + + /// + /// Fires twice for one helicopter (§14.2.1): at the crippling hit, naming the player, and + /// at the crash, naming nobody. The first player it names is the killing blow. + /// + private void OnPatrolHelicopterKill(PatrolHelicopter heli, HitInfo info) + { + MarkHook("OnPatrolHelicopterKill"); + + BasePlayer player = info != null ? info.InitiatorPlayer : null; + + if (heli == null || heli.net == null || player == null || player.IsNpc) + return; + + VehicleCredit credit = CreditFor(heli); + + if (credit.Blow == 0UL) + { + credit.Blow = (ulong)player.userID; + credit.Names[credit.Blow] = player.displayName; + } + } + + /// + /// From OnEntityDeath: a patrol helicopter, a Chinook or a Bradley died. The frame + /// carries every candidate for the credit and the site applies the server's rule to the + /// line and to Rotorbane / Tankbreaker alike, so the two never disagree (D372). + /// + private void WorldVehicleDied(BaseCombatEntity entity, BasePlayer attacker) + { + VehicleCredit credit = null; + + if (entity.net != null && _vehicleCredit.TryGetValue(entity.net.ID.Value, out credit)) + _vehicleCredit.Remove(entity.net.ID.Value); + + if (!_worldReady) + return; + + Dictionary frame; + + if (entity is BradleyAPC) + { + frame = WorldEventFrame("bradley.destroyed", entity); + frame["where"] = BradleyWhere((BradleyAPC)entity) ?? "other"; + + if (entity.net != null) + _bradleyWhere.Remove(entity.net.ID.Value); + } + else if (entity is PatrolHelicopter) + { + frame = WorldEventFrame("heli.downed", entity); + } + else + { + frame = WorldEventFrame("chinook.downed", entity); + } + + PutCredit(frame, credit, attacker != null && !attacker.IsNpc ? attacker : null); + EmitFrame(frame); + } + + /// + /// credit {blow, last, most, damagers} and names. Each of the three is absent + /// when nobody qualifies; a death nobody touched carries an empty damagers. + /// + private static void PutCredit(Dictionary frame, VehicleCredit credit, BasePlayer killer) + { + var body = new Dictionary(); + var names = new Dictionary(); + var rows = new List>(); + + if (credit != null) + rows.AddRange(credit.Damage); + + rows.Sort((a, b) => b.Value.CompareTo(a.Value)); + + ulong blow = credit != null ? credit.Blow : 0UL; + + // A Bradley's and a Chinook's death names its killer outright; a helicopter's crash + // does not, and its blow was kept at the crippling hit. + if (blow == 0UL && killer != null) + blow = (ulong)killer.userID; + + if (blow != 0UL) + body["blow"] = Id(blow); + + if (credit != null && credit.Last != 0UL) + body["last"] = Id(credit.Last); + + if (rows.Count > 0) + body["most"] = Id(rows[0].Key); + + var damagers = new List(); + + for (int i = 0; i < rows.Count && i < CreditMaxDamagers; i++) + { + damagers.Add(new Dictionary + { + { "steamId", Id(rows[i].Key) }, + { "damage", Math.Round((double)rows[i].Value, 1) } + }); + } + + body["damagers"] = damagers; + + if (credit != null) + foreach (KeyValuePair n in credit.Names) + names[Id(n.Key)] = n.Value; + + if (killer != null && blow == (ulong)killer.userID) + names[Id(blow)] = killer.displayName; + + frame["credit"] = body; + + if (names.Count > 0) + frame["names"] = names; + } + + private static string Id(ulong id) + { + return id.ToString(System.Globalization.CultureInfo.InvariantCulture); + } + + // ── The patrol helicopter leaving ─────────────────────────────────────────────────────── + + private void OnHelicopterRetire(PatrolHelicopterAI ai) + { + MarkHook("OnHelicopterRetire"); + + if (ai != null && ai.helicopterBase != null && WorldEventsLive()) + EmitFrame(WorldEventFrame("heli.left", ai.helicopterBase)); + } + + // ── Bradley ───────────────────────────────────────────────────────────────────────────── + + /// + /// Next tick, because both spawners record the Bradley only after this hook has run inside + /// its spawn. Launch Site's is one line; road Bradleys are gathered for a second and said + /// as one line with a count. + /// + private void OnBradleyApcInitialize(BradleyAPC apc) + { + MarkHook("OnBradleyApcInitialize"); + + if (apc == null || !WorldEventsLive()) + return; + + NextTick(() => + { + if (!InWorld(apc)) + return; + + string where = BradleyWhere(apc); + + if (where == "launch_site") + { + Dictionary frame = WorldEventFrame("bradley.spawned", null); + frame["where"] = where; + frame["count"] = 1; + EmitFrame(frame); + } + else if (where == "road") + { + _roadBradleysPending++; + + if (_roadBradleysFlush == null) + _roadBradleysFlush = timer.Once(RoadBradleyBatchSeconds, FlushRoadBradleys); + } + }); + } + + private void FlushRoadBradleys() + { + _roadBradleysFlush = null; + int count = _roadBradleysPending; + _roadBradleysPending = 0; + + if (count <= 0 || !WorldEventsLive()) + return; + + Dictionary frame = WorldEventFrame("bradley.spawned", null); + frame["where"] = "road"; + frame["count"] = count; + EmitFrame(frame); + } + + /// launch_site, road, or null for a Bradley neither of the game's spawners holds. + private string BradleyWhere(BradleyAPC apc) + { + string where; + + if (apc.net != null && _bradleyWhere.TryGetValue(apc.net.ID.Value, out where)) + return where; + + where = null; + + if (BradleySpawner.singleton != null && BradleySpawner.singleton.spawned == apc) + where = "launch_site"; + else if (InRoadBradleys(apc)) + where = "road"; + + if (where != null && apc.net != null) + _bradleyWhere[apc.net.ID.Value] = where; + + return where; + } + + /// Whether the road event holds this Bradley in its own list, which is private. + private static bool InRoadBradleys(BradleyAPC apc) + { + if (_roadBradleysList == null) + _roadBradleysList = typeof(RoadBradleys).GetField("spawnedAPCs", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic); + + if (_roadBradleysList == null) + return false; + + foreach (RoadBradleys road in UnityEngine.Object.FindObjectsOfType()) + { + var list = _roadBradleysList.GetValue(road) as List; + + if (list != null && list.Contains(apc)) + return true; + } + + return false; + } + + // ── The cargo ship ────────────────────────────────────────────────────────────────────── + + private void OnCargoShipHarborApproach(CargoShip ship, object harbor = null) + { + MarkHook("OnCargoShipHarborApproach"); + EmitCargo("cargo.docking", ship); + } + + private void OnCargoShipHarborArrived(CargoShip ship) + { + MarkHook("OnCargoShipHarborArrived"); + EmitCargo("cargo.docked", ship); + } + + private void OnCargoShipHarborLeave(CargoShip ship) + { + MarkHook("OnCargoShipHarborLeave"); + EmitCargo("cargo.undocked", ship); + } + + /// The ship turning for the edge of the map, radiation on: it has left. The kill follows when it is gone (stage 1's spike). + private void OnCargoShipEgress(CargoShip ship) + { + MarkHook("OnCargoShipEgress"); + + if (ship != null && WorldEventsLive()) + EmitFrame(WorldEventFrame("cargo.left", ship)); + } + + /// The three harbour types name the nearest harbour. + private void EmitCargo(string type, CargoShip ship) + { + if (ship == null || !WorldEventsLive()) + return; + + Dictionary frame = WorldEventFrame(type, ship); + PutMonument(frame, "harbour", ship.transform.position, "harbor"); + EmitFrame(frame); + } + + // ── The Chinook's drop ────────────────────────────────────────────────────────────────── + + /// + /// The door opening over a drop zone. The crate is made a moment later with no net id + /// yet, so it is joined to this drop when it lands, by place and time. + /// + private void OnHelicopterDropCrate(CH47HelicopterAIController chinook) + { + MarkHook("OnHelicopterDropCrate"); + + if (chinook == null || chinook.transform == null) + return; + + float now = UnityEngine.Time.realtimeSinceStartup; + _chinookDrops.RemoveAll(d => now - d.Value > ChinookDropJoinSeconds); + _chinookDrops.Add(new KeyValuePair(chinook.transform.position, now)); + } + + private void OnCrateLanded(HackableLockedCrate crate) + { + MarkHook("OnCrateLanded"); + + if (crate == null || crate.net == null || _chinookDrops.Count == 0) + return; + + ulong id = crate.net.ID.Value; + + if (_chinookCrates.Contains(id)) + return; + + float now = UnityEngine.Time.realtimeSinceStartup; + UnityEngine.Vector3 at = crate.transform.position; + int found = _chinookDrops.FindIndex(d => now - d.Value <= ChinookDropJoinSeconds + && Distance2D(d.Key, at) <= ChinookDropJoinMetres); + + if (found < 0) + return; + + _chinookDrops.RemoveAt(found); + _chinookCrates.Add(id); + + if (!WorldEventsLive() || InWorldLedger(id)) + return; + + Dictionary frame = WorldEventFrame("chinook.dropped", crate); + PutMonument(frame, "monument", at, null); + EmitFrame(frame); + } + + // ── Locked crates ─────────────────────────────────────────────────────────────────────── + + /// A crate one of our own steps placed. It is the event's, not Rust's. + private bool InWorldLedger(ulong id) + { + return _world.ContainsKey(id.ToString(System.Globalization.CultureInfo.InvariantCulture)); + } + + /// + /// Where a locked crate is, told positively: oilrig_large, oilrig_small, + /// cargo, ghostship, chinook, or other for anything else. + /// + private string CrateWhere(HackableLockedCrate crate) + { + if (crate.net != null && InWorldLedger(crate.net.ID.Value)) + return "other"; + + string prefab = crate.ShortPrefabName ?? string.Empty; + + if (prefab.EndsWith("_oilrig", StringComparison.Ordinal)) + { + string kind; + NearestMonument(crate.transform.position, "oilrig", out kind); + + if (kind == "oilrig_1") + return "oilrig_large"; + if (kind == "oilrig_2") + return "oilrig_small"; + return "other"; + } + + if (prefab.EndsWith("_ghostship", StringComparison.Ordinal)) + return "ghostship"; + + if (crate.GetParentEntity() is CargoShip) + return "cargo"; + + if (crate.net != null && _chinookCrates.Contains(crate.net.ID.Value)) + return "chinook"; + + return "other"; + } + + /// Who is trying. The hack itself is OnCrateHack, which names nobody, a moment later. + private void CanHackCrate(BasePlayer player, HackableLockedCrate crate) + { + MarkHook("CanHackCrate"); + + WorldWho who = WhoOf(player); + + if (who != null && crate != null && crate.net != null) + _crateHacker[crate.net.ID.Value] = who; + } + + /// + /// A hack started: said once per crate, and counted once for its starter (Safecracker, + /// D356). On an oil rig this is also the scientists being called, at once. + /// + private void OnCrateHack(HackableLockedCrate crate) + { + MarkHook("OnCrateHack"); + + if (crate == null || crate.net == null) + return; + + string where = CrateWhere(crate); + + if (where.StartsWith("oilrig", StringComparison.Ordinal)) + _rigHackAt = UnityEngine.Time.realtimeSinceStartup; + + ulong id = crate.net.ID.Value; + + if (where == "other" || !_crateHackSaid.Add(id)) + return; + + WorldWho who; + _crateHacker.TryGetValue(id, out who); + + if (who != null && UnityEngine.Time.realtimeSinceStartup - who.At > HackJoinSeconds) + who = null; + + if (who != null) + { + BasePlayer player = BasePlayer.FindByID(who.Id) ?? BasePlayer.FindSleeping(who.Id); + + if (player != null) + TallyFor(player).CratesHacked++; + } + else + { + _crateHacker.Remove(id); + } + + if (!WorldEventsLive()) + return; + + Dictionary frame = WorldEventFrame("crate.hack.started", crate); + frame["where"] = where; + PutWho(frame, who); + EmitFrame(frame); + } + + private void OnCrateHackEnd(HackableLockedCrate crate) + { + MarkHook("OnCrateHackEnd"); + + if (crate == null || crate.net == null || !WorldEventsLive()) + return; + + string where = CrateWhere(crate); + + if (where == "other") + return; + + WorldWho who; + _crateHacker.TryGetValue(crate.net.ID.Value, out who); + + Dictionary frame = WorldEventFrame("crate.unlocked", crate); + frame["where"] = where; + PutWho(frame, who); + EmitFrame(frame); + } + + /// + /// The first loot of an airdrop (Scavenger, D356) and of an unlocked crate. Fires for every + /// container anybody opens, so the type test is first. + /// + private void OnLootEntity(BasePlayer player, BaseEntity entity) + { + MarkHook("OnLootEntity"); + + if (!(entity is SupplyDrop || entity is HackableLockedCrate) || entity.net == null || player == null || player.IsNpc) + return; + + ulong id = entity.net.ID.Value; + + if (InWorldLedger(id)) + return; + + if (entity is SupplyDrop) + { + if (!_dropLooted.Add(id)) + return; + + TallyFor(player).DropsLooted++; + + if (!WorldEventsLive()) + return; + + Dictionary drop = WorldEventFrame("airdrop.looted", entity); + PutWho(drop, WhoOf(player)); + EmitFrame(drop); + return; + } + + var crate = (HackableLockedCrate)entity; + + if (!crate.IsFullyHacked() || _crateLooted.Contains(id)) + return; + + string where = CrateWhere(crate); + + if (where == "other") + return; + + _crateLooted.Add(id); + + if (!WorldEventsLive()) + return; + + Dictionary frame = WorldEventFrame("crate.looted", crate); + frame["where"] = where; + PutWho(frame, WhoOf(player)); + EmitFrame(frame); + } + + // ── Airdrops ──────────────────────────────────────────────────────────────────────────── + + /// A supply signal: its thrower is the caller, and the drop point is where it lies. + private void OnCargoPlaneSignaled(BaseEntity plane, SupplySignal signal) + { + MarkHook("OnCargoPlaneSignaled"); + + if (plane == null || signal == null) + return; + + _planeCaller[plane] = WhoOf(signal.creatorEntity as BasePlayer); + _planeDropPoint[plane] = signal.transform.position; + } + + /// The schedule's airdrop: nobody called it. Only its drop point is kept; the plane's spawn says the line. + private void OnAirdrop(CargoPlane plane, UnityEngine.Vector3 at = default(UnityEngine.Vector3)) + { + MarkHook("OnAirdrop"); + + if (plane != null && !_planeDropPoint.ContainsKey(plane) && at != UnityEngine.Vector3.zero) + _planeDropPoint[plane] = at; + } + + private static System.Reflection.FieldInfo _planeDropPositionField; + + /// + /// A cargo plane's spawn, next tick, says airdrop.called: once per plane, by every + /// route there is. The console's supply.call raised neither hook above (stage 1), + /// so the spawn is the one thing every airdrop has; the hooks only say who and where, + /// and next tick is after a signal's hook has named its thrower. + /// + private void AnnounceAirdropSoon(BaseEntity plane) + { + NextTick(() => + { + if (plane == null || plane.IsDestroyed || !_planeAnnounced.Add(plane) || !WorldEventsLive()) + return; + + WorldWho who; + _planeCaller.TryGetValue(plane, out who); + + Dictionary frame = WorldEventFrame("airdrop.called", null); + UnityEngine.Vector3 at; + + if (_planeDropPoint.TryGetValue(plane, out at) || TryPlaneDropPoint(plane, out at)) + PutPosition(frame, at); + + PutWho(frame, who); + EmitFrame(frame); + }); + } + + /// The plane's own drop point, which is private to it: read by reflection, as Carbon leaves it private. + private static bool TryPlaneDropPoint(BaseEntity plane, out UnityEngine.Vector3 at) + { + at = UnityEngine.Vector3.zero; + + if (_planeDropPositionField == null) + _planeDropPositionField = typeof(CargoPlane).GetField("dropPosition", + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic); + + if (_planeDropPositionField == null || !(plane is CargoPlane)) + return false; + + object value = _planeDropPositionField.GetValue(plane); + + if (!(value is UnityEngine.Vector3)) + return false; + + at = (UnityEngine.Vector3)value; + return at != UnityEngine.Vector3.zero; + } + + private void OnSupplyDropDropped(BaseEntity drop, CargoPlane plane) + { + MarkHook("OnSupplyDropDropped"); + + if (drop == null || drop.net == null || plane == null) + return; + + WorldWho who; + + if (_planeCaller.TryGetValue(plane, out who) && who != null) + _dropCaller[drop.net.ID.Value] = who; + } + + /// Said once per drop: the hook fired twice for one drop in the test phase. + private void OnSupplyDropLanded(SupplyDrop drop) + { + MarkHook("OnSupplyDropLanded"); + + if (drop == null || drop.net == null || InWorldLedger(drop.net.ID.Value) || !_dropLanded.Add(drop.net.ID.Value)) + return; + + if (!WorldEventsLive()) + return; + + WorldWho who; + _dropCaller.TryGetValue(drop.net.ID.Value, out who); + + Dictionary frame = WorldEventFrame("airdrop.landed", drop); + PutWho(frame, who); + EmitFrame(frame); + } + + // ── The Deep Sea (off the map: no position) ───────────────────────────────────────────── + + private void OnDeepSeaOpened(DeepSeaManager manager) + { + MarkHook("OnDeepSeaOpened"); + + if (WorldEventsLive()) + EmitFrame(WorldEventFrame("deepsea.opened", null)); + } + + private void OnDeepSeaClosed(DeepSeaManager manager) + { + MarkHook("OnDeepSeaClosed"); + + if (WorldEventsLive()) + EmitFrame(WorldEventFrame("deepsea.closed", null)); + } + + // ---- calling a world event (protocol 14, PROTOCOL.md §20.5; D354, D367, D378) ---- + // + // `world.call` runs one of the game's own scheduled events now, the way the schedule does: + // one of its `TriggeredEvent`s' `RunEvent`, without `Trigger`, which would also reset the + // schedule's own clock. No console command is involved, so it works the same on Carbon. + // + // **Kept.** Nothing a call starts is removed at teardown — a cargo ship or a drop may + // already hold players or their loot (D354) — so there is no registry of what a call made, + // only of the call: its key, for a retry, and its run, for the per-run ceiling. + // + // **A position only where the game has a route to one** (stage 1's spike): the airdrop's + // drop point (`InitDropPosition` before it spawns) and the patrol helicopter's first + // destination (`SetInitialDestination` before it spawns, after which it patrols as usual). + // The Chinook drops only at the map's drop zones, and the one route to a chosen zone is + // the Airfield terminal's shared state, so it takes no position (D378). + + /// The events a call may run: the schedules' keys, and the satellite. + private static readonly string[] WorldCallEvents = + { + "patrol_heli", "cargo", "chinook", "airdrop", "travelling_vendor", "bradley_road", "f15", "satellite" + }; + + /// The most of one event one call runs. The game runs the cargo ship, the vendor and road Bradleys one at a time. + private const int WorldCallHardMaxCount = 5; + + /// The ceiling on calls per run. EventsMaxWorldCalls may lower it, never raise it. + private const int WorldCallsHardMax = 10; + + /// How long a call's key is remembered: seven days, as a payment's is. + private const long WorldCallMemoryMs = 7L * 24L * 60L * 60L * 1000L; + + /// One call, kept for its key and its run. Persisted as it is. + private sealed class WorldCall + { + public string Key; + public string RunId; + public string Event; + public int Called; + public long AtMs; + } + + private Dictionary _worldCalls = new Dictionary(StringComparer.Ordinal); + + private static bool WorldCallPlaceable(string ev) + { + return ev == "airdrop" || ev == "patrol_heli"; + } + + private static int WorldCallMaxCount(string ev) + { + return ev == "cargo" || ev == "travelling_vendor" || ev == "bradley_road" ? 1 : WorldCallHardMaxCount; + } + + private string WorldCallsPath() + { + return Path.Combine(Interface.Oxide.DataDirectory, Name, "world-calls.json"); + } + + private void SaveWorldCalls() + { + WriteDataFile(WorldCallsPath(), new List(_worldCalls.Values), "world calls"); + } + + private void LoadWorldCalls() + { + _worldCalls = new Dictionary(StringComparer.Ordinal); + List calls = ReadDataFile(WorldCallsPath(), "world calls"); + + if (calls != null) + foreach (WorldCall c in calls) + if (c != null && !string.IsNullOrEmpty(c.Key)) + _worldCalls[c.Key] = c; + + ForgetOldWorldCalls(); + } + + private void ForgetOldWorldCalls() + { + long now = NowMs(); + var stale = new List(); + + foreach (KeyValuePair kv in _worldCalls) + if (now >= kv.Value.AtMs + WorldCallMemoryMs) + stale.Add(kv.Key); + + foreach (string key in stale) + _worldCalls.Remove(key); + + if (stale.Count > 0) + SaveWorldCalls(); + } + + private int WorldCallsLimit() + { + return _config != null ? _config.EventsMaxWorldCalls : WorldCallsHardMax; + } + + /// Main thread. Run one of the game's world events now, for a run. + private void HandleWorldCall(Dictionary cmd, string reqId) + { + if (!WorldWritesAllowed(reqId)) + return; + + string key = StringField(cmd, "key"); + string runId = StringField(cmd, "runId"); + + if (string.IsNullOrEmpty(key) || key.Length > PaymentKeyMax || string.IsNullOrEmpty(runId)) + { + EmitWorldError(reqId, "malformed", "a call is made for a run, under a key of at most 128 characters"); + return; + } + + ForgetOldWorldCalls(); + + WorldCall seen; + + if (_worldCalls.TryGetValue(key, out seen)) + { + EmitWorldCalled(reqId, seen, true); + return; + } + + string ev = (StringField(cmd, "event") ?? string.Empty).Trim().ToLowerInvariant(); + + if (Array.IndexOf(WorldCallEvents, ev) < 0) + { + EmitWorldError(reqId, "bad-event", $"'{ev}' is not a world event this server calls ({string.Join(", ", WorldCallEvents)})"); + return; + } + + int count = cmd.ContainsKey("count") ? IntField(cmd, "count") : 1; + + if (count < 1 || count > WorldCallMaxCount(ev)) + { + EmitWorldError(reqId, "bad-count", $"'{ev}' is called 1 to {WorldCallMaxCount(ev)} at a time, not {count}"); + return; + } + + bool placed = !string.IsNullOrEmpty(StringField(cmd, "monument")) || cmd.ContainsKey("x") || cmd.ContainsKey("z"); + UnityEngine.Vector3 at = UnityEngine.Vector3.zero; + + if (placed) + { + if (!WorldCallPlaceable(ev)) + { + EmitWorldError(reqId, "not-placeable", $"'{ev}' goes where the game puts it; only an airdrop and a patrol helicopter take a position"); + return; + } + + string reason, refusal; + + if (!ResolveWorldPoint(cmd, out at, out reason, out refusal)) + { + EmitWorldError(reqId, reason, refusal); + return; + } + } + + int used = 0; + + foreach (WorldCall c in _worldCalls.Values) + if (c.RunId == runId) + used += c.Called; + + if (used + count > WorldCallsLimit()) + { + EmitWorldError(reqId, "too-many", $"a run calls at most {WorldCallsLimit()} world events on this server, and this one has called {used}"); + return; + } + + int called = 0; + string failure = null; + + for (int i = 0; i < count; i++) + { + if (!RunWorldCall(ev, placed ? (UnityEngine.Vector3?)at : null, out failure)) + break; + + called++; + } + + if (called == 0) + { + EmitWorldError(reqId, "refused", failure ?? $"the game did not start '{ev}'"); + return; + } + + var call = new WorldCall { Key = key, RunId = runId, Event = ev, Called = called, AtMs = NowMs() }; + _worldCalls[key] = call; + SaveWorldCalls(); + + _lastWorldSummary = $"called {called} {ev} for run {runId}" + (placed ? $" at {Grid(at) ?? "?"}" : string.Empty); + Puts($"world: {_lastWorldSummary}"); + EmitWorldCalled(reqId, call, false); + } + + private void EmitWorldCalled(string reqId, WorldCall call, bool repeat) + { + Dictionary frame = Frame("world.called", "reply"); + frame["key"] = call.Key; + frame["event"] = call.Event; + frame["repeat"] = repeat; + frame["called"] = call.Called; + + if (reqId != null) + frame["reqId"] = reqId; + + EmitFrame(frame); + } + + /// The game's own schedule for an event, enabled or not: an operator who switched one off may still call it. + private static EventSchedule ScheduleFor(string key) + { + foreach (EventSchedule s in UnityEngine.Object.FindObjectsOfType()) + if (s != null && string.Equals(s.Key, key, StringComparison.OrdinalIgnoreCase)) + return s; + + return null; + } + + /// Main thread. One run of one event; false, with why, when the game has nothing to run. + private bool RunWorldCall(string ev, UnityEngine.Vector3? at, out string failure) + { + failure = null; + + if (ev == "satellite") + { + // The satellite is not on the schedule; its own console command crashes one at a + // random spot. A native command, which Carbon runs from code (§14.2.1). + ConsoleSystem.Run(ConsoleSystem.Option.Server.Quiet(), "satellite.random"); + return true; + } + + EventSchedule schedule = ScheduleFor(ev); + TriggeredEvent[] events = schedule != null ? schedule.GetComponents() : null; + + if (events == null || events.Length == 0) + { + failure = $"this map has no '{ev}' event"; + return false; + } + + if (at.HasValue) + return SpawnPlaced(ev, events, at.Value, out failure); + + // As the schedule picks: one of its events at random. + TriggeredEvent picked = events[UnityEngine.Random.Range(0, events.Length)]; + + try + { + picked.RunEvent(); + } + catch (Exception ex) + { + failure = $"the game could not start '{ev}': {Flatten(ex.Message)}"; + return false; + } + + return true; + } + + /// + /// The airdrop or the patrol helicopter at a point: the schedule's own prefab, spawned the + /// way TriggeredEventPrefab.RunEvent does it, with the position set before the spawn. + /// + private bool SpawnPlaced(string ev, TriggeredEvent[] events, UnityEngine.Vector3 at, out string failure) + { + failure = null; + TriggeredEventPrefab source = null; + + foreach (TriggeredEvent e in events) + { + source = e as TriggeredEventPrefab; + + if (source != null && source.targetPrefab != null && source.targetPrefab.isValid) + break; + + source = null; + } + + if (source == null) + { + failure = $"'{ev}' has no prefab to place on this map"; + return false; + } + + BaseEntity entity = GameManager.server.CreateEntity(source.targetPrefab.resourcePath); + + if (entity == null) + { + failure = $"the game could not create '{ev}'"; + return false; + } + + entity.SendMessage("TriggeredEventSpawn", UnityEngine.SendMessageOptions.DontRequireReceiver); + + var plane = entity as CargoPlane; + var heli = entity as PatrolHelicopter; + + if (plane != null) + { + plane.InitDropPosition(at); + } + else if (heli != null) + { + PatrolHelicopterAI ai = heli.GetComponent(); + + if (ai == null) + { + entity.Kill(); + failure = "the patrol helicopter has no AI to send"; + return false; + } + + at.y = TerrainMeta.HeightMap.GetHeight(at) + 10f; + ai.SetInitialDestination(at, 0.25f); + } + else + { + entity.Kill(); + failure = $"'{ev}' cannot be placed"; + return false; + } + + entity.Spawn(); + entity.SendMessage("TriggeredEventPostSpawn", UnityEngine.SendMessageOptions.DontRequireReceiver); + + if (source.shouldBroadcastSpawn) + BasePlayer.Server_SendWorldNotificationToAllActivePlayers(source.notificationType, entity.transform.position); + + return true; + } + // ---- diagnostics ---- /// diff --git a/scripts/checkPlugin.js b/scripts/checkPlugin.js index bf4e078..3d5049d 100644 --- a/scripts/checkPlugin.js +++ b/scripts/checkPlugin.js @@ -113,6 +113,16 @@ function readChatCommands(source) { return found } +/** Every `[ConsoleCommand("x")]` the plugin declares. */ +function readConsoleCommands(source) { + return [...source.matchAll(/\[ConsoleCommand\("([^"]+)"\)\]/g)].map((m) => m[1]) +} + +/** The `rg.*` commands the plugin runs on itself through `ConsoleSystem.Run`. */ +function readOwnConsoleRuns(source) { + return [...source.matchAll(/ConsoleSystem\.Run\([^,]+,\s*"(rg\.[\w.]+)"/g)].map((m) => m[1]) +} + function readMethods(source) { const found = [] let m @@ -196,7 +206,20 @@ function check(source, toml) { seen.add(cmd.command) } - // 5. The two declaration sites this repository owns must agree. + // 5. Protocol 14 (D363): the console probe runs one of this plugin's own commands + // through `ConsoleSystem.Run` to learn whether the framework runs a plugin's + // command from code. A probe naming a command the plugin does not declare + // answers "no" on every framework, which reads exactly like Carbon's bug. + for (const name of readOwnConsoleRuns(source)) { + if (!readConsoleCommands(source).includes(name)) { + problems.push( + `ConsoleSystem.Run runs "${name}", but the plugin declares no [ConsoleCommand("${name}")]: ` + + 'the console probe would report false on every framework (D363).' + ) + } + } + + // 6. The two declaration sites this repository owns must agree. const inCode = /ProtocolVersion\s*=\s*(\d+)\s*;/.exec(source) // runicnpc stage 4: the RunicNPC API the bridge needs, declared twice like the protocol. const npcToml = /^\s*runicnpc_api\s*=\s*(\d+)\s*$/m.exec(toml) @@ -242,6 +265,6 @@ function main() { ) } -module.exports = { check, readExpectedHooks, readMethods, readChatCommands, HOOK_NAME } +module.exports = { check, readExpectedHooks, readMethods, readChatCommands, readConsoleCommands, readOwnConsoleRuns, HOOK_NAME } if (require.main === module) main() diff --git a/scripts/checkPlugin.test.js b/scripts/checkPlugin.test.js index ebb8eff..ee4f6f9 100644 --- a/scripts/checkPlugin.test.js +++ b/scripts/checkPlugin.test.js @@ -16,7 +16,7 @@ const assert = require('node:assert') const fs = require('node:fs') const path = require('node:path') -const { check, readExpectedHooks, readMethods, readChatCommands, HOOK_NAME } = require('./checkPlugin') +const { check, readExpectedHooks, readMethods, readChatCommands, readConsoleCommands, readOwnConsoleRuns, HOOK_NAME } = require('./checkPlugin') /** A minimal plugin that passes, as the baseline every case below deviates from. */ function source({ expected = ['OnPlayerDeath'], methods, version = 2 } = {}) { @@ -210,3 +210,55 @@ test('the chat-command parser reads the real plugin, rather than quietly matchin assert.ok(names.has('link'), '/link was not found by the chat-command parser') assert.ok(names.has('unlink'), '/unlink was not found by the chat-command parser') }) + +// ── The console probe (protocol 14, D363) ────────────────────────────────── +// +// The plugin runs its own `rg.probe` through `ConsoleSystem.Run` to learn whether +// the framework runs a plugin's command from code. A probe that names a command +// the plugin does not declare answers false everywhere, and false is exactly what +// Carbon's bug looks like, so nobody would ever notice. + +/** A plugin that runs `ran` on itself and declares `declared`. */ +function withProbe(ran, declared) { + return source({ + expected: [], + methods: ` private void StartWorldEvents() + { + ConsoleSystem.Run(ConsoleSystem.Option.Server.Quiet(), "${ran}"); + } + + [ConsoleCommand("${declared}")] + private void CmdProbe(ConsoleSystem.Arg arg) + { + }`, + }) +} + +test('a probe that runs a command the plugin declares passes', () => { + assert.deepEqual(check(withProbe('rg.probe', 'rg.probe'), toml()), []) +}) + +test('a probe that runs a command the plugin does not declare is caught', () => { + const problems = check(withProbe('rg.probe', 'rg.prob'), toml()) + assert.equal(problems.length, 1) + assert.match(problems[0], /ConsoleSystem\.Run runs "rg\.probe", but the plugin declares no \[ConsoleCommand\("rg\.probe"\)\]/) +}) + +test('a native command run through the console is not the probe, and is left alone', () => { + const methods = ` private void Call() + { + ConsoleSystem.Run(ConsoleSystem.Option.Server.Quiet(), "satellite.random"); + }` + assert.deepEqual(check(source({ expected: [], methods }), toml()), []) +}) + +test('the probe parsers read the real plugin, rather than quietly matching nothing', () => { + const real = fs.readFileSync( + path.resolve(__dirname, '..', 'overlay', 'oxide', 'plugins', 'RunicGateway.cs'), + 'utf8' + ) + + assert.ok(readOwnConsoleRuns(real).includes('rg.probe'), 'the console probe was not found by the parser') + assert.ok(readConsoleCommands(real).includes('rg.probe'), 'rg.probe was not found among the console commands') + assert.ok(readConsoleCommands(real).includes('rg.hooks'), 'rg.hooks was not found among the console commands') +}) -- 2.49.1 From 8fdc374ab3c6b39ab1b87c9b5aef817e53854339 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 15 Oct 2026 03:03:30 -0500 Subject: [PATCH 05/14] =?UTF-8?q?feat:=20TruePVE=20in=20the=20zone=20step?= =?UTF-8?q?=20(PLAN=5FREDESIGNS=20=C2=A714.3.2,=20WIP)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 3 of the §14.3 integration plan (D362, D368): world.zone's optional truePve ({exclude: true} or {ruleset}), all or nothing, unmapped on every path that erases one of our zones, with truepve.json for erases while TruePVE is away. Docs and PRs still to come. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- overlay/oxide/plugins/RunicGateway.cs | 265 +++++++++++++++++++++++++- 1 file changed, 262 insertions(+), 3 deletions(-) diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index 989d3e3..f5bdd35 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -67,7 +67,8 @@ namespace Oxide.Plugins /// world events (§20.5, PLAN_REDESIGNS.md §14.3.1): the world.event kind and its /// 24 types, world.call, hello's console.plugins, and the tally's /// cratesHacked and dropsLooted, where apcKills and - /// heliKills are no longer sent. + /// heliKills are no longer sent; and TruePVE (§20.6, PLAN_REDESIGNS.md §14.3.2): + /// world.zone's truePve and the integrations.truePve it needs. /// /// private const int ProtocolVersion = 14; @@ -561,6 +562,7 @@ namespace Oxide.Plugins // lease command can arrive before the world has finished loading, and it must find // the holds already in place rather than have them loaded over it afterwards. LoadLeases(); + LoadTruePvePending(); LoadWorld(); LoadRewards(); LoadWorldCalls(); @@ -1621,6 +1623,10 @@ namespace Oxide.Plugins DropWorldFromOtherWipes(); RecreateZones("load"); + // Mappings an erase left while TruePVE was away (§14.3.2). When TruePVE loads after + // this, its own OnPluginLoaded does it. + FlushTruePvePending("load"); + // The map's sets are seeded here for the same reason: a hotload hears no spawns. SeedMapSets(); @@ -5155,6 +5161,11 @@ namespace Oxide.Plugins if (plugin != null && plugin != this && plugin.Name == "ZoneManager") NextTick(() => RecreateZones("ZoneManager reloaded")); + // TruePVE has read its table by now (it does in Init), so what an erase left while it + // was away can go (§14.3.2). + if (IsTruePve(plugin)) + NextTick(() => FlushTruePvePending("TruePVE loaded")); + // Hello carries ZoneManager's flag list and both helpers' states, so a change to any // of the four is said again rather than waiting for the next connect (§3.1, §3.2). // Economics too: hello carries its starting balance, which the richest column needs; @@ -8086,6 +8097,13 @@ namespace Oxide.Plugins public int DomeType; public int DomeStack; + /// + /// The TruePVE mapping this zone was given (PLAN_REDESIGNS §14.3.2): exclude or a + /// ruleset's name, or null for none. Kept so the path that erases the zone removes the + /// mapping too (D362), and touches nothing else in TruePVE's table. + /// + public string TruePve; + /// When a zone is erased without being asked, on this host's clock (D96). Zero for an entity. public long DeadlineMs; @@ -8525,6 +8543,192 @@ namespace Oxide.Plugins NextTick(() => RestoreDomes("ZoneDomes started")); } + // ---- TruePVE in the zone step (PLAN_REDESIGNS §14.3.2, D362, D368) ---- + // + // A zone may ask TruePVE to leave it alone (`exclude`) or to use one of its rulesets. The + // §14.2 test found the three facts this is built on: + // + // * `AddOrUpdateMapping(zoneId, name)` answers false for a ruleset TruePVE does not have, + // and nothing lists the names, so the call is the check (D368). + // * TruePVE keeps its mappings in its own data file, across its reloads and a restart, and + // prunes a mapping whose zone is gone only when the zone id is a number. Ours are `rg-…`, + // so the path that erases one of our zones removes its mapping (D362). + // * It reads that file in `Init`, so by `OnPluginLoaded` the table is there to change. + // + // An erase while TruePVE is not loaded keeps the id in `truepve.json` and removes it when + // TruePVE comes back. Only ids this plugin mapped are ever removed. + + private const string TruePveExclude = "exclude"; + + /// The longest ruleset name a step may send. + private const int TruePveRulesetMax = 64; + + /// Zone ids whose mapping is still to remove, because TruePVE was not loaded. Main thread only. + private HashSet _truePvePending = new HashSet(StringComparer.Ordinal); + + private Plugin TruePvePlugin() + { + return plugins.Find("TruePVE"); + } + + private bool IsTruePve(Plugin plugin) + { + return plugin != null && plugin != this && plugin.Name == "TruePVE"; + } + + /// + /// A zone's truePve: {"exclude": true} or {"ruleset": "name"}, exactly one. + /// Null with true when the command has none; false with a reason when it is malformed. + /// + private static bool ReadTruePve(Dictionary cmd, out string mapping, out string refusal) + { + mapping = null; + refusal = null; + object raw; + + if (!cmd.TryGetValue("truePve", out raw) || raw == null) + return true; + + var o = raw as Newtonsoft.Json.Linq.JObject; + Newtonsoft.Json.Linq.JToken exclude = o != null ? o["exclude"] : null; + Newtonsoft.Json.Linq.JToken ruleset = o != null ? o["ruleset"] : null; + bool hasExclude = exclude != null && exclude.Type != Newtonsoft.Json.Linq.JTokenType.Null; + bool hasRuleset = ruleset != null && ruleset.Type != Newtonsoft.Json.Linq.JTokenType.Null; + + if (o == null || hasExclude == hasRuleset) + { + refusal = "a zone's truePve is {\"exclude\": true} or {\"ruleset\": \"name\"}, exactly one"; + return false; + } + + if (hasExclude) + { + if (exclude.Type != Newtonsoft.Json.Linq.JTokenType.Boolean || !(bool)exclude) + { + refusal = "a zone's truePve exclude is true, or left out"; + return false; + } + + mapping = TruePveExclude; + return true; + } + + string name = ruleset.Type == Newtonsoft.Json.Linq.JTokenType.String ? ((string)ruleset).Trim() : null; + + if (string.IsNullOrEmpty(name) || name.Length > TruePveRulesetMax) + { + refusal = $"a TruePVE ruleset is named by 1 to {TruePveRulesetMax} characters"; + return false; + } + + mapping = name; + return true; + } + + /// + /// Main thread. Removes the TruePVE mapping of a zone this plugin is erasing, if it gave the + /// zone one. Called before the zone goes, on every path that erases one (D362). + /// + private void UnmapTruePve(WorldEntry e) + { + if (e == null || e.Kind != WorldKindZone || string.IsNullOrEmpty(e.TruePve)) + return; + + Plugin tp = TruePvePlugin(); + + if (tp != null) + { + // False is "not in the table", which is what this was for. + tp.Call("RemoveMapping", e.Id); + return; + } + + if (_truePvePending.Add(e.Id)) + { + SaveTruePvePending(); + Puts($"world: TruePVE is not loaded; {e.Id}'s mapping is removed when it is"); + } + } + + /// Main thread. Removes every mapping an erase left while TruePVE was away. + private void FlushTruePvePending(string why) + { + Plugin tp = TruePvePlugin(); + + if (tp == null || _truePvePending.Count == 0) + return; + + int removed = 0; + + foreach (string id in _truePvePending) + { + object gone = tp.Call("RemoveMapping", id); + + if (gone is bool && (bool)gone) + removed++; + } + + int count = _truePvePending.Count; + _truePvePending.Clear(); + SaveTruePvePending(); + + _lastWorldSummary = $"TruePVE after {why}: {removed} of {count} left-over mapping(s) removed"; + Puts($"world: {_lastWorldSummary}"); + } + + private string TruePvePendingPath() + { + return Path.Combine(Interface.Oxide.DataDirectory, Name, "truepve.json"); + } + + private void SaveTruePvePending() + { + try + { + string path = TruePvePendingPath(); + + if (_truePvePending.Count == 0) + { + if (File.Exists(path)) + File.Delete(path); + + return; + } + + Directory.CreateDirectory(Path.GetDirectoryName(path)); + File.WriteAllText(path, JsonConvert.SerializeObject(new List(_truePvePending), Formatting.Indented), Utf8NoBom); + } + catch (Exception ex) + { + Puts($"could not save the TruePVE leftovers: {Flatten(ex.Message)}"); + } + } + + private void LoadTruePvePending() + { + _truePvePending = new HashSet(StringComparer.Ordinal); + + try + { + string path = TruePvePendingPath(); + + if (!File.Exists(path)) + return; + + List ids = JsonConvert.DeserializeObject>(File.ReadAllText(path)); + + foreach (string id in ids ?? new List()) + { + if (!string.IsNullOrEmpty(id)) + _truePvePending.Add(id); + } + } + catch (Exception ex) + { + Puts($"could not read the TruePVE leftovers: {Flatten(ex.Message)}"); + } + } + // ---- the zone's messages (D193) ---- // // ZoneManager can say a zone's enter and leave messages itself, but only to chat unless its @@ -9020,6 +9224,22 @@ namespace Oxide.Plugins dome = true; } + // §14.3.2 (D368): TruePVE's rules in the zone, as `exclude` or one ruleset by name. + string truePve; + string truePveRefusal; + + if (!ReadTruePve(cmd, out truePve, out truePveRefusal)) + { + EmitWorldError(reqId, "malformed", truePveRefusal); + return; + } + + if (truePve != null && TruePvePlugin() == null) + { + EmitWorldError(reqId, "truepve-missing", "TruePVE is not loaded on this server, so a zone cannot change its PvP rules"); + return; + } + var entry = new WorldEntry { Id = $"rg-{ShortRun(runId)}-{NowMs() % 100000000L}-{++_zoneSerial}", @@ -9061,11 +9281,35 @@ namespace Oxide.Plugins return; } + // The mapping last, all or nothing like the dome: only TruePVE's own answer can say + // whether it has a ruleset of that name (D368), and a zone without the rules its + // step asked for is not opened at all. + if (truePve != null) + { + object mapped = TruePvePlugin()?.Call("AddOrUpdateMapping", entry.Id, truePve); + + if (!(mapped is bool && (bool)mapped)) + { + RemoveDome(entry); + zm.Call("EraseTemporaryZone", this, entry.Id); + + if (truePve == TruePveExclude) + EmitWorldError(reqId, "refused", "TruePVE did not map the zone, so it was not opened"); + else + EmitWorldError(reqId, "truepve-ruleset", + $"TruePVE on this server has no ruleset named '{truePve}' (names are case-sensitive), so the zone was not opened"); + + return; + } + + entry.TruePve = truePve; + } + _world[entry.Id] = entry; SaveWorld(); EnsureWorldTimer(); - _lastWorldSummary = $"zone {entry.Id} r={radius:0} for {holdMs / 60000L} min, {flags.Count} flag(s){(dome ? ", domed" : "")}, run {runId}"; + _lastWorldSummary = $"zone {entry.Id} r={radius:0} for {holdMs / 60000L} min, {flags.Count} flag(s){(dome ? ", domed" : "")}{(truePve != null ? $", TruePVE {truePve}" : "")}, run {runId}"; Puts($"world: {_lastWorldSummary}"); EmitWorldPlaced(reqId, new List { entry }, false); } @@ -9416,6 +9660,9 @@ namespace Oxide.Plugins // left there outlives the zone it was drawn over (D194). RemoveDome(e); + // And its TruePVE mapping, which TruePVE never prunes for an id like ours (D362). + UnmapTruePve(e); + if (wasThere) zm.Call("EraseTemporaryZone", this, e.Id); @@ -9493,7 +9740,11 @@ namespace Oxide.Plugins if (pruned != null) { foreach (string id in pruned) + { + // A zone ZoneManager could not put back is erased all the same, mapping and all. + UnmapTruePve(_world[id]); _world.Remove(id); + } SaveWorld(); } @@ -9700,6 +9951,7 @@ namespace Oxide.Plugins foreach (WorldEntry e in due) { RemoveDome(e); + UnmapTruePve(e); if (zm != null && zm.Call("CheckZoneID", e.Id) != null) zm.Call("EraseTemporaryZone", this, e.Id); @@ -9748,8 +10000,12 @@ namespace Oxide.Plugins if (stale.Count == 0) return; + // TruePVE's table outlives a wipe, so the old map's mappings are removed with it. foreach (string id in stale) + { + UnmapTruePve(_world[id]); _world.Remove(id); + } SaveWorld(); _lastWorldSummary = $"a new wipe dropped {stale.Count} things from the old map"; @@ -9775,6 +10031,7 @@ namespace Oxide.Plugins arg.ReplyWith( $"protocol={ProtocolVersion} eventsEnabled={(_config != null && _config.EventsEnabled)} " + $"zones={zones} crates={crates} npcs={npcs} zoneManager={(ZoneManagerPlugin() != null)} " + + $"truePve={(TruePvePlugin() != null)} truePveLeftover={_truePvePending.Count} " + $"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 + @@ -13381,7 +13638,9 @@ namespace Oxide.Plugins { "zoneDomes", ZoneDomesBody() }, { "runicNpc", RunicNpcBody() }, { "economics", EconomicsBody() }, - { "backpacks", BackpacksBody() } + { "backpacks", BackpacksBody() }, + // Protocol 14 (§14.3.2): whether a zone step may change PvP rules here. + { "truePve", IntegrationBody(TruePvePlugin()) } }; } -- 2.49.1 From 491760d81733d3a7c486138bb95f240d849e035e Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 15 Oct 2026 05:47:58 -0500 Subject: [PATCH 06/14] =?UTF-8?q?feat(bridge):=20TruePVE=20in=20a=20zone?= =?UTF-8?q?=20on=20protocol=2014=20(PLAN=5FREDESIGNS=20=C2=A714.3.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 3 of the §14.3 integration plan (D362, D368), specified in docs rust-link/PROTOCOL.md §20.6. - world.zone's optional truePve: {"exclude": true} or {"ruleset": "name"}, exactly one, the name 1-64 characters. Malformed is `malformed`; TruePVE not loaded is `truepve-missing`. - All or nothing: the zone (and its dome) is made, then AddOrUpdateMapping. A false erases both and answers `truepve-ruleset` with the name (or `refused` for exclude). - The registry entry remembers its mapping, and every path that erases one of our zones calls RemoveMapping first: the deadline, world.revert, a zone world.owned finds gone, a new wipe. While TruePVE is away the id waits in data/RunicGateway/truepve.json, flushed at OnPluginLoaded(TruePVE) and at the bridge's own load. - hello's integrations.truePve { loaded, version }, sent again when TruePVE loads or unloads. rg.world prints truePve= and truePveLeftover=. Compiled and loaded on both rigs (Rust 25902425, Oxide 2.0.7830, Carbon 2.0.262, TruePVE 2.4.6). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- README.md | 5 +++++ overlay/oxide/plugins/RunicGateway.cs | 7 ++++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index faf0d7e..3570289 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,11 @@ backpack from the site, a player can see their own, and an event can lend a bigg the bridge only ever reads a backpack, and the bigger size is one of Backpacks' own `backpacks.size.N` permissions, pushed like any other grant. +nivex's `TruePVE` is optional in the same way. Where it is loaded an event's zone can turn off PvP +protection inside it, or use one of the operator's own TruePVE rulesets by name; where it is not, the +zone step says "TruePVE is not installed on this server". The bridge only ever adds and removes the +mappings of zones it opened, and removes each one with its zone, even when TruePVE was away at the time. + 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 f5bdd35..532e8ac 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -5169,8 +5169,9 @@ namespace Oxide.Plugins // Hello carries ZoneManager's flag list and both helpers' states, so a change to any // of the four is said again rather than waiting for the next connect (§3.1, §3.2). // Economics too: hello carries its starting balance, which the richest column needs; - // and Backpacks, whose sizes are what a Bigger backpack step offers. - if (IsZonePlugin(plugin) || IsRunicNpc(plugin) || IsEconomics(plugin) || IsBackpacks(plugin)) + // 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). + if (IsZonePlugin(plugin) || IsRunicNpc(plugin) || IsEconomics(plugin) || IsBackpacks(plugin) || IsTruePve(plugin)) NextTick(ResendHello); // Economics loaded after players connected: what they hold now is not otherwise known @@ -5200,7 +5201,7 @@ namespace Oxide.Plugins if (plugin != null && plugin.Name == "BetterChat") _titlesRegistered = false; - if (IsZonePlugin(plugin) || IsRunicNpc(plugin) || IsEconomics(plugin) || IsBackpacks(plugin)) + if (IsZonePlugin(plugin) || IsRunicNpc(plugin) || IsEconomics(plugin) || IsBackpacks(plugin) || IsTruePve(plugin)) NextTick(ResendHello); ConfigWriteJob job = _configJob; -- 2.49.1 From 7c9c8bdacb569f25793fd88ef985a142b2b9ad5d Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 15 Oct 2026 06:12:04 -0500 Subject: [PATCH 07/14] =?UTF-8?q?feat(bridge):=20Dynamic=20PVP's=20zones?= =?UTF-8?q?=20on=20protocol=2014=20(PLAN=5FREDESIGNS=20=C2=A714.3.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 4 of the §14.3 integration plan, specified in docs rust-link/PROTOCOL.md §20.7 and §17.3. - pvp.zone.created { zoneId, event, x, z, radius, shape, durationSec } from OnCreatedDynamicPVP, and pvp.zone.deleted { zoneId, event } from OnDeletedDynamicPVP. Neither is sent while the world is not live. - Size and place come from ZoneManager: a sphere's Radius, or half a cube's longer side (cargo's zone is a cube); the position is the zone's transform, so cargo's moves with the ship. - map.live gains a `pvp` layer of the live zones, kept by the two hooks and seeded from Dynamic PVP's own AllDynamicPVPZones / GetEventName at world ready and when it loads; a zone ZoneManager no longer holds is dropped. - hello's integrations.dynamicPvp, sent again when it loads or unloads; rg.world prints dynamicPvp= and pvpZones=; both hooks join rg.hooks. - The bridge never makes, maps or erases one of Dynamic PVP's zones. Compiled and loaded on both rigs (Rust 25902425, Oxide 2.0.7830, Carbon 2.0.262). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- README.md | 4 + overlay/oxide/plugins/RunicGateway.cs | 229 +++++++++++++++++++++++++- 2 files changed, 227 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 3570289..df9444e 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,10 @@ protection inside it, or use one of the operator's own TruePVE rulesets by name; zone step says "TruePVE is not installed on this server". The bridge only ever adds and removes the mappings of zones it opened, and removes each one with its zone, even when TruePVE was away at the time. +HunterZ's `Dynamic PVP` (which needs ZoneManager and TruePVE) is optional too. Where it is loaded, the +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. + 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 532e8ac..5d578db 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -68,7 +68,10 @@ namespace Oxide.Plugins /// 24 types, world.call, hello's console.plugins, and the tally's /// cratesHacked and dropsLooted, where apcKills and /// heliKills are no longer sent; and TruePVE (§20.6, PLAN_REDESIGNS.md §14.3.2): - /// world.zone's truePve and the integrations.truePve it needs. + /// 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. /// /// private const int ProtocolVersion = 14; @@ -272,7 +275,9 @@ namespace Oxide.Plugins "OnCargoShipHarborApproach", "OnCargoShipHarborArrived", "OnCargoShipHarborLeave", "OnCargoShipEgress", "OnHelicopterDropCrate", "OnCrateLanded", "CanHackCrate", "OnCrateHack", "OnCrateHackEnd", "OnLootEntity", "OnCargoPlaneSignaled", "OnAirdrop", "OnSupplyDropDropped", "OnSupplyDropLanded", - "OnDeepSeaOpened", "OnDeepSeaClosed" + "OnDeepSeaOpened", "OnDeepSeaClosed", + // Protocol 14, Dynamic PVP's zones (§20.7). + "OnCreatedDynamicPVP", "OnDeletedDynamicPVP" }; private void Init() @@ -1629,6 +1634,7 @@ namespace Oxide.Plugins // The map's sets are seeded here for the same reason: a hotload hears no spawns. SeedMapSets(); + SeedPvpZones("load"); // Protocol 14: the console probe (D363) and the world events' sweep. Before hello, which // carries what the probe found. @@ -5170,10 +5176,15 @@ namespace Oxide.Plugins // of the four is said again rather than waiting for the next connect (§3.1, §3.2). // 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). - if (IsZonePlugin(plugin) || IsRunicNpc(plugin) || IsEconomics(plugin) || IsBackpacks(plugin) || IsTruePve(plugin)) + // 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)) NextTick(ResendHello); + // Dynamic PVP raises its creates as it opens its zones, which at a load are its + // monuments'; seeding once it has finished starting catches any it opened before us. + if (IsDynamicPvp(plugin)) + NextTick(() => SeedPvpZones("Dynamic PVP loaded")); + // Economics loaded after players connected: what they hold now is not otherwise known // until it moves (D336). if (IsEconomics(plugin)) @@ -5201,9 +5212,13 @@ namespace Oxide.Plugins if (plugin != null && plugin.Name == "BetterChat") _titlesRegistered = false; - if (IsZonePlugin(plugin) || IsRunicNpc(plugin) || IsEconomics(plugin) || IsBackpacks(plugin) || IsTruePve(plugin)) + if (IsZonePlugin(plugin) || IsRunicNpc(plugin) || IsEconomics(plugin) || IsBackpacks(plugin) || IsTruePve(plugin) || IsDynamicPvp(plugin)) NextTick(ResendHello); + // Its Unload deleted every zone, each with its hook; whatever is left was missed. + if (IsDynamicPvp(plugin)) + _pvpZones.Clear(); + ConfigWriteJob job = _configJob; if (job == null || plugin == null) @@ -8730,6 +8745,204 @@ namespace Oxide.Plugins } } + // ---- Dynamic PVP's zones (PLAN_REDESIGNS §14.3.3) ---- + // + // HunterZ's Dynamic PVP opens its own PvP zones around a downed helicopter, a Bradley, a + // locked crate, an airdrop, cargo and the rest. They are its own: the bridge never makes, + // maps or erases one, and only says that they are there, for the feed and the map. The + // §14.2 test and the plugin's own source (5.2.0) shape it: + // + // * `OnCreatedDynamicPVP(zoneId, eventName, position, duration)` and + // `OnDeletedDynamicPVP(zoneId, eventName)` are raised for every zone, a reload's and an + // unload's included. Its ids are always numeric, so never one of our `rg-…`. + // * Neither hook carries the zone's size, and a zone is a sphere (`Radius`) or a cube + // (`Size`, cargo's), so both are read from ZoneManager. Cargo's zone is parented to the + // ship, so where a zone is comes from its transform, never from where it was made. + // * `AllDynamicPVPZones()` and `GetEventName(zoneId)` say which zones are live, so the + // set is seeded from Dynamic PVP itself at load: a hotload of the bridge hears no creates. + // * `OnPlayerEnterPVP` / `ExitPVP` are not subscribed: they would say who stands where. + + private const string PvpZoneCreatedKind = "pvp.zone.created"; + private const string PvpZoneDeletedKind = "pvp.zone.deleted"; + + /// One of Dynamic PVP's live zones: its event's name, and how long it was given. + private sealed class PvpZone + { + public string Event; + + /// Seconds, or -1 for "as long as its event" (cargo, a monument's). + public int DurationSec; + } + + /// Dynamic PVP's live zones, by its zone id. Main thread only. + private readonly Dictionary _pvpZones = new Dictionary(StringComparer.Ordinal); + + private Plugin DynamicPvpPlugin() + { + return plugins.Find("DynamicPVP"); + } + + private bool IsDynamicPvp(Plugin plugin) + { + return plugin != null && plugin != this && plugin.Name == "DynamicPVP"; + } + + private void OnCreatedDynamicPVP(string zoneId, string eventName, UnityEngine.Vector3 position, float duration) + { + MarkHook("OnCreatedDynamicPVP"); + + if (string.IsNullOrEmpty(zoneId)) + return; + + var zone = new PvpZone { Event = eventName ?? "", DurationSec = duration > 0f ? (int)Math.Round(duration) : -1 }; + _pvpZones[zoneId] = zone; + + // A boot or a reload of Dynamic PVP opens every monument's zone at once, and that is + // not news; the map has them all the same. + if (!WorldEventsLive()) + return; + + Dictionary frame = Frame(PvpZoneCreatedKind, "event"); + frame["zoneId"] = zoneId; + frame["event"] = zone.Event; + + UnityEngine.Vector3 at; + float radius; + string shape; + + if (!PvpZoneShape(zoneId, out at, out radius, out shape)) + at = position; + + PutPosition(frame, at); + frame["radius"] = Math.Round(radius, 1); + frame["shape"] = shape; + frame["durationSec"] = zone.DurationSec; + EmitFrame(frame); + } + + private void OnDeletedDynamicPVP(string zoneId, string eventName) + { + MarkHook("OnDeletedDynamicPVP"); + + if (string.IsNullOrEmpty(zoneId) || !_pvpZones.Remove(zoneId)) + return; + + // A shutdown deletes every zone, and that is not news. Dynamic PVP's own unload does too, + // and is said: the zones really have closed, and the line is staff's. + if (!WorldEventsLive()) + return; + + Dictionary frame = Frame(PvpZoneDeletedKind, "event"); + frame["zoneId"] = zoneId; + frame["event"] = eventName ?? ""; + EmitFrame(frame); + } + + /// + /// Main thread. Where one of Dynamic PVP's zones is now, and how big: a sphere's radius, + /// or for a cube half its longer side across the ground, so the map can draw a ring. + /// False when ZoneManager does not hold it. + /// + private bool PvpZoneShape(string zoneId, out UnityEngine.Vector3 at, out float radius, out string shape) + { + at = UnityEngine.Vector3.zero; + radius = 0f; + shape = "sphere"; + + Plugin zm = ZoneManagerPlugin(); + var zone = zm != null ? zm.Call("GetZoneByID", zoneId) as UnityEngine.Component : null; + + if (zone == null || zone.transform == null) + return false; + + at = zone.transform.position; + object r = zm.Call("GetZoneRadius", zoneId); + + if (r is float && (float)r > 0f) + { + radius = (float)r; + return true; + } + + object s = zm.Call("GetZoneSize", zoneId); + + if (s is UnityEngine.Vector3) + { + var size = (UnityEngine.Vector3)s; + radius = Math.Max(size.x, size.z) / 2f; + shape = "cube"; + } + + return true; + } + + /// Main thread. The live zones from Dynamic PVP itself, replacing what the hooks kept. + private void SeedPvpZones(string why) + { + _pvpZones.Clear(); + Plugin dpvp = DynamicPvpPlugin(); + + if (dpvp == null) + return; + + var ids = dpvp.Call("AllDynamicPVPZones") as string[]; + + if (ids == null) + return; + + foreach (string id in ids) + { + if (string.IsNullOrEmpty(id)) + continue; + + // How long it was given is not asked for; the map does not need it. + _pvpZones[id] = new PvpZone { Event = dpvp.Call("GetEventName", id) as string ?? "", DurationSec = -1 }; + } + + if (_pvpZones.Count > 0) + Puts($"map: {_pvpZones.Count} Dynamic PVP zone(s) after {why}"); + } + + /// Main thread. The map's pvp layer: every live zone ZoneManager still holds, where it is now. + private List PvpZoneRows() + { + var rows = new List(); + List gone = null; + + foreach (KeyValuePair z in _pvpZones) + { + UnityEngine.Vector3 at; + float radius; + string shape; + + if (!PvpZoneShape(z.Key, out at, out radius, out shape)) + { + // Erased without its hook (ZoneManager reloaded); Dynamic PVP's own state is + // its business, and the map shows only what is there. + if (gone == null) + gone = new List(); + + gone.Add(z.Key); + continue; + } + + Dictionary row = At(at); + row["zoneId"] = z.Key; + row["event"] = z.Value.Event; + row["radius"] = Math.Round(radius, 1); + row["shape"] = shape; + rows.Add(row); + } + + if (gone != null) + { + foreach (string id in gone) + _pvpZones.Remove(id); + } + + return rows; + } + // ---- the zone's messages (D193) ---- // // ZoneManager can say a zone's enter and leave messages itself, but only to chat unless its @@ -10033,6 +10246,7 @@ namespace Oxide.Plugins $"protocol={ProtocolVersion} eventsEnabled={(_config != null && _config.EventsEnabled)} " + $"zones={zones} crates={crates} npcs={npcs} zoneManager={(ZoneManagerPlugin() != null)} " + $"truePve={(TruePvePlugin() != null)} truePveLeftover={_truePvePending.Count} " + + $"dynamicPvp={(DynamicPvpPlugin() != null)} pvpZones={_pvpZones.Count} " + $"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 + @@ -13641,7 +13855,9 @@ namespace Oxide.Plugins { "economics", EconomicsBody() }, { "backpacks", BackpacksBody() }, // Protocol 14 (§14.3.2): whether a zone step may change PvP rules here. - { "truePve", IntegrationBody(TruePvePlugin()) } + { "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()) } }; } @@ -15046,6 +15262,7 @@ namespace Oxide.Plugins reply["events"] = events; reply["players"] = players; reply["bases"] = bases; + reply["pvp"] = PvpZoneRows(); reply["tookMs"] = clock.ElapsedMilliseconds; if (sleepersTruncated) -- 2.49.1 From 72f51c3d9e58f79c40708b715137538de8a0968d Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 15 Oct 2026 06:45:25 -0500 Subject: [PATCH 08/14] =?UTF-8?q?feat(bridge):=20Dangerous=20Treasures=20o?= =?UTF-8?q?n=20protocol=2014=20(PLAN=5FREDESIGNS=20=C2=A714.3.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` 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 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- README.md | 7 + overlay/oxide/plugins/RunicGateway.cs | 469 +++++++++++++++++++++++++- 2 files changed, 470 insertions(+), 6 deletions(-) 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; -- 2.49.1 From 4e83def0dde5fa9b53022f502d4afe40bf62e2ea Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 15 Oct 2026 12:26:36 -0500 Subject: [PATCH 09/14] =?UTF-8?q?feat(bridge):=20Guarded=20Crate=20on=20pr?= =?UTF-8?q?otocol=2014=20(PLAN=5FREDESIGNS=20=C2=A714.3.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` position 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 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- README.md | 8 + overlay/oxide/plugins/RunicGateway.cs | 735 +++++++++++++++++++++++++- 2 files changed, 737 insertions(+), 6 deletions(-) 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 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 row = At(g.At); + row["kind"] = "guarded"; + world.Add(row); + } + } + + /// + /// Hello's integrations.guardedCrate: loaded and version, NpcSpawn's beside it + /// (D364), and events, the event names in Guarded Crate's data file, absent when + /// the file cannot be read. + /// + private Dictionary GuardedCrateBody() + { + Plugin gc = GuardedCratePlugin(); + Dictionary body = IntegrationBody(gc); + body["npcSpawn"] = IntegrationBody(NpcSpawnPlugin()); + + if (gc == null) + return body; + + List events = GuardedCrateEventNames(); + + if (events != null) + body["events"] = events; + + return body; + } + + /// + /// The event names in Guarded Crate's data file (CrateEventEntries, each row's + /// event display name), 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). + /// + private List 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 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(); + + 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; + } + + /// + /// Guarded Crate's console command from its config (command name - (default: + /// gcrate)), or gcrate when the file does not say. Empty when the operator + /// blanked it, which registers no command at all. + /// + 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(_gcPlacements.Values), "Guarded Crate placements"); + } + + private void LoadGcPlacements() + { + _gcPlacements = new Dictionary(StringComparer.Ordinal); + List placed = ReadDataFile(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(); + + foreach (KeyValuePair 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(); + } + + /// + /// Main thread. Place one guarded crate for a run's step: { key, event } 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. + /// + private void HandleGcPlace(Dictionary 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 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); + } + + /// Why our gcrate position placed nothing, told by Guarded Crate's own messages, read from its lang. + 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)}"); + } + + /// One of Guarded Crate's messages, flattened as a reply is, or null. + 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 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 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 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); -- 2.49.1 From 454ce53937f3d721f62045024e3139fd078488cd Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 15 Oct 2026 18:27:54 -0500 Subject: [PATCH 10/14] =?UTF-8?q?feat(bridge):=20ZLevels=20Remastered=20in?= =?UTF-8?q?=20hello=20(PLAN=5FREDESIGNS=20=C2=A714.3.6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 7 of the §14.3 integration plan (D374): hello's integrations gains zLevels { loaded, version }, sent again when ZLevels loads or unloads, and rg.world prints zLevels=. No level is read or sent; its XP boosts are permissions a run already lends. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- README.md | 4 ++++ overlay/oxide/plugins/RunicGateway.cs | 31 ++++++++++++++++++++++----- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 2a65cad..12533ec 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,10 @@ kept when the event ends: Guarded Crate has no way to end one event alone. Witho is refused, because the crate would land unguarded; and, as for Dangerous Treasures, a framework with `consolePlugins=False` cannot place one. +nivex's `ZLevels Remastered` is optional, and the bridge only says whether it is loaded. Its levels are +never read or sent. Its XP boosts are its own permissions (`zlevelsremastered.vip1` to `vip3`), so an +event lends one as it lends any other permission, and it takes effect at once. + 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 ab2210f..e40a715 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -77,7 +77,8 @@ namespace Oxide.Plugins /// 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. + /// integrations.guardedCrate with NpcSpawn beside it; and ZLevels Remastered + /// (§20.10, PLAN_REDESIGNS.md §14.3.6): integrations.zLevels, and nothing more. /// /// private const int ProtocolVersion = 14; @@ -5214,8 +5215,9 @@ 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). - // 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)) + // Guarded Crate and NpcSpawn too: a placement is refused without either (D364). And + // ZLevels, which the servers page names (§14.3.6). + if (IsZonePlugin(plugin) || IsRunicNpc(plugin) || IsEconomics(plugin) || IsBackpacks(plugin) || IsTruePve(plugin) || IsDynamicPvp(plugin) || IsDangerousTreasures(plugin) || IsGuardedCrate(plugin) || IsZLevels(plugin)) NextTick(ResendHello); // Dynamic PVP raises its creates as it opens its zones, which at a load are its @@ -5258,7 +5260,7 @@ 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) || IsGuardedCrate(plugin)) + if (IsZonePlugin(plugin) || IsRunicNpc(plugin) || IsEconomics(plugin) || IsBackpacks(plugin) || IsTruePve(plugin) || IsDynamicPvp(plugin) || IsDangerousTreasures(plugin) || IsGuardedCrate(plugin) || IsZLevels(plugin)) NextTick(ResendHello); // Guarded Crate's Unload ended every event WITHOUT a hook. @@ -10098,6 +10100,22 @@ namespace Oxide.Plugins EmitFrame(frame); } + // ---- ZLevels Remastered (PLAN_REDESIGNS §14.3.6, D369, D374) ---- + // + // Hello says whether it is loaded, and that is all. No level is read or sent: the + // leaderboard does not change for it (D374). Its XP boosts are permissions, so an event + // lends one through `perm.lend` with no code of ours (§12). + + private Plugin ZLevelsPlugin() + { + return plugins.Find("ZLevelsRemastered"); + } + + private bool IsZLevels(Plugin plugin) + { + return plugin != null && plugin != this && plugin.Name == "ZLevelsRemastered"; + } + // ---- the zone's messages (D193) ---- // // ZoneManager can say a zone's enter and leave messages itself, but only to chat unless its @@ -11408,6 +11426,7 @@ namespace Oxide.Plugins $"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} " + + $"zLevels={(ZLevelsPlugin() != null ? ZLevelsPlugin().Version.ToString() : "absent")} " + $"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 + @@ -15022,7 +15041,9 @@ namespace Oxide.Plugins // Protocol 14 (§14.3.4): whether a step may open a Dangerous Treasures event here. { "dangerousTreasures", IntegrationBody(DangerousTreasuresPlugin()) }, // Protocol 14 (§14.3.5): whether a step may place a guarded crate here, and which. - { "guardedCrate", GuardedCrateBody() } + { "guardedCrate", GuardedCrateBody() }, + // Protocol 14 (§14.3.6): whether its XP boosts are here for a step to lend. + { "zLevels", IntegrationBody(ZLevelsPlugin()) } }; } -- 2.49.1 From b52b8a8f807af4932aca6f563d6f07f6056dfa7a Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 16 Oct 2026 02:30:59 -0500 Subject: [PATCH 11/14] =?UTF-8?q?feat(bridge):=20console=20commands=20as?= =?UTF-8?q?=20event=20steps=20=E2=80=94=20command.run=20(PLAN=5FREDESIGNS?= =?UTF-8?q?=20=C2=A716)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `command.run` frame (protocol 14, PROTOCOL.md §20.11) runs the console commands a step's author typed: once, or once per online recipient with {steamid} and {name} filled in (the name quoted the way the game's own BuildCommand quotes an argument). D382-D384: no allowlist; offline recipients are skipped and named. - At most once per key: the key is written to commands-run.json before anything runs; a repeat answers from the record, a repeat while running joins the running job, a record a reload interrupted answers `interrupted`. Kept 7 days, at most 500 records. - Spread over frames: whole recipients per frame, up to 30 commands or 8 ms. - unknown-command: ConsoleSystem.Run returns null both for an unknown command and for one that replies nothing, so the run listens to Unity's log for the game's own "Command '...' not found" line while each command runs (spiked on Oxide 2.0.7830 and Carbon 2.0.262, Rust 25902425). The same capture is the reply when Run returns nothing. - rg.world shows commandKeys and commandsRunning. Compiled on both rigs and driven through a throwaway plugin (repeat key, unknown command, offline recipients, malformed requests); rigs restored to the protocol-13 bridge. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- README.md | 9 + overlay/oxide/plugins/RunicGateway.cs | 594 +++++++++++++++++++++++++- 2 files changed, 602 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 12533ec..d7e5982 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,15 @@ nivex's `ZLevels Remastered` is optional, and the bridge only says whether it is never read or sent. Its XP boosts are its own permissions (`zlevelsremastered.vip1` to `vip3`), so an event lends one as it lends any other permission, and it takes effect at once. +An event can also run console commands an admin typed (`command.run`): once, as a step, or once for each +recipient of a reward who is online, with `{steamid}` and `{name}` filled in (the name as one quoted +argument). That is how a plugin with no support of its own here is used from an event, for example +RaidableBases' `rbe`. There is no allowlist: the site's switchboard is the switch, and `EventsEnabled` +here is the server's. Each request runs at most once: its key is written to +`data/RunicGateway/commands-run.json` before anything runs, and is kept for seven days. A command the +server does not know stops the request there as `unknown-command`. On a framework with +`consolePlugins=False`, that is every plugin's command. + 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 e40a715..0241808 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -78,7 +78,9 @@ namespace Oxide.Plugins /// §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; and ZLevels Remastered - /// (§20.10, PLAN_REDESIGNS.md §14.3.6): integrations.zLevels, and nothing more. + /// (§20.10, PLAN_REDESIGNS.md §14.3.6): integrations.zLevels, and nothing more; + /// and console commands as event steps (§20.11, PLAN_REDESIGNS.md §16): + /// command.run, answered command.ran or command.error. /// /// private const int ProtocolVersion = 14; @@ -584,6 +586,7 @@ namespace Oxide.Plugins LoadWorldCalls(); LoadDtStarts(); LoadGcPlacements(); + LoadCommandRecords(); } // ---- the outbound queue ---- @@ -1094,6 +1097,12 @@ namespace Oxide.Plugins HandleGcPlace(cmd, reqId); break; + case "command.run": + // Protocol 14 (PROTOCOL.md §20.11, D382-D384). Console commands a step's author + // typed: once, or once per online recipient; at most once per key. + HandleCommandRun(cmd, reqId); + break; + case "tally.open": // Protocol 10 (PLAN.md §29). Who takes part in a run, counted by the game. HandleTallyOpen(cmd, reqId); @@ -11428,6 +11437,7 @@ namespace Oxide.Plugins $"gcEvents={(GuardedCrateEventNames() != null ? string.Join("|", GuardedCrateEventNames().ToArray()) : "none")} gcCommand={GuardedCrateCommand()} gcListReadable={_gcListReadable} " + $"zLevels={(ZLevelsPlugin() != null ? ZLevelsPlugin().Version.ToString() : "absent")} " + $"wipeId={(_wipeId ?? "none")} calls={_worldCalls.Count} consolePlugins={_consolePlugins} " + + $"commandKeys={_commandRecords.Count} commandsRunning={_commandJobs.Count} " + $"credits={_vehicleCredit.Count} chinookCrates={_chinookCrates.Count} said={_worldEventsSaid} " + $"worldEvents={(WorldEventsLive() ? "live" : $"quiet (ready={_worldReady} quitting={Rust.Application.isQuitting} loadingSave={Rust.Application.isLoadingSave})")}" + Environment.NewLine + $"last: {_lastWorldSummary}"); @@ -18156,5 +18166,587 @@ namespace Oxide.Plugins $"fired: {firedText}" + Environment.NewLine + $"silent: {silentText}"); } + + // ---- console commands an admin typed (PROTOCOL.md §20.11, PLAN_REDESIGNS §16, D382-D384) ---- + // + // `command.run` runs the commands a step's author typed, as the server console would: once + // (a *Run console commands* step and its teardown), or once per recipient who is online with + // `{steamid}` and `{name}` filled in (a *Reward by console command*). There is no allowlist + // (D384); the site's switchboard is the switch, as it is for every world write. + // + // **At most once, by the key.** A command cannot be asked whether it already ran, so the key + // is written to `commands-run.json` BEFORE anything runs. A repeat answers from that record + // and runs nothing; a repeat while the first is still running waits for it and gets its + // answer. A record left running by a restart or a reload is answered `interrupted`: some of + // its commands may have run, and none is run again. + // + // **Spread over frames.** A full reward is 100 recipients by 10 commands. A recipient's + // commands run together in one frame, and a frame stops taking recipients past + // `CommandsPerFrame` commands or `CommandFrameMs`, so the game never stalls on a step. + // + // **`unknown-command`.** `ConsoleSystem.Run` answers null both for a command it does not + // know and for one that replies nothing. The game's own "Command '...' not found" line is + // logged synchronously inside the call on Oxide and on Carbon (spike, 2026-10-16), so the + // run listens to the log while it runs, and stops at the first such line, before running + // the command for anyone else. What the command logged while it ran is its reply when + // `Run` returns nothing. + + /// At most this many commands in one request (§16.2). + private const int CommandsMax = 10; + + /// The longest command, in characters (§16.2). + private const int CommandLengthMax = 512; + + /// The most recipients one reward names (the kit reward's bound, §16.3). + private const int CommandRecipientsMax = 100; + + /// A console reply is trimmed to this many characters (§16.6). + private const int CommandReplyMax = 200; + + /// At most this many replies go back with an answer and stay in its record. + private const int CommandRepliesMax = 20; + + /// At most this many skipped names go back; the rest are counted. + private const int CommandSkippedMax = 50; + + /// How long a key is remembered: seven days, as a payment's is (§16.4). + private const long CommandMemoryMs = 7L * 24L * 60L * 60L * 1000L; + + /// The most records kept, oldest forgotten first, so the file stays small. + private const int CommandRecordsMax = 500; + + /// A frame takes no more recipients once it has run this many commands… + private const int CommandsPerFrame = 30; + + /// …or spent this long on them. + private const double CommandFrameMs = 8.0; + + private static readonly System.Text.RegularExpressions.Regex SteamIdPlaceholder = + new System.Text.RegularExpressions.Regex(@"\{steamid\}", System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.CultureInvariant); + + private static readonly System.Text.RegularExpressions.Regex NamePlaceholder = + new System.Text.RegularExpressions.Regex(@"\{name\}", System.Text.RegularExpressions.RegexOptions.IgnoreCase | System.Text.RegularExpressions.RegexOptions.CultureInvariant); + + /// One command that ran, and what the console said. Persisted with its record. + private sealed class CommandReply + { + public string Command; + public string SteamId; + public string Reply; + } + + /// One key's run: written before anything runs, and again when it is done. + private sealed class CommandRecord + { + public string Key; + public long AtMs; + public bool Done; + /// Null when the run finished; a reason (`unknown-command`) when it stopped. + public string Reason; + public string Message; + public int Ran; + public int Rewarded; + public List Replies = new List(); + public List Skipped = new List(); + public int SkippedCount; + } + + /// One recipient a reward names: the Steam id, and the name the site knows. + private sealed class CommandRecipient + { + public ulong Id; + public string Name; + } + + /// A run in progress. Main thread only; never persisted (its record is). + private sealed class CommandJob + { + public CommandRecord Record; + public List Commands; + /// Null for a run without recipients: the commands run once. + public List Recipients; + public int Next; + public readonly List Waiting = new List(); + } + + private Dictionary _commandRecords = new Dictionary(StringComparer.Ordinal); + + private readonly Dictionary _commandJobs = new Dictionary(StringComparer.Ordinal); + + private string CommandRecordsPath() + { + return Path.Combine(Interface.Oxide.DataDirectory, Name, "commands-run.json"); + } + + private void SaveCommandRecords() + { + WriteDataFile(CommandRecordsPath(), new List(_commandRecords.Values), "console command keys"); + } + + /// Main thread, once per load. A record still running belongs to a load that is gone. + private void LoadCommandRecords() + { + _commandRecords = new Dictionary(StringComparer.Ordinal); + List records = ReadDataFile(CommandRecordsPath(), "console command keys"); + + if (records != null) + foreach (CommandRecord r in records) + if (r != null && !string.IsNullOrEmpty(r.Key)) + _commandRecords[r.Key] = r; + + ForgetOldCommandRecords(); + } + + private void ForgetOldCommandRecords() + { + long now = NowMs(); + var stale = new List(); + + foreach (KeyValuePair kv in _commandRecords) + if (now >= kv.Value.AtMs + CommandMemoryMs && !_commandJobs.ContainsKey(kv.Key)) + stale.Add(kv.Key); + + if (_commandRecords.Count - stale.Count > CommandRecordsMax) + { + var oldest = new List(_commandRecords.Values); + oldest.Sort((a, b) => a.AtMs.CompareTo(b.AtMs)); + + foreach (CommandRecord r in oldest) + { + if (_commandRecords.Count - stale.Count <= CommandRecordsMax) + break; + + if (!stale.Contains(r.Key) && !_commandJobs.ContainsKey(r.Key)) + stale.Add(r.Key); + } + } + + foreach (string key in stale) + _commandRecords.Remove(key); + + if (stale.Count > 0) + SaveCommandRecords(); + } + + /// + /// Main thread. { key, commands, recipients? }. Checks everything before the key + /// is recorded, so a refusal for a malformed request is not remembered as a run. + /// + private void HandleCommandRun(Dictionary cmd, string reqId) + { + if (!WorldWritesAllowed(reqId)) + return; + + string key = StringField(cmd, "key"); + + if (string.IsNullOrEmpty(key) || key.Length > PaymentKeyMax) + { + EmitCommandError(reqId, "malformed", "commands are run under a key of at most 128 characters", null); + return; + } + + CommandJob running; + + if (_commandJobs.TryGetValue(key, out running)) + { + // The site retried while the first is still spreading over frames: one answer each. + if (reqId != null) + running.Waiting.Add(reqId); + + return; + } + + ForgetOldCommandRecords(); + + CommandRecord seen; + + if (_commandRecords.TryGetValue(key, out seen)) + { + if (!seen.Done) + { + EmitCommandError(reqId, "interrupted", + "these commands began before the plugin last reloaded or the server restarted, so some of them may have run; none is run again", seen); + return; + } + + EmitCommandAnswer(reqId, seen, true); + return; + } + + var commands = new List(); + string refusal = null; + + foreach (string raw in Strings(cmd, "commands")) + { + string line = (raw ?? string.Empty).Trim(); + + if (line.Length == 0) + continue; + + if (line.Length > CommandLengthMax) + refusal = $"a command is at most {CommandLengthMax} characters, and one is {line.Length}"; + else if (HasControl(line)) + refusal = "a command is one line, with no line breaks or control characters in it"; + + commands.Add(line); + } + + if (refusal == null && commands.Count == 0) + refusal = "there are no commands to run"; + else if (refusal == null && commands.Count > CommandsMax) + refusal = $"at most {CommandsMax} commands are run at a time, not {commands.Count}"; + + List recipients = null; + + if (refusal == null && cmd.ContainsKey("recipients")) + { + recipients = new List(); + var ids = new HashSet(); + + foreach (Dictionary row in Rows(cmd, "recipients")) + { + ulong id; + + if (!ulong.TryParse(StringField(row, "steamId"), System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out id) || id == 0UL) + { + refusal = $"'{StringField(row, "steamId")}' is not a Steam id"; + break; + } + + if (ids.Add(id)) + recipients.Add(new CommandRecipient { Id = id, Name = StringField(row, "name") }); + } + + if (refusal == null && recipients.Count > CommandRecipientsMax) + refusal = $"a reward runs for at most {CommandRecipientsMax} people at a time, not {recipients.Count}"; + } + + if (refusal == null && recipients == null) + { + // Nobody to fill the placeholders with: refused rather than sent with the braces in. + foreach (string line in commands) + { + if (SteamIdPlaceholder.IsMatch(line) || NamePlaceholder.IsMatch(line)) + { + refusal = "{steamid} and {name} are filled in only when commands are run for recipients"; + break; + } + } + } + + if (refusal != null) + { + EmitCommandError(reqId, "malformed", refusal, null); + return; + } + + // The key is on disk before the first command runs (§16.4). + var record = new CommandRecord { Key = key, AtMs = NowMs() }; + _commandRecords[key] = record; + SaveCommandRecords(); + + var job = new CommandJob { Record = record, Commands = commands, Recipients = recipients }; + + if (reqId != null) + job.Waiting.Add(reqId); + + _commandJobs[key] = job; + StepCommandJob(job); + } + + private static bool HasControl(string text) + { + foreach (char c in text) + if (char.IsControl(c)) + return true; + + return false; + } + + /// + /// Main thread. One frame's share of a job: whole recipients, until the frame has run + /// commands or spent . The rest + /// wait for the next frame. + /// + private void StepCommandJob(CommandJob job) + { + if (!_commandJobs.ContainsKey(job.Record.Key)) + return; + + var clock = System.Diagnostics.Stopwatch.StartNew(); + int ranThisFrame = 0; + + try + { + if (job.Recipients == null) + { + // One run, without recipients: every command, now. + if (!RunCommandSet(job, job.Commands, null)) + return; + + FinishCommandJob(job); + return; + } + + while (job.Next < job.Recipients.Count) + { + if (ranThisFrame >= CommandsPerFrame || clock.Elapsed.TotalMilliseconds >= CommandFrameMs) + { + NextTick(() => StepCommandJob(job)); + return; + } + + CommandRecipient who = job.Recipients[job.Next++]; + BasePlayer player = OnlinePlayer(who.Id); + + if (player == null) + { + // D383: skipped and named, never held until they join. + string name = string.IsNullOrEmpty(who.Name) ? who.Id.ToString(System.Globalization.CultureInfo.InvariantCulture) : who.Name; + + if (job.Record.Skipped.Count < CommandSkippedMax) + job.Record.Skipped.Add(name); + + job.Record.SkippedCount++; + continue; + } + + var filled = new List(job.Commands.Count); + + foreach (string line in job.Commands) + filled.Add(FillPlaceholders(line, player)); + + if (!RunCommandSet(job, filled, player)) + return; + + job.Record.Rewarded++; + ranThisFrame += filled.Count; + } + + FinishCommandJob(job); + } + catch (Exception ex) + { + StopCommandJob(job, "failed", $"the commands stopped: {Flatten(ex.Message)}"); + } + } + + /// A connected player by Steam id, or null: a sleeper is not online. + private static BasePlayer OnlinePlayer(ulong id) + { + foreach (BasePlayer p in BasePlayer.activePlayerList) + if (p != null && (ulong)p.userID == id && p.IsConnected) + return p; + + return null; + } + + /// + /// {steamid} as digits; {name} as ONE quoted argument, the way the game's own + /// BuildCommand quotes one, so spaces, quotes and semicolons in a name stay inside it + /// (spike, 2026-10-16). Control characters are dropped first. + /// + private static string FillPlaceholders(string line, BasePlayer player) + { + string id = ((ulong)player.userID).ToString(System.Globalization.CultureInfo.InvariantCulture); + var clean = new StringBuilder(); + + foreach (char c in player.displayName ?? string.Empty) + if (!char.IsControl(c)) + clean.Append(c); + + string quoted = clean.ToString().QuoteSafe(); + string filled = SteamIdPlaceholder.Replace(line, m => id); + return NamePlaceholder.Replace(filled, m => quoted); + } + + /// + /// Main thread. One set of commands, in order. False when the job stopped: a command the + /// server does not know ends it there, before anyone else's turn. + /// + private bool RunCommandSet(CommandJob job, List lines, BasePlayer player) + { + foreach (string line in lines) + { + bool notFound; + string reply = RunConsoleLine(line, out notFound); + + if (notFound) + { + string verb = line.Split(' ')[0]; + string why = $"the server does not know the command '{verb}'"; + + if (!_consolePlugins) + why += "; this framework does not run a plugin's console command from code, so a plugin's command is not found here (D363)"; + else + why += ", or the plugin that adds it is not loaded"; + + StopCommandJob(job, "unknown-command", why); + return false; + } + + job.Record.Ran++; + + if (job.Record.Replies.Count < CommandRepliesMax) + { + job.Record.Replies.Add(new CommandReply + { + Command = line, + SteamId = player != null ? ((ulong)player.userID).ToString(System.Globalization.CultureInfo.InvariantCulture) : null, + Reply = reply, + }); + } + } + + return true; + } + + /// + /// Main thread. One line, as the server console runs it, and what it answered: its reply, + /// or what it logged while it ran when it replies nothing. when + /// the game logged that it does not know the command. + /// + private string RunConsoleLine(string line, out bool notFound) + { + string unknown = "Command '" + line + "' not found"; + string verb = line.Split(' ')[0]; + var heard = new List(); + bool missing = false; + + UnityEngine.Application.LogCallback listen = (message, trace, type) => + { + if (message == null) + return; + + // Exactly the line, as the game prints it; or its first word, should a framework + // print only that. A command the line's own command runs is not this line. + if (message == unknown || message == "Command '" + verb + "' not found") + { + missing = true; + return; + } + + if (heard.Count < 8) + heard.Add(message); + }; + + string reply; + UnityEngine.Application.logMessageReceived += listen; + + try + { + // Not Quiet: the operator's console shows each command and its answer, as typed. + reply = ConsoleSystem.Run(ConsoleSystem.Option.Server, line); + } + catch (Exception ex) + { + reply = "threw: " + ex.Message; + } + finally + { + UnityEngine.Application.logMessageReceived -= listen; + } + + notFound = missing; + + if (string.IsNullOrEmpty(reply) && heard.Count > 0) + reply = string.Join(" | ", heard.ToArray()); + + reply = Flatten(reply ?? string.Empty); + return reply.Length > CommandReplyMax ? reply.Substring(0, CommandReplyMax) : reply; + } + + private void FinishCommandJob(CommandJob job) + { + CommandRecord r = job.Record; + r.Done = true; + _commandJobs.Remove(r.Key); + SaveCommandRecords(); + + _lastWorldSummary = job.Recipients == null + ? $"ran {r.Ran} console command(s) for key {r.Key}" + : $"ran console commands for {r.Rewarded} recipient(s), {r.SkippedCount} offline, for key {r.Key}"; + Puts($"world: {_lastWorldSummary}"); + + foreach (string waiting in job.Waiting) + EmitCommandAnswer(waiting, r, false); + } + + private void StopCommandJob(CommandJob job, string reason, string message) + { + CommandRecord r = job.Record; + r.Done = true; + r.Reason = reason; + r.Message = message; + _commandJobs.Remove(r.Key); + SaveCommandRecords(); + + Puts($"world: console commands for key {r.Key} stopped ({reason}) after {r.Ran}: {message}"); + + foreach (string waiting in job.Waiting) + EmitCommandAnswer(waiting, r, false); + } + + /// A finished record: command.ran, or command.error for one that stopped. + private void EmitCommandAnswer(string reqId, CommandRecord r, bool repeat) + { + if (r.Reason != null) + { + Dictionary error = CommandFrame("command.error", reqId, r); + error["reason"] = r.Reason; + error["message"] = r.Message; + error["repeat"] = repeat; + _lastWorldSummary = $"console commands refused ({r.Reason}): {r.Message}"; + EmitFrame(error); + return; + } + + Dictionary frame = CommandFrame("command.ran", reqId, r); + frame["repeat"] = repeat; + EmitFrame(frame); + } + + private void EmitCommandError(string reqId, string reason, string message, CommandRecord r) + { + Dictionary frame = CommandFrame("command.error", reqId, r); + frame["reason"] = reason; + frame["message"] = message; + _lastWorldSummary = $"console commands refused ({reason}): {message}"; + EmitFrame(frame); + } + + /// What every answer carries: the key, how many ran, the replies and who was skipped. + private Dictionary CommandFrame(string kind, string reqId, CommandRecord r) + { + Dictionary frame = Frame(kind, "reply"); + + if (r != null) + { + frame["key"] = r.Key; + frame["ran"] = r.Ran; + frame["rewarded"] = r.Rewarded; + + var replies = new List(); + + foreach (CommandReply reply in r.Replies) + { + var row = new Dictionary { { "command", reply.Command }, { "reply", reply.Reply ?? string.Empty } }; + + if (reply.SteamId != null) + row["steamId"] = reply.SteamId; + + replies.Add(row); + } + + frame["replies"] = replies; + frame["skipped"] = new List(r.Skipped); + frame["skippedCount"] = r.SkippedCount; + } + + if (reqId != null) + frame["reqId"] = reqId; + + return frame; + } } } -- 2.49.1 From 5104e0dbcd7c12869f99194932e8edc060a7b42e Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 16 Oct 2026 02:36:49 -0500 Subject: [PATCH 12/14] =?UTF-8?q?feat(bridge):=20keep=20a=20step's=20teard?= =?UTF-8?q?own=20with=20its=20key=20=E2=80=94=20teardownOf=20(D385)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core records nothing for a step that failed or whose answer was lost, and then reverts it with only the key: no server, no teardown. So a run request now carries its `teardown`, stored in the key's record and never run with it, and `{ key, teardownOf }` runs the teardown stored under `teardownOf`, under its own key, at most once: - only when something there may have run (some commands ran, or a reload cut the run off); otherwise answered with nothing run and nothing kept; - `busy` while the run it is for is still going; - a reward carries no teardown (refused `malformed`), and a teardown takes no placeholders. Re-compiled and driven on both rigs (a failed run's teardown ran once, a repeat ran nothing, nothing-ran and unknown keys ran nothing); rigs restored to the protocol-13 bridge. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- README.md | 4 +- overlay/oxide/plugins/RunicGateway.cs | 116 +++++++++++++++++++++----- 2 files changed, 99 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index d7e5982..ef11d55 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,9 @@ recipient of a reward who is online, with `{steamid}` and `{name}` filled in (th argument). That is how a plugin with no support of its own here is used from an event, for example RaidableBases' `rbe`. There is no allowlist: the site's switchboard is the switch, and `EventsEnabled` here is the server's. Each request runs at most once: its key is written to -`data/RunicGateway/commands-run.json` before anything runs, and is kept for seven days. A command the +`data/RunicGateway/commands-run.json` before anything runs, and is kept for seven days. A step's +teardown commands are kept with its key too, so the site can have them run even for a step that failed +part-way or whose answer was lost (`teardownOf`). A command the server does not know stops the request there as `unknown-command`. On a framework with `consolePlugins=False`, that is every plugin's command. diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index 0241808..79fda21 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -18180,6 +18180,12 @@ namespace Oxide.Plugins // answer. A record left running by a restart or a reload is answered `interrupted`: some of // its commands may have run, and none is run again. // + // **The teardown is kept with the key (D385).** A step's teardown commands come with its + // run and are stored, not run. The site's ledger keeps its own copy and sends it back at + // teardown; but core records nothing for a step that FAILED, or whose answer was lost, and + // then asks with only the key. So `{ key, teardownOf }` runs the teardown stored under + // `teardownOf`, when anything there may have run, under its own key, once. + // // **Spread over frames.** A full reward is 100 recipients by 10 commands. A recipient's // commands run together in one frame, and a frame stops taking recipients past // `CommandsPerFrame` commands or `CommandFrameMs`, so the game never stalls on a step. @@ -18249,6 +18255,8 @@ namespace Oxide.Plugins public List Replies = new List(); public List Skipped = new List(); public int SkippedCount; + /// The step's teardown commands, kept for teardownOf; never run with the record (D385). + public List Teardown = new List(); } /// One recipient a reward names: the Steam id, and the name the site knows. @@ -18373,28 +18381,20 @@ namespace Oxide.Plugins return; } - var commands = new List(); - string refusal = null; + string teardownOf = StringField(cmd, "teardownOf"); - foreach (string raw in Strings(cmd, "commands")) + if (!string.IsNullOrEmpty(teardownOf)) { - string line = (raw ?? string.Empty).Trim(); - - if (line.Length == 0) - continue; - - if (line.Length > CommandLengthMax) - refusal = $"a command is at most {CommandLengthMax} characters, and one is {line.Length}"; - else if (HasControl(line)) - refusal = "a command is one line, with no line breaks or control characters in it"; - - commands.Add(line); + StartTeardownOf(key, teardownOf, reqId); + return; } + string refusal = null; + List commands = CommandLines(cmd, "commands", ref refusal); + List teardown = CommandLines(cmd, "teardown", ref refusal); + if (refusal == null && commands.Count == 0) refusal = "there are no commands to run"; - else if (refusal == null && commands.Count > CommandsMax) - refusal = $"at most {CommandsMax} commands are run at a time, not {commands.Count}"; List recipients = null; @@ -18419,12 +18419,20 @@ namespace Oxide.Plugins if (refusal == null && recipients.Count > CommandRecipientsMax) refusal = $"a reward runs for at most {CommandRecipientsMax} people at a time, not {recipients.Count}"; + + if (refusal == null && teardown.Count > 0) + refusal = "a reward has no teardown: nothing it runs is undone"; } - if (refusal == null && recipients == null) + if (refusal == null) { // Nobody to fill the placeholders with: refused rather than sent with the braces in. - foreach (string line in commands) + var unfilled = new List(teardown); + + if (recipients == null) + unfilled.AddRange(commands); + + foreach (string line in unfilled) { if (SteamIdPlaceholder.IsMatch(line) || NamePlaceholder.IsMatch(line)) { @@ -18440,8 +18448,8 @@ namespace Oxide.Plugins return; } - // The key is on disk before the first command runs (§16.4). - var record = new CommandRecord { Key = key, AtMs = NowMs() }; + // The key is on disk before the first command runs (§16.4), and the teardown with it. + var record = new CommandRecord { Key = key, AtMs = NowMs(), Teardown = teardown }; _commandRecords[key] = record; SaveCommandRecords(); @@ -18454,6 +18462,74 @@ namespace Oxide.Plugins StepCommandJob(job); } + /// + /// The command lines in one array field, trimmed, blanks dropped. A refusal is set on the + /// first line too long or with a control character in it, or on more than + /// ; an earlier refusal is kept. + /// + private static List CommandLines(Dictionary cmd, string field, ref string refusal) + { + var lines = new List(); + + foreach (string raw in Strings(cmd, field)) + { + string line = (raw ?? string.Empty).Trim(); + + if (line.Length == 0) + continue; + + if (refusal == null && line.Length > CommandLengthMax) + refusal = $"a command is at most {CommandLengthMax} characters, and one is {line.Length}"; + else if (refusal == null && HasControl(line)) + refusal = "a command is one line, with no line breaks or control characters in it"; + + lines.Add(line); + } + + if (refusal == null && lines.Count > CommandsMax) + refusal = $"at most {CommandsMax} {field} commands are run at a time, not {lines.Count}"; + + return lines; + } + + /// + /// Main thread. D385: run the teardown stored with , under + /// , when anything there may have run — some of its commands ran, + /// or a reload cut it off. Nothing stored, or nothing ran: answered with nothing run and + /// nothing recorded, which is a server this step never reached. + /// + private void StartTeardownOf(string key, string of, string reqId) + { + if (_commandJobs.ContainsKey(of)) + { + EmitCommandError(reqId, "busy", "the commands this teardown is for are still running; ask again in a moment", null); + return; + } + + CommandRecord first; + bool due = _commandRecords.TryGetValue(of, out first) + && first.Teardown != null && first.Teardown.Count > 0 + && (first.Ran > 0 || !first.Done); + + if (!due) + { + EmitCommandAnswer(reqId, new CommandRecord { Key = key, Done = true }, false); + return; + } + + var record = new CommandRecord { Key = key, AtMs = NowMs() }; + _commandRecords[key] = record; + SaveCommandRecords(); + + var job = new CommandJob { Record = record, Commands = new List(first.Teardown) }; + + if (reqId != null) + job.Waiting.Add(reqId); + + _commandJobs[key] = job; + StepCommandJob(job); + } + private static bool HasControl(string text) { foreach (char c in text) -- 2.49.1 From e0d6c46850faed88eba131a671d212830efa1c20 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Fri, 16 Oct 2026 19:29:02 -0500 Subject: [PATCH 13/14] =?UTF-8?q?feat(bridge):=20RaidableBases=20=E2=80=94?= =?UTF-8?q?=20raid.*=20events,=20raid.spawn,=20the=20raids=20helper=20(PLA?= =?UTF-8?q?N=5FREDESIGNS=20=C2=A711.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PLAN_REDESIGNS §11.4 (D202-D208, D386-D390), protocol 14. Spec: docs rust-link/PROTOCOL.md §20.12. - RunicGatewayRaids.cs, the raids helper (D168, D204-D207, D387), shipped beside the bridge and released with it. Harmony patches on RaidableBases' spawn queue Add, OpenEvent and each base's BypassUseOwners(): a run's base is tagged at birth, its PvP/NPCs/despawn/lock set on a member-by-member copy of its profile (Setup, NPC and DespawnOptions copied again, so the shared profile is never touched), PvP also set on the base after OpenEvent; a spot checked (own look, then the plugin's area check with its reason, rocks and cliffs a warning; then the ocean level, D388, D389) and pasted; one base despawned; profiles reloaded under watch. Both editions, every member found by name. rgr.status. - Hello's integrations.raidableBases: edition, the paid spawn call (looked for by name, D348), CopyPaste, the plugin's own difficulties (D386), the helper, and with it the grid and the profiles. - raid.started / raid.completed / raid.ended / raid.purchased / raid.locked from the lifecycle hooks; the unload trap's completions (no owner, no raiders) are neither sent nor counted. The tally's raidsCompleted (Warlord). - map.live's raids layer, kept by the hooks and seeded from GetAllEvents(). - raid.spawn: with the helper the base is a world registry entry, so world.revert / world.owned / world.expired serve it (never-started after 15 min, raidablebases-unloaded); without it the paid call or rbevent, not the run's. raid.check for the editor's advice (D208, D391). - RaidableBases' profiles in the configuration editor (D205): a virtual @data/RaidableBases/Profiles/ path, all-profiles-or-none writes, reloaded by the helper and rolled back when a profile does not load. Compiled on both rigs (Rust 25902425, Oxide 2.0.7830, Carbon 2.0.262) and driven on both rigs with the paid 3.2.2 and the tier-3 pack through a throwaway plugin: the pick and spot routes with every setting, a repeat key, revert, expiry, and a good and a broken profile edit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- .gitea/workflows/release.yml | 7 +- README.md | 21 + overlay/oxide/plugins/RunicGateway.cs | 1623 ++++++++++++++++++- overlay/oxide/plugins/RunicGatewayRaids.cs | 1697 ++++++++++++++++++++ 4 files changed, 3336 insertions(+), 12 deletions(-) create mode 100644 overlay/oxide/plugins/RunicGatewayRaids.cs diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 6799076..48ccce1 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -30,7 +30,7 @@ # # 2. A FRAMEWORK-NEUTRAL LAYOUT. The tarball holds one directory, # `runicgateway-rust-plugin/`, with `RunicGateway.cs`, its helpers -# (`RunicGatewayZones.cs`, `RunicGatewayDomes.cs`) and `manifest.json` at its top. Every `.cs` in it is listed in the +# (`RunicGatewayZones.cs`, `RunicGatewayDomes.cs`, `RunicGatewayRaids.cs`) and `manifest.json` at its top. Every `.cs` in it is listed in the # manifest's `files`, and the installer and the egg place every one of them. It does NOT mirror `oxide/plugins/`: the same file goes to # `oxide/plugins/` or `carbon/plugins/`, and the installer and the egg each # decide which (§34.2.1). The fixed, unversioned prefix is so a reader never @@ -41,7 +41,8 @@ # committed file keeps its placeholder; the manifest records the commit. # Only the bridge is stamped. A helper (docs/modules/rust/PLAN_FIXES.md D168, # D182 — today `RunicGatewayZones.cs` for ZoneManager and `RunicGatewayDomes.cs` -# for ZoneDomes, PLAN_REDESIGNS D194) keeps its own +# for ZoneDomes, PLAN_REDESIGNS D194, and `RunicGatewayRaids.cs` for RaidableBases, +# D204) keeps its own # version: it does one job, changes rarely, and the bridge reads its version at # hello to know which job it does. # @@ -81,7 +82,7 @@ env: PLUGIN: overlay/oxide/plugins/RunicGateway.cs # The helpers shipped beside the bridge (D168, D182). Optional at runtime — # the bridge loads without them — and installed by default. - HELPERS: overlay/oxide/plugins/RunicGatewayZones.cs overlay/oxide/plugins/RunicGatewayDomes.cs + HELPERS: overlay/oxide/plugins/RunicGatewayZones.cs overlay/oxide/plugins/RunicGatewayDomes.cs overlay/oxide/plugins/RunicGatewayRaids.cs SEED_VERSION: "0.1.0" INSTALLER_REPO: RunicGateway/installer diff --git a/README.md b/README.md index ef11d55..ba7a598 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ unauthenticated command channel on the network. overlay/oxide/plugins/RunicGateway.cs → /oxide/plugins/RunicGateway.cs overlay/oxide/plugins/RunicGatewayZones.cs → /oxide/plugins/RunicGatewayZones.cs overlay/oxide/plugins/RunicGatewayDomes.cs → /oxide/plugins/RunicGatewayDomes.cs +overlay/oxide/plugins/RunicGatewayRaids.cs → /oxide/plugins/RunicGatewayRaids.cs ``` `RunicGatewayZones.cs` is the **ZoneManager helper** (docs/modules/rust/PLAN_FIXES.md D181, D182). ZoneManager @@ -52,6 +53,17 @@ helper patches the first with Harmony and tells the bridge when ZoneDomes has st domes back without drawing any twice. It is optional: without it the site offers no dome, and says which piece is missing. `rgd.status` in the server console says whether it patched. +`RunicGatewayRaids.cs` is the **RaidableBases helper** (docs/modules/rust/PLAN_REDESIGNS.md §11.4, D204–D207, +D387). RaidableBases' API spawns nothing and despawns nothing, so an event's base cannot be the event's from the +outside. The helper reaches its internals by reflection and patches its spawn queue, `OpenEvent` and each base's +`BypassUseOwners()` with Harmony: it tags the base a run asked for at birth, applies that spawn's own PvP, NPCs, +despawn time and lock on a copy of its profile, places one at a spot after checking it, despawns that one base at +the run's end, and reloads RaidableBases' profiles when the site edits them. It serves both editions, free and +paid, and reads every difficulty, profile and base name from the installed plugin. It is optional: without it the +bridge still reports raid bases on the feed and the map, and a step can still ask RaidableBases for a base where it +picks, but that base despawns on its own timer, and the site says so. `rgr.status` in the server console says +whether it patched, which edition's spawn call it found, and whether the plugin's spawn grid is built. + Oxide compiles and loads it on the write, and writes `oxide/config/RunicGateway.json` on first load: ```json @@ -134,6 +146,15 @@ kept when the event ends: Guarded Crate has no way to end one event alone. Witho is refused, because the crate would land unguarded; and, as for Dangerous Treasures, a framework with `consolePlugins=False` cannot place one. +nivex's `RaidableBases` is optional, free or paid, and needs `CopyPaste` to paste its bases. Where it is +loaded, a base appearing, being raided, bought or locked to a player reach the site's feed, its map's own +*Raid bases* layer and the *Warlord* title, and an event can spawn one (`raid.spawn`). With +`RunicGatewayRaids.cs` (above) the base is the event's: at a spot or where RaidableBases picks, with its own +settings, despawned when the event ends; and the site can edit RaidableBases' profiles from its configuration +page, reloading them under watch. For up to ten minutes after RaidableBases loads it builds its spawn grid and +refuses every spawn, and a spawn then is answered "not ready". Every difficulty, profile and base name is read +from the installed plugin. + nivex's `ZLevels Remastered` is optional, and the bridge only says whether it is loaded. Its levels are never read or sent. Its XP boosts are its own permissions (`zlevelsremastered.vip1` to `vip3`), so an event lends one as it lends any other permission, and it takes effect at once. diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index 79fda21..0be829e 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -290,7 +290,10 @@ namespace Oxide.Plugins // Protocol 14, Dangerous Treasures (§20.8). "OnDangerousEventStarted", "OnDangerousEventWon", "OnDangerousEventEnded", // Protocol 14, Guarded Crate (§20.9). - "OnGuardedCrateEventStart", "OnGuardedCrateEventEnded", "OnGuardedCrateEventFailed" + "OnGuardedCrateEventStart", "OnGuardedCrateEventEnded", "OnGuardedCrateEventFailed", + // Protocol 14, RaidableBases (§20.12). + "OnRaidableBaseStarted", "OnRaidableBaseEnded", "OnRaidableBaseCompleted", + "OnRaidableBasePurchased", "OnRaidableBaseLocked" }; private void Init() @@ -586,6 +589,7 @@ namespace Oxide.Plugins LoadWorldCalls(); LoadDtStarts(); LoadGcPlacements(); + LoadRaidStarts(); LoadCommandRecords(); } @@ -1097,6 +1101,17 @@ namespace Oxide.Plugins HandleGcPlace(cmd, reqId); break; + case "raid.spawn": + // Protocol 14 (PROTOCOL.md §20.12, D203-D208, D387). One RaidableBases base for a + // run: the plugin's pick or an admin's spot, despawned at the run's end. + HandleRaidSpawn(cmd, reqId); + break; + + case "raid.check": + // Protocol 14 (PROTOCOL.md §20.12, D208, D388, D389). A spot's checks, spawning nothing. + HandleRaidCheck(cmd, reqId); + break; + case "command.run": // Protocol 14 (PROTOCOL.md §20.11, D382-D384). Console commands a step's author // typed: once, or once per online recipient; at most once per key. @@ -1672,6 +1687,7 @@ namespace Oxide.Plugins SeedTreasures("load"); NoteDtSeen(DangerousTreasuresPlugin() != null); SeedGuardedCrates("load"); + SeedRaids("load"); // Protocol 14: the console probe (D363) and the world events' sweep. Before hello, which // carries what the probe found. @@ -2918,6 +2934,9 @@ namespace Oxide.Plugins /// Protocol 14 (D375): guarded crates this player cleared, by killing the last guard (Warden). public int GuardedWon; + + /// Protocol 14 (§11.4, D202): RaidableBases bases this player completed as a raider (Warlord). + public int RaidsCompleted; public readonly Dictionary Crafted = new Dictionary(); /// Stage 4 (D247): kills of RunicNPC's NPCs, by profile name. Also in . @@ -2986,7 +3005,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 && GuardedWon == 0 - && Crafted.Count == 0 && Built == 0 && Repaired == 0 && Healed == 0 + && RaidsCompleted == 0 && Crafted.Count == 0 && Built == 0 && Repaired == 0 && Healed == 0 && Rockets == 0 && Explosives == 0 && Missions == 0 && NpcProfileKills.Count == 0 && !HasBalance; } @@ -3094,6 +3113,8 @@ namespace Oxide.Plugins frame["treasuresWon"] = tally.TreasuresWon; if (tally.GuardedWon > 0) frame["guardedWon"] = tally.GuardedWon; + if (tally.RaidsCompleted > 0) + frame["raidsCompleted"] = tally.RaidsCompleted; if (tally.Crafted.Count > 0) frame["crafted"] = tally.Crafted; if (tally.Built > 0) @@ -5226,7 +5247,7 @@ namespace Oxide.Plugins // zone step's PvP rules are refused without (§14.3.2); and Dynamic PVP (§14.3.3). // Guarded Crate and NpcSpawn too: a placement is refused without either (D364). And // ZLevels, which the servers page names (§14.3.6). - if (IsZonePlugin(plugin) || IsRunicNpc(plugin) || IsEconomics(plugin) || IsBackpacks(plugin) || IsTruePve(plugin) || IsDynamicPvp(plugin) || IsDangerousTreasures(plugin) || IsGuardedCrate(plugin) || IsZLevels(plugin)) + if (IsZonePlugin(plugin) || IsRunicNpc(plugin) || IsEconomics(plugin) || IsBackpacks(plugin) || IsTruePve(plugin) || IsDynamicPvp(plugin) || IsDangerousTreasures(plugin) || IsGuardedCrate(plugin) || IsZLevels(plugin) || IsRaidPlugin(plugin)) NextTick(ResendHello); // Dynamic PVP raises its creates as it opens its zones, which at a load are its @@ -5242,6 +5263,11 @@ namespace Oxide.Plugins NextTick(() => SeedTreasures("Dangerous Treasures loaded")); } + // RaidableBases' live bases, found again: a reload of it keeps none, but a hotload of + // this bridge hears no starts (§20.12). + if (plugin != null && plugin != this && plugin.Name == "RaidableBases") + NextTick(() => SeedRaids("RaidableBases loaded")); + // Economics loaded after players connected: what they hold now is not otherwise known // until it moves (D336). if (IsEconomics(plugin)) @@ -5269,7 +5295,7 @@ 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) || IsGuardedCrate(plugin) || IsZLevels(plugin)) + if (IsZonePlugin(plugin) || IsRunicNpc(plugin) || IsEconomics(plugin) || IsBackpacks(plugin) || IsTruePve(plugin) || IsDynamicPvp(plugin) || IsDangerousTreasures(plugin) || IsGuardedCrate(plugin) || IsZLevels(plugin) || IsRaidPlugin(plugin)) NextTick(ResendHello); // Guarded Crate's Unload ended every event WITHOUT a hook. @@ -5287,6 +5313,11 @@ namespace Oxide.Plugins NoteDtSeen(false); } + // Its Unload despawned every base, each with its hooks; a run's base still on its way + // is gone with the queue (§20.12). + if (plugin != null && plugin != this && plugin.Name == "RaidableBases") + RaidableBasesGone(); + ConfigWriteJob job = _configJob; if (job == null || plugin == null) @@ -5528,6 +5559,15 @@ namespace Oxide.Plugins /// The log lines that showed the failure, or the tail read when the ceiling passed. public string RollbackLog; + + /// + /// RaidableBases' profiles rather than a plugin's config (D205): reloaded by the raids + /// helper as rb.reloadprofiles does, and judged by whether each profile loaded. + /// + public bool Profiles; + + /// The helper's finished reloads when this phase's reload was asked for; one more is this one. + public int ReloadsBefore; } private ConfigWriteJob _configJob; @@ -5553,6 +5593,7 @@ namespace Oxide.Plugins try { WalkConfig(root, root, 0, files, ref truncated); + ListRaidProfiles(files, ref truncated); } catch (Exception ex) { @@ -5893,6 +5934,34 @@ namespace Oxide.Plugins }); } + // RaidableBases' profiles reload through its own command, not a plugin reload (D205), so + // a write is all profiles or none, and refused before anything lands when the reload + // could not be asked for or watched. + int profileFiles = 0; + + foreach (StagedConfigWrite file in staged) + { + if (IsRaidProfilePath(file.RelPath)) + profileFiles++; + } + + if (profileFiles > 0 && profileFiles < staged.Count) + { + EmitConfigError(reqId, "mixed", "RaidableBases' profiles reload on their own; save them apart from other files"); + return; + } + + if (profileFiles > 0) + { + string refusal = RaidProfileWriteRefusal(); + + if (refusal != null) + { + EmitConfigError(reqId, "profiles-unavailable", refusal); + return; + } + } + foreach (StagedConfigWrite file in staged) { try @@ -5911,6 +5980,12 @@ namespace Oxide.Plugins } } + if (profileFiles > 0) + { + StartRaidProfileJob(reqId, staged); + return; + } + // This plugin never reloads itself (§21.3), so a change to its own settings would // otherwise wait for somebody's next manual reload — and `EventsEnabled` is the first // of them an operator is MEANT to change from the website (protocol 8, D76). It is @@ -6032,6 +6107,12 @@ namespace Oxide.Plugins return; } + if (job.Profiles) + { + RaidProfileWatchTick(job); + return; + } + string failure = ReadConfigFailure(job); if (failure != null && IsCompileStallOf(failure, job.Reload)) @@ -6540,6 +6621,269 @@ namespace Oxide.Plugins } } + // ---- RaidableBases' profiles in the editor (PLAN_REDESIGNS §11.4.3, D205) ---- + // + // The editor never walks a plugin's data directory, which holds live state and the + // permission stores. **One folder is the named exception**: RaidableBases keeps its profiles + // (every base's NPCs, loot rules, timers and protection) in `data/RaidableBases/Profiles/`, + // and an operator tunes them there. They travel under a virtual prefix so no path from the + // config tree can ever name them, and they get the editor's usual guarantees: version + // checked, backed up, written, reloaded under watch, and rolled back if the profile does not + // load. The reload is `rb.reloadprofiles`'s, asked of the raids helper, and "loaded" is the + // helper finding the profile in the plugin's own table after the reload finished. + + private const string RaidProfilePrefix = "@data/RaidableBases/Profiles/"; + + private static bool IsRaidProfilePath(string rel) + { + return rel != null && rel.StartsWith(RaidProfilePrefix, StringComparison.Ordinal); + } + + private static string RaidProfilesDir() + { + try + { + return Path.Combine(Path.Combine(Interface.Oxide.DataDirectory, "RaidableBases"), "Profiles"); + } + catch (Exception) + { + return null; + } + } + + /// A profile file's path on this host: one .json directly in the profiles folder, never a link. + private static bool ResolveRaidProfilePath(string rel, out string full, out string refusal) + { + full = null; + refusal = null; + string name = rel.Substring(RaidProfilePrefix.Length); + + if (name.Length == 0 || name.IndexOf('/') >= 0 || name.IndexOf('\\') >= 0 || name.IndexOf(':') >= 0 + || name.IndexOf('\0') >= 0 || name.IndexOf("..", StringComparison.Ordinal) >= 0 + || !name.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) + { + refusal = "a RaidableBases profile is one .json file directly in its profiles folder"; + return false; + } + + string dir = RaidProfilesDir(); + + if (string.IsNullOrEmpty(dir)) + { + refusal = "this framework reports no data directory"; + return false; + } + + try + { + string canonicalDir = Path.GetFullPath(dir); + string candidate = Path.GetFullPath(Path.Combine(canonicalDir, name)); + + if (!string.Equals(Path.GetDirectoryName(candidate), canonicalDir.TrimEnd(Path.DirectorySeparatorChar), StringComparison.OrdinalIgnoreCase)) + { + refusal = "a path may not step outside RaidableBases' profiles folder"; + return false; + } + + if (File.Exists(candidate) && (new FileInfo(candidate).Attributes & FileAttributes.ReparsePoint) != 0) + { + refusal = "a link is not followed"; + return false; + } + + full = candidate; + return true; + } + catch (Exception ex) + { + refusal = ex.Message; + return false; + } + } + + /// Main thread. The profile files, as config rows under the virtual prefix. Directory entries only, like the walk. + private static void ListRaidProfiles(List into, ref bool truncated) + { + string dir = RaidProfilesDir(); + + if (string.IsNullOrEmpty(dir) || !Directory.Exists(dir)) + return; + + string[] entries = Directory.GetFiles(dir, "*.json"); + Array.Sort(entries, StringComparer.OrdinalIgnoreCase); + + foreach (string path in entries) + { + if (into.Count >= ConfigMaxFiles) + { + truncated = true; + return; + } + + FileInfo info; + + try + { + info = new FileInfo(path); + } + catch (Exception) + { + continue; + } + + // RaidableBases skips its own placeholder, and so does the editor. + if ((info.Attributes & FileAttributes.ReparsePoint) != 0 || info.Name.IndexOf("_empty", StringComparison.OrdinalIgnoreCase) >= 0) + continue; + + var row = new Dictionary + { + { "path", RaidProfilePrefix + info.Name }, + { "bytes", info.Length }, + { "modified", ToUnixMs(info.LastWriteTimeUtc) }, + { "plugin", "RaidableBases" }, + { "profile", true } + }; + + if (info.Length > ConfigMaxFileBytes) + { + row["editable"] = false; + row["reason"] = "larger than this bridge will carry"; + } + else + { + row["editable"] = true; + } + + into.Add(row); + } + } + + /// Why a profile write cannot be reloaded and watched now, or null. + private string RaidProfileWriteRefusal() + { + if (RaidableBasesPlugin() == null) + return "RaidableBases is not loaded, so its profiles cannot be reloaded"; + + if (!RaidsHelperPatched()) + return RaidsHelperState() == "missing" + ? "a profile is saved through RunicGatewayRaids.cs, which reloads it and checks it loaded, and it is not installed" + : $"RunicGatewayRaids.cs could not patch this RaidableBases ({RaidsHelperState()}), so a profile cannot be reloaded and checked"; + + if (!(RaidsHelperPlugin().Call("GridReady") is bool ready) || !ready) + return "RaidableBases is still building its spawn grid (up to ten minutes after it loads) and refuses to reload its profiles until it has"; + + return null; + } + + /// Main thread. The files are on disk: ask for the reload, answer at once, watch. + private void StartRaidProfileJob(string reqId, List staged) + { + Plugin helper = RaidsHelperPlugin(); + + var job = new ConfigWriteJob + { + WriteId = Guid.NewGuid().ToString("N"), + Reload = "RaidableBases", + Staged = staged, + Phase = ConfigPhase.Edit, + Started = DateTime.UtcNow, + PhaseStarted = DateTime.UtcNow, + Profiles = true, + ReloadsBefore = helper.Call("ReloadsFinished") is int done ? done : 0 + }; + + if (!(helper.Call("ReloadProfiles") is bool asked) || !asked) + { + RestoreConfig(staged); + EmitConfigError(reqId, "reload-failed", "RaidableBases would not reload its profiles now (its grid, or a reload already running)"); + return; + } + + _configJob = job; + job.Watch = timer.Every(ConfigLogPollSeconds, ConfigWatchTick); + SendConfigReport(reqId, staged, false, false, null, null, job); + } + + /// + /// Main thread, once a second while a profile reload is pending. Settled when the helper + /// says the reload finished, by whether every written profile is in the plugin's table; + /// a profile that is not is rolled back and the old one reloaded. + /// + private void RaidProfileWatchTick(ConfigWriteJob job) + { + Plugin helper = RaidsHelperPlugin(); + + if (helper == null || RaidableBasesPlugin() == null) + { + if (job.Phase == ConfigPhase.Edit) + { + RestoreConfig(job.Staged); + SettleConfigJob(job, false, true, false, "RaidableBases or its helper unloaded before the profiles finished reloading; the files are back", null); + } + else + { + SettleConfigJob(job, false, true, false, $"{job.RollbackReason}; RaidableBases or its helper unloaded before the restored profiles reloaded", job.RollbackLog); + } + + return; + } + + int finished = helper.Call("ReloadsFinished") is int n ? n : 0; + + if (finished > job.ReloadsBefore) + { + var failed = new List(); + + foreach (StagedConfigWrite file in job.Staged) + { + string name = Path.GetFileNameWithoutExtension(file.FullPath); + + if (helper.Call("ProfileState", name) as string != "loaded") + failed.Add(name); + } + + if (job.Phase == ConfigPhase.Edit) + { + if (failed.Count == 0) + SettleConfigJob(job, true, false, null, null, null); + else + RollBackRaidProfiles(job, $"RaidableBases did not load profile {string.Join(", ", failed.ToArray())}", LogTail()); + } + else + { + SettleConfigJob(job, false, true, failed.Count == 0, + failed.Count == 0 ? job.RollbackReason : $"{job.RollbackReason}; the restored profile did not load either", + job.RollbackLog); + } + + return; + } + + if ((DateTime.UtcNow - job.PhaseStarted).TotalSeconds < ConfigReloadCeilingSeconds) + return; + + if (job.Phase == ConfigPhase.Edit) + RollBackRaidProfiles(job, $"RaidableBases' profile reload did not finish within {ConfigReloadCeilingSeconds:0}s", LogTail()); + else + SettleConfigJob(job, false, true, false, $"{job.RollbackReason}; the restored profiles did not finish reloading either", job.RollbackLog); + } + + private void RollBackRaidProfiles(ConfigWriteJob job, string reason, string log) + { + RestoreConfig(job.Staged); + + job.Phase = ConfigPhase.Restore; + job.PhaseStarted = DateTime.UtcNow; + job.RollbackReason = reason; + job.RollbackLog = log; + + Plugin helper = RaidsHelperPlugin(); + job.ReloadsBefore = helper != null && helper.Call("ReloadsFinished") is int done ? done : 0; + + if (helper == null || !(helper.Call("ReloadProfiles") is bool asked) || !asked) + SettleConfigJob(job, false, true, false, $"{reason}; the files are back, but RaidableBases would not reload them", log); + } + /// /// Main thread. If a write touched this plugin's own config, take the one setting that /// is safe to change live — EventsEnabled — from what was written. Returns @@ -6851,6 +7195,10 @@ namespace Oxide.Plugins return false; } + // The one named exception to "never the data directory" (D205): RaidableBases' profiles. + if (IsRaidProfilePath(rel)) + return ResolveRaidProfilePath(rel, out full, out refusal); + if (rel.IndexOf('\0') >= 0 || rel.IndexOf("..", StringComparison.Ordinal) >= 0) { refusal = "a path may not step outside the configuration directory"; @@ -6949,6 +7297,9 @@ namespace Oxide.Plugins /// private static string PluginGuess(string rel) { + if (IsRaidProfilePath(rel)) + return "RaidableBases"; + int slash = rel.IndexOf('/'); return slash > 0 @@ -8191,6 +8542,12 @@ namespace Oxide.Plugins /// When a zone is erased without being asked, on this host's clock (D96). Zero for an entity. public long DeadlineMs; + /// A raid base's difficulty name, the plugin's own (§20.12, D386). Null for anything else. + public string Difficulty; + + /// Whether a raid base has opened. Until it does it is on its way, and the position is the spot asked for or zero. + public bool Started; + public string BootId; public string WipeId; public long TakenMs; @@ -10109,6 +10466,1214 @@ namespace Oxide.Plugins EmitFrame(frame); } + // ---- RaidableBases (PLAN_REDESIGNS §11.4, D202-D208, D386-D390) ---- + // + // nivex's RaidableBases pastes a stocked, guarded CopyPaste building for players to raid. The + // bridge reports its bases (the feed, the map's raids layer, the tally's Warlord count) from + // the plugin's own lifecycle hooks, which both editions raise alike, and spawns one for a + // run's step. PROTOCOL.md §20.12 is the wire; what the spikes found is §11.4.1–§11.4.4. + // + // * **Names are the plugin's (D386).** Difficulty names and levels come from its own + // `GetAllDifficulties` (and `GetRaidableModes` on the paid edition), profile and base + // names from the raids helper. None is written here. + // * **A run's base needs the raids helper to be the run's** (D204–D207, D387). The helper + // tags it at birth, applies the step's settings, places it at a spot, and despawns it + // alone. The base is then a world registry entry like a zone, under its own id, and + // `world.revert`, `world.owned` and `world.expired` treat it as they treat the rest. + // * **Without the helper a step still spawns one where the plugin can be asked**: the paid + // edition's own `SpawnRandomBase` call, at a difficulty, or the free edition's console + // command, which says nothing back. Neither base is the run's; it despawns on its own + // timer, and the reply says so. + // * **An unload of RaidableBases raises `Completed` for every live base, with no owner and + // no raiders (§11.4.2).** Such a completion is neither counted nor told. + + private const string WorldKindRaid = "raidbase"; + + /// A run's raid base id is this bridge's own, so a zone's rg- can never be mistaken for one. + private const string RaidIdPrefix = "raid-"; + + private const string RaidStartedKind = "raid.started"; + private const string RaidCompletedKind = "raid.completed"; + private const string RaidEndedKind = "raid.ended"; + private const string RaidPurchasedKind = "raid.purchased"; + private const string RaidLockedKind = "raid.locked"; + + /// The free edition's console command, as its config ships it. + private const string RbConsoleCommand = "rbevent"; + + /// How far a hook's position may be from a base this bridge knows and still be that base. + private const float RaidReach = 2f; + + /// The most raiders a completion names; the rest are counted in raiderCount. + private const int RaidRaidersMax = 32; + + /// A run's base not open this long after it was asked for never will be: its request is dropped and it expires. + private const long RaidPendingMs = 15L * 60L * 1000L; + + /// How long a spawn's key is remembered, as a payment's is. + private const long RaidStartMemoryMs = 7L * 24L * 60L * 60L * 1000L; + + /// One live base, as the hooks told it. Main thread only. + private sealed class LiveRaid + { + public UnityEngine.Vector3 At; + public int Level; + public string Difficulty; + public bool Pvp; + public string Base; + public long DespawnAtMs; + + /// The run's id for it (a world registry id), or null for a base no run made. + public string Id; + } + + private readonly List _raids = new List(); + + /// One spawn a step asked for, kept for its key so a retry spawns nothing. Persisted as it is. + private sealed class RaidStart + { + public string Key; + public string Id; + public string Route; + public string Difficulty; + public int Level; + public string Profile; + public string Base; + public float X; + public float Z; + public bool Spot; + public long AtMs; + } + + private Dictionary _raidStarts = new Dictionary(StringComparer.Ordinal); + + private int _raidSerial; + + private Timer _raidTimer; + + private Plugin RaidableBasesPlugin() + { + return plugins.Find("RaidableBases"); + } + + private Plugin RaidsHelperPlugin() + { + return plugins.Find("RunicGatewayRaids"); + } + + /// RaidableBases, its helper and CopyPaste are all in hello, so a load or unload of any says it again. + private bool IsRaidPlugin(Plugin plugin) + { + if (plugin == null || plugin == this) + return false; + + string name = plugin.Name; + return name == "RaidableBases" || name == "RunicGatewayRaids" || name == "CopyPaste"; + } + + /// missing, or the helper's own state: patched, unsupported or no-raidablebases. + private string RaidsHelperState() + { + Plugin helper = RaidsHelperPlugin(); + return helper == null ? "missing" : helper.Call("State") as string ?? "unsupported"; + } + + private bool RaidsHelperPatched() + { + return RaidableBasesPlugin() != null && RaidsHelperState() == "patched"; + } + + /// + /// The installed copy's difficulties, each name once with its level, by level (D386). From + /// its public GetAllDifficulties, which lists one per profile; the free edition's is + /// Normal at 512. + /// + private List> RaidDifficulties() + { + var rows = new List>(); + Plugin rb = RaidableBasesPlugin(); + + if (rb == null) + return rows; + + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + try + { + if (rb.Call("GetAllDifficulties") is System.Collections.IEnumerable all) + { + foreach (object pair in all) + { + string name = TupleItem(pair, 1) as string; + object level = TupleItem(pair, 2); + + if (string.IsNullOrEmpty(name) || !(level is int) || !seen.Add(name)) + continue; + + rows.Add(new KeyValuePair(name, (int)level)); + } + } + } + catch (Exception ex) + { + Puts($"could not read RaidableBases' difficulties: {Flatten(ex.Message)}"); + } + + rows.Sort((a, b) => a.Value != b.Value ? a.Value.CompareTo(b.Value) : string.Compare(a.Key, b.Key, StringComparison.OrdinalIgnoreCase)); + return rows; + } + + /// The name the installed copy gives a level, or null. + private string RaidDifficultyName(int level) + { + foreach (KeyValuePair d in RaidDifficulties()) + { + if (d.Value == level) + return d.Key; + } + + return null; + } + + /// + /// Item (from 1) of a value tuple, following Rest past the + /// seventh. Reflection over System.ValueTuple only: nothing of RaidableBases' own. + /// + private static object TupleItem(object tuple, int n) + { + object at = tuple; + + while (at != null && n > 7) + { + at = at.GetType().GetField("Rest")?.GetValue(at); + n -= 7; + } + + return at?.GetType().GetField("Item" + n)?.GetValue(at); + } + + /// Whether the paid edition's own SpawnRandomBase(string, Vector3, int, int, bool) is there to call (D348: looked for by name, never assumed from the edition). + private bool RaidSpawnCall(Plugin rb) + { + if (rb == null) + return false; + + try + { + foreach (System.Reflection.MethodInfo m in rb.GetType().GetMethods(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic)) + { + if (m.Name != "SpawnRandomBase") + continue; + + System.Reflection.ParameterInfo[] ps = m.GetParameters(); + + if (ps.Length == 5 && ps[0].ParameterType == typeof(string) && ps[1].ParameterType == typeof(UnityEngine.Vector3) + && ps[2].ParameterType == typeof(int) && ps[3].ParameterType == typeof(int) && ps[4].ParameterType == typeof(bool)) + return true; + } + } + catch (Exception) + { + } + + return false; + } + + /// The plugin's RaidableType.Manual as an int, bound by name (the paid edition moved the ints, §11.4.2). + private static int RaidManualType(Plugin rb) + { + try + { + Type type = rb.GetType().GetNestedType("RaidableType", System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic); + + if (type != null && type.IsEnum) + return Convert.ToInt32(Enum.Parse(type, "Manual")); + } + catch (Exception) + { + } + + return 1; + } + + /// + /// Hello's integrations.raidableBases: the edition, the difficulties, whether the + /// paid call is there, CopyPaste, and the raids helper with its grid and profiles. + /// + private Dictionary RaidableBasesBody() + { + Plugin rb = RaidableBasesPlugin(); + Dictionary body = IntegrationBody(rb); + Plugin helperPlugin = RaidsHelperPlugin(); + var helper = new Dictionary { { "state", RaidsHelperState() } }; + + if (helperPlugin != null) + { + helper["version"] = helperPlugin.Version.ToString(); + + if (helperPlugin.Call("StateReason") is string reason) + helper["reason"] = reason; + } + + body["helper"] = helper; + + if (rb == null) + return body; + + body["premium"] = rb.Call("IsPremium") is bool premium && premium; + body["spawnCall"] = RaidSpawnCall(rb); + body["copyPaste"] = IntegrationBody(plugins.Find("CopyPaste")); + + var difficulties = new List(); + + foreach (KeyValuePair d in RaidDifficulties()) + difficulties.Add(new Dictionary { { "name", d.Key }, { "level", d.Value } }); + + body["difficulties"] = difficulties; + + if (RaidsHelperPatched()) + { + body["gridReady"] = helperPlugin.Call("GridReady") is bool ready && ready; + + if (helperPlugin.Call("Profiles") is List profiles) + body["profiles"] = profiles; + } + + return body; + } + + // ---- the hooks ---- + + private void OnRaidableBaseStarted(UnityEngine.Vector3 location, int level, bool allowPvp, string id, float a, float b, float loadTime, + ulong ownerId, BasePlayer owner, List raiders, List intruders, List entities, string baseName, + DateTime spawn, DateTime despawn, float protectionRadius, int lootRemaining) + { + MarkHook("OnRaidableBaseStarted"); + + // Its one-argument overload carries the plugin's own class; a framework that padded it + // into this signature hands over no position and no name. + if (location == UnityEngine.Vector3.zero && baseName == null) + return; + + string ours = RaidTagAt(location); + LiveRaid live = NearestRaid(location); + + if (live == null) + { + live = new LiveRaid(); + _raids.Add(live); + } + + live.At = location; + live.Level = level; + live.Difficulty = RaidDifficultyName(level); + live.Pvp = allowPvp; + live.Base = baseName; + live.DespawnAtMs = RaidMs(despawn); + live.Id = ours; + + WorldEntry entry = null; + + if (ours != null && _world.TryGetValue(ours, out entry)) + { + entry.X = location.x; + entry.Y = location.y; + entry.Z = location.z; + entry.Started = true; + entry.Name = baseName; + entry.Difficulty = live.Difficulty; + SaveWorld(); + } + + if (!WorldEventsLive()) + return; + + Dictionary frame = RaidFrame(RaidStartedKind, live, entry); + frame["radius"] = Math.Round(protectionRadius, 1); + + if (live.DespawnAtMs > 0) + frame["despawnAt"] = live.DespawnAtMs; + + EmitFrame(frame); + } + + private void OnRaidableBaseCompleted(UnityEngine.Vector3 location, int level, bool allowPvp, string id, float a, float b, float loadTime, + ulong ownerId, BasePlayer owner, List raiders, List intruders, List entities, string baseName, + DateTime spawn, DateTime despawn, float protectionRadius, int lootRemaining) + { + MarkHook("OnRaidableBaseCompleted"); + + var credited = new List(); + + if (raiders != null) + { + foreach (BasePlayer p in raiders) + { + if (p != null && !p.IsNpc && !credited.Contains(p)) + credited.Add(p); + } + } + + // The unload trap (§11.4.2): every live base "completes" with no owner and no raiders. + if (credited.Count == 0 && ownerId == 0UL) + return; + + // The plugin's own participants are its credit; a locked base completed by its owner + // alone, with nobody else listed, credits the owner. + if (credited.Count == 0 && owner != null && !owner.IsNpc) + credited.Add(owner); + + foreach (BasePlayer p in credited) + TallyFor(p).RaidsCompleted++; + + if (!WorldEventsLive()) + return; + + string ours = RaidTagAt(location); + LiveRaid live = NearestRaid(location) ?? new LiveRaid { At = location, Level = level, Difficulty = RaidDifficultyName(level), Pvp = allowPvp, Base = baseName, Id = ours }; + WorldEntry entry = null; + + if (ours != null) + _world.TryGetValue(ours, out entry); + + Dictionary frame = RaidFrame(RaidCompletedKind, live, entry); + var named = new List(); + + foreach (BasePlayer p in credited) + { + if (named.Count >= RaidRaidersMax) + break; + + named.Add(new Dictionary { { "steamId", SteamIdOf(p) }, { "name", p.displayName } }); + } + + frame["raiders"] = named; + frame["raiderCount"] = credited.Count; + + if (ownerId != 0UL) + { + frame["ownerId"] = ownerId.ToString(System.Globalization.CultureInfo.InvariantCulture); + + if (owner != null && !string.IsNullOrEmpty(owner.displayName)) + frame["ownerName"] = owner.displayName; + } + + EmitFrame(frame); + } + + private void OnRaidableBaseEnded(UnityEngine.Vector3 location, int level, bool allowPvp, string id, float a, float b, float loadTime, + ulong ownerId, BasePlayer owner, List raiders, List intruders, List entities, string baseName, + DateTime spawn, DateTime despawn, float protectionRadius, int lootRemaining) + { + MarkHook("OnRaidableBaseEnded"); + + LiveRaid live = NearestRaid(location); + + if (live != null) + _raids.Remove(live); + + string ours = RaidTagAt(location) ?? live?.Id; + WorldEntry entry; + + // A run's base the plugin ended first, on its own timer or because it was raided: the + // run's resource is marked expired (D170). One the run's teardown despawned is already + // off the record. + if (ours != null && _world.TryGetValue(ours, out entry)) + { + _world.Remove(ours); + SaveWorld(); + EmitRaidExpired(entry, null); + } + + if (!WorldEventsLive()) + return; + + LiveRaid told = live ?? new LiveRaid { At = location, Level = level, Difficulty = RaidDifficultyName(level), Pvp = allowPvp, Base = baseName, Id = ours }; + EmitFrame(RaidFrame(RaidEndedKind, told, null)); + } + + private void OnRaidableBasePurchased(string userId, UnityEngine.Vector3 location, string grid, int level, bool allowPvp, string baseName, + DateTime spawn, DateTime despawn) + { + MarkHook("OnRaidableBasePurchased"); + EmitRaidOwner(RaidPurchasedKind, userId, location, level, allowPvp, baseName); + } + + private void OnRaidableBaseLocked(string userId, UnityEngine.Vector3 location, string grid, int level, bool allowPvp, float loadTime, + string baseName, DateTime spawn, DateTime despawn) + { + MarkHook("OnRaidableBaseLocked"); + EmitRaidOwner(RaidLockedKind, userId, location, level, allowPvp, baseName); + } + + /// A player bought a base, or an admin locked one to a player (paid edition, D390). + private void EmitRaidOwner(string kind, string userId, UnityEngine.Vector3 location, int level, bool allowPvp, string baseName) + { + if (!WorldEventsLive()) + return; + + var live = new LiveRaid { At = location, Level = level, Difficulty = RaidDifficultyName(level), Pvp = allowPvp, Base = baseName }; + Dictionary frame = RaidFrame(kind, live, null); + ulong steam; + + if (TryParseSteamId(userId, out steam)) + { + frame["steamId"] = steam.ToString(System.Globalization.CultureInfo.InvariantCulture); + BasePlayer who = BasePlayer.FindByID(steam) ?? BasePlayer.FindSleeping(steam); + + if (who != null && !string.IsNullOrEmpty(who.displayName)) + frame["name"] = who.displayName; + } + + EmitFrame(frame); + } + + /// A raid.* event's common fields, and the run's when the base is one. + private Dictionary RaidFrame(string kind, LiveRaid live, WorldEntry entry) + { + Dictionary frame = Frame(kind, "event"); + PutPosition(frame, live.At); + frame["level"] = live.Level; + frame["pvp"] = live.Pvp; + + if (!string.IsNullOrEmpty(live.Difficulty)) + frame["difficulty"] = live.Difficulty; + + if (!string.IsNullOrEmpty(live.Base)) + frame["base"] = live.Base; + + if (entry != null) + { + frame["id"] = entry.Id; + frame["runId"] = entry.RunId; + frame["key"] = entry.Key; + } + + return frame; + } + + private void EmitRaidExpired(WorldEntry e, string reason) + { + Dictionary frame = Frame("world.expired", "event"); + frame["id"] = e.Id; + frame["what"] = WorldKindRaid; + + if (!string.IsNullOrEmpty(e.RunId)) + frame["runId"] = e.RunId; + + if (!string.IsNullOrEmpty(reason)) + frame["reason"] = reason; + + EmitFrame(frame); + } + + /// A hook's DateTime as unix milliseconds; the plugin keeps local times. + private static long RaidMs(DateTime at) + { + if (at == default(DateTime)) + return 0L; + + return ToUnixMs(at.Kind == DateTimeKind.Utc ? at : at.ToUniversalTime()); + } + + private LiveRaid NearestRaid(UnityEngine.Vector3 at) + { + LiveRaid best = null; + float bestDistance = RaidReach; + + foreach (LiveRaid r in _raids) + { + float d = Distance2D(r.At, at); + + if (d <= bestDistance) + { + bestDistance = d; + best = r; + } + } + + return best; + } + + /// The run's id for the base at a spot, from the helper, or null. + private string RaidTagAt(UnityEngine.Vector3 at) + { + Plugin helper = RaidsHelperPlugin(); + return helper != null ? helper.Call("TagAt", at) as string : null; + } + + /// + /// Main thread. The live bases, found again from the plugin's public GetAllEvents: + /// a hotload of the bridge hears no starts. Its tuple's first four items and its base + /// name are read (the plugin warns that the shape may change; a shape this cannot read + /// leaves the set to the hooks). + /// + private void SeedRaids(string why) + { + _raids.Clear(); + Plugin rb = RaidableBasesPlugin(); + + if (rb == null) + return; + + try + { + if (!(rb.Call("GetAllEvents") is System.Collections.IEnumerable all)) + return; + + foreach (object row in all) + { + if (!(TupleItem(row, 1) is UnityEngine.Vector3 at)) + continue; + + int level = TupleItem(row, 3) is int l ? l : 0; + _raids.Add(new LiveRaid + { + At = at, + Level = level, + Difficulty = RaidDifficultyName(level), + Pvp = TupleItem(row, 4) is bool pvp && pvp, + Base = TupleItem(row, 14) as string, + DespawnAtMs = TupleItem(row, 16) is DateTime d ? RaidMs(d) : 0L, + Id = RaidTagAt(at) + }); + } + } + catch (Exception ex) + { + Puts($"could not read RaidableBases' live bases ({why}): {Flatten(ex.Message)}"); + } + + if (_raids.Count > 0) + Puts($"map: {_raids.Count} raid base(s) after {why}"); + } + + /// The map's raids rows (§20.12): every live base, and the run's own among them. + private List RaidRows() + { + var rows = new List(); + + foreach (LiveRaid r in _raids) + { + Dictionary row = At(r.At); + row["level"] = r.Level; + row["pvp"] = r.Pvp; + + if (!string.IsNullOrEmpty(r.Difficulty)) + row["difficulty"] = r.Difficulty; + + if (!string.IsNullOrEmpty(r.Base)) + row["base"] = r.Base; + + if (r.DespawnAtMs > 0) + row["despawnAt"] = r.DespawnAtMs; + + WorldEntry entry; + + if (r.Id != null && _world.TryGetValue(r.Id, out entry)) + { + row["runId"] = entry.RunId; + row["key"] = entry.Key; + } + + rows.Add(row); + } + + return rows; + } + + /// Main thread. RaidableBases unloaded: every base went with it, and every run's base still on its way. + private void RaidableBasesGone() + { + _raids.Clear(); + var gone = new List(); + + foreach (WorldEntry e in _world.Values) + { + if (e.Kind == WorldKindRaid) + gone.Add(e); + } + + if (gone.Count == 0) + return; + + foreach (WorldEntry e in gone) + { + _world.Remove(e.Id); + EmitRaidExpired(e, "raidablebases-unloaded"); + } + + SaveWorld(); + _lastWorldSummary = $"RaidableBases unloaded: {gone.Count} run base(s) went with it"; + Puts($"world: {_lastWorldSummary}"); + } + + // ---- a run's base in the world registry ---- + + /// Main thread. Despawns a run's base through the helper. Whether it was there to take. + private bool DespawnRaid(WorldEntry e) + { + Plugin helper = RaidsHelperPlugin(); + + if (helper == null) + return false; + + string done = helper.Call("Despawn", e.Id) as string; + return done == "removed" || done == "cancelled"; + } + + /// + /// Main thread. Whether a run's base is still out there. A base does not survive a + /// restart (RaidableBases despawns every one as it unloads), so one from another boot is + /// gone. Without the helper the bridge cannot ask, and "I cannot ask" is not "it is gone". + /// + private bool RaidHeld(WorldEntry e) + { + if (!string.IsNullOrEmpty(e.BootId) && e.BootId != _bootId) + return false; + + Plugin helper = RaidsHelperPlugin(); + + if (helper == null || RaidableBasesPlugin() == null) + return RaidableBasesPlugin() != null; + + string live = helper.Call("LiveState", e.Id) as string; + return live == "open" || live == "queued"; + } + + private void EnsureRaidTimer() + { + if (_raidTimer == null) + _raidTimer = timer.Every(30f, CheckRaidsPending); + } + + /// + /// Main thread, every 30 s while a run's base is on its way. One that has not opened + /// fifteen minutes after it was asked for never will (its paste failed, or it found no + /// room): the request is dropped and the run's resource expires, with the reason. + /// + private void CheckRaidsPending() + { + List due = null; + bool waiting = false; + long now = NowMs(); + + foreach (WorldEntry e in _world.Values) + { + if (e.Kind != WorldKindRaid || e.Started) + continue; + + waiting = true; + + if (now - e.TakenMs < RaidPendingMs) + continue; + + if (due == null) + due = new List(); + + due.Add(e); + } + + if (!waiting && _raidTimer != null) + { + _raidTimer.Destroy(); + _raidTimer = null; + return; + } + + if (due == null) + return; + + foreach (WorldEntry e in due) + { + _world.Remove(e.Id); + DespawnRaid(e); + EmitRaidExpired(e, "never-started"); + } + + SaveWorld(); + _lastWorldSummary = $"{due.Count} run raid base(s) never opened and were dropped"; + Puts($"world: {_lastWorldSummary}"); + } + + // ---- the spawn's keys ---- + + private string RaidStartsPath() + { + return Path.Combine(Interface.Oxide.DataDirectory, Name, "raid-starts.json"); + } + + private void SaveRaidStarts() + { + WriteDataFile(RaidStartsPath(), new List(_raidStarts.Values), "RaidableBases spawns"); + } + + private void LoadRaidStarts() + { + _raidStarts = new Dictionary(StringComparer.Ordinal); + List starts = ReadDataFile(RaidStartsPath(), "RaidableBases spawns"); + + if (starts != null) + foreach (RaidStart s in starts) + if (s != null && !string.IsNullOrEmpty(s.Key)) + _raidStarts[s.Key] = s; + + ForgetOldRaidStarts(); + + foreach (WorldEntry e in _world.Values) + { + if (e.Kind == WorldKindRaid && !e.Started) + { + EnsureRaidTimer(); + break; + } + } + } + + private void ForgetOldRaidStarts() + { + long now = NowMs(); + var stale = new List(); + + foreach (KeyValuePair kv in _raidStarts) + if (now >= kv.Value.AtMs + RaidStartMemoryMs) + stale.Add(kv.Key); + + foreach (string key in stale) + _raidStarts.Remove(key); + + if (stale.Count > 0) + SaveRaidStarts(); + } + + // ---- raid.spawn and raid.check ---- + + /// + /// Main thread. One base for a run's step (§20.12). With the raids helper it is the run's: + /// a world registry entry, despawned at teardown. Without it, the plugin's own call or + /// console command spawns one that despawns on its own timer. A key already answered is + /// answered again with its first spawn and spawns nothing. + /// + private void HandleRaidSpawn(Dictionary cmd, string reqId) + { + if (!WorldWritesAllowed(reqId)) + return; + + string key = StringField(cmd, "key"); + string runId = StringField(cmd, "runId"); + + if (string.IsNullOrEmpty(key) || key.Length > PaymentKeyMax || string.IsNullOrEmpty(runId)) + { + EmitRaidError(reqId, "malformed", "a spawn is made for a run, under a key of at most 128 characters", null); + return; + } + + ForgetOldRaidStarts(); + + RaidStart seen; + + if (_raidStarts.TryGetValue(key, out seen)) + { + EmitRaidQueued(reqId, seen, true, null); + return; + } + + Plugin rb = RaidableBasesPlugin(); + + if (rb == null) + { + EmitRaidError(reqId, "rb-missing", "RaidableBases is not loaded on this server", null); + return; + } + + if (plugins.Find("CopyPaste") == null) + { + EmitRaidError(reqId, "copypaste-missing", "CopyPaste is not loaded on this server, and RaidableBases pastes every base with it", null); + return; + } + + string difficulty = StringField(cmd, "difficulty"); + string profile = StringField(cmd, "profile"); + string baseName = StringField(cmd, "base"); + bool spot = !string.IsNullOrEmpty(StringField(cmd, "monument")) || DoubleField(cmd, "x").HasValue || DoubleField(cmd, "z").HasValue; + bool settings = cmd.ContainsKey("pvp") || cmd.ContainsKey("npcs") || cmd.ContainsKey("npcCount") || cmd.ContainsKey("despawnMinutes") || cmd.ContainsKey("lock"); + bool needsHelper = spot || settings || !string.IsNullOrEmpty(profile) || !string.IsNullOrEmpty(baseName); + + // The difficulty, by the plugin's own names (D386). + int? level = null; + + if (!string.IsNullOrEmpty(difficulty)) + { + foreach (KeyValuePair d in RaidDifficulties()) + { + if (string.Equals(d.Key, difficulty, StringComparison.OrdinalIgnoreCase)) + { + difficulty = d.Key; + level = d.Value; + break; + } + } + + if (!level.HasValue) + { + EmitRaidError(reqId, "bad-difficulty", $"RaidableBases has no difficulty '{difficulty}' on this server", null); + return; + } + } + + if (RaidsHelperPatched()) + { + SpawnWithHelper(cmd, reqId, key, runId, difficulty, level, profile, baseName, spot); + return; + } + + if (needsHelper) + { + string state = RaidsHelperState(); + EmitRaidError(reqId, state == "missing" ? "helper-needed" : "helper-unsupported", + state == "missing" + ? "a spot, a profile, a base or settings for one spawn need RunicGatewayRaids.cs beside the bridge, and it is not installed" + : $"RunicGatewayRaids.cs could not patch this RaidableBases ({state}), so a spot, a profile, a base or settings cannot be used", + null); + return; + } + + SpawnWithoutHelper(rb, reqId, key, difficulty, level); + } + + private void SpawnWithHelper(Dictionary cmd, string reqId, string key, string runId, string difficulty, int? level, + string profile, string baseName, bool spot) + { + Plugin helper = RaidsHelperPlugin(); + string id = $"{RaidIdPrefix}{NowMs()}-{++_raidSerial}"; + var req = new Dictionary { { "id", id } }; + + if (difficulty != null) + req["difficulty"] = difficulty; + + if (level.HasValue) + req["level"] = level.Value; + + if (!string.IsNullOrEmpty(profile)) + req["profile"] = profile; + + if (!string.IsNullOrEmpty(baseName)) + req["base"] = baseName; + + if (spot) + { + UnityEngine.Vector3 at; + string reason, refusal; + + // A spot is a world call's location; its height is the step's own (below), not `y`. + var where = new Dictionary(cmd); + where.Remove("y"); + + if (!ResolveWorldPoint(where, out at, out reason, out refusal)) + { + EmitRaidError(reqId, reason, refusal, null); + return; + } + + req["x"] = at.x; + req["z"] = at.z; + + double? adjust = DoubleField(cmd, "heightAdjust"); + double? height = DoubleField(cmd, "height"); + + if (adjust.HasValue && height.HasValue) + { + EmitRaidError(reqId, "malformed", "a spot takes a height adjustment or an exact height, not both", null); + return; + } + + if (height.HasValue) + req["height"] = height.Value; + else if (adjust.HasValue) + req["heightAdjust"] = adjust.Value; + } + + foreach (string flag in new[] { "pvp", "npcs", "lock" }) + { + bool value; + + if (cmd.ContainsKey(flag) && TryParseFlag(StringField(cmd, flag), out value)) + req[flag] = value; + } + + int npcCount = IntField(cmd, "npcCount"); + int despawn = IntField(cmd, "despawnMinutes"); + + if (cmd.ContainsKey("npcCount")) + { + if (npcCount < 0 || npcCount > WorldHardMaxNpcs) + { + EmitRaidError(reqId, "out-of-range", $"a raid base takes 0 to {WorldHardMaxNpcs} NPCs, not {npcCount}", null); + return; + } + + req["npcCount"] = npcCount; + } + + if (cmd.ContainsKey("despawnMinutes")) + { + if (despawn < 1 || despawn > 24 * 60) + { + EmitRaidError(reqId, "out-of-range", $"a raid base despawns after 1 to 1440 minutes, not {despawn}", null); + return; + } + + req["despawnMinutes"] = despawn; + } + + var answer = helper.Call("Spawn", req) as Dictionary; + + if (answer == null) + { + EmitRaidError(reqId, "refused", "the raids helper did not answer", null); + return; + } + + List warnings = answer.TryGetValue("warnings", out object w) ? w as List : null; + + if (!(answer.TryGetValue("ok", out object ok) && ok is bool done && done)) + { + EmitRaidError(reqId, StringField(answer, "reason") ?? "refused", StringField(answer, "message") ?? "the raids helper refused", warnings); + return; + } + + double? x = DoubleField(answer, "x"); + double? z = DoubleField(answer, "z"); + + var start = new RaidStart + { + Key = key, + Id = id, + Route = StringField(answer, "route") == "spot" ? "spot" : "pick", + Difficulty = StringField(answer, "difficulty"), + Level = IntField(answer, "level"), + Profile = StringField(answer, "profile"), + Base = StringField(answer, "base"), + X = x.HasValue ? (float)x.Value : 0f, + Z = z.HasValue ? (float)z.Value : 0f, + Spot = x.HasValue, + AtMs = NowMs() + }; + + _world[id] = new WorldEntry + { + Id = id, + Kind = WorldKindRaid, + RunId = runId, + Key = key, + X = start.X, + Y = 0f, + Z = start.Z, + Name = start.Base, + Difficulty = start.Difficulty, + BootId = _bootId, + WipeId = _wipeId, + TakenMs = NowMs() + }; + + _raidStarts[key] = start; + SaveWorld(); + SaveRaidStarts(); + EnsureRaidTimer(); + + _lastWorldSummary = $"asked RaidableBases for a {start.Difficulty ?? "?"} base ({start.Route}) for run {runId} as {id}"; + Puts($"world: {_lastWorldSummary}"); + EmitRaidQueued(reqId, start, false, warnings); + } + + /// No helper: the paid edition's own call at a difficulty, else the free edition's console command. Neither base is the run's. + private void SpawnWithoutHelper(Plugin rb, string reqId, string key, string difficulty, int? level) + { + var start = new RaidStart { Key = key, Difficulty = difficulty, Level = level ?? 0, AtMs = NowMs() }; + + if (RaidSpawnCall(rb)) + { + if (!level.HasValue) + { + List> all = RaidDifficulties(); + + if (all.Count == 0) + { + EmitRaidError(reqId, "bad-difficulty", "RaidableBases lists no difficulty on this server", null); + return; + } + + KeyValuePair picked = all[UnityEngine.Random.Range(0, all.Count)]; + start.Difficulty = picked.Key; + start.Level = picked.Value; + } + + object result = rb.Call("SpawnRandomBase", null, UnityEngine.Vector3.zero, start.Level, RaidManualType(rb), true); + + if (!(result is bool queued) || !queued) + { + EmitRaidError(reqId, "refused", $"RaidableBases did not queue a base: {(result as string ?? "no reason given")}", null); + return; + } + + start.Route = "plugin"; + } + else + { + if (!_consolePlugins) + { + EmitRaidError(reqId, "console-unavailable", + "this framework does not run another plugin's console command from code, so this edition of RaidableBases cannot be asked for a base without RunicGatewayRaids.cs (D363, Rust-Plugins#34)", + null); + return; + } + + try + { + ConsoleSystem.Run(ConsoleSystem.Option.Server.Quiet(), RbConsoleCommand); + } + catch (Exception ex) + { + EmitRaidError(reqId, "refused", $"RaidableBases' console command threw: {Flatten(ex.Message)}", null); + return; + } + + start.Route = "console"; + start.Difficulty = null; + } + + _raidStarts[key] = start; + SaveRaidStarts(); + + _lastWorldSummary = $"asked RaidableBases for a base by its {start.Route} (not the run's: it despawns on its own timer)"; + Puts($"world: {_lastWorldSummary}"); + EmitRaidQueued(reqId, start, false, null); + } + + /// Main thread. A spot's checks, spawning nothing (D208): the editor's advice. + private void HandleRaidCheck(Dictionary cmd, string reqId) + { + if (!WorldReady(reqId)) + return; + + if (RaidableBasesPlugin() == null) + { + EmitRaidError(reqId, "rb-missing", "RaidableBases is not loaded on this server", null); + return; + } + + if (!RaidsHelperPatched()) + { + EmitRaidError(reqId, RaidsHelperState() == "missing" ? "helper-needed" : "helper-unsupported", + "checking a spot needs RunicGatewayRaids.cs beside the bridge, patched", null); + return; + } + + UnityEngine.Vector3 at; + string reason, refusal; + var where = new Dictionary(cmd); + where.Remove("y"); + + if (!ResolveWorldPoint(where, out at, out reason, out refusal)) + { + EmitRaidError(reqId, reason, refusal, null); + return; + } + + var req = new Dictionary { { "x", at.x }, { "z", at.z } }; + + foreach (string field in new[] { "difficulty", "profile", "base" }) + { + string value = StringField(cmd, field); + + if (!string.IsNullOrEmpty(value)) + req[field] = value; + } + + var answer = RaidsHelperPlugin().Call("Check", req) as Dictionary; + Dictionary reply = Frame("raid.checked", "reply"); + + if (answer == null) + { + reply["ok"] = false; + reply["reason"] = "refused"; + reply["message"] = "the raids helper did not answer"; + } + else + { + reply["ok"] = answer.TryGetValue("ok", out object ok) && ok is bool fine && fine; + reply["warnings"] = answer.TryGetValue("warnings", out object w) && w is List list ? list : new List(); + + if (StringField(answer, "reason") != null) + reply["reason"] = StringField(answer, "reason"); + + if (StringField(answer, "message") != null) + reply["message"] = StringField(answer, "message"); + + if (StringField(answer, "base") != null) + reply["base"] = StringField(answer, "base"); + } + + PutPosition(reply, at); + + if (reqId != null) + reply["reqId"] = reqId; + + EmitFrame(reply); + } + + private void EmitRaidQueued(string reqId, RaidStart start, bool repeat, List warnings) + { + Dictionary frame = Frame("raid.queued", "reply"); + frame["key"] = start.Key; + frame["route"] = start.Route; + frame["repeat"] = repeat; + + // The run's base, despawned at teardown, only by the helper's routes (D204). + frame["despawns"] = start.Id != null ? "teardown" : "own-timer"; + + if (start.Id != null) + frame["id"] = start.Id; + + if (!string.IsNullOrEmpty(start.Difficulty)) + { + frame["difficulty"] = start.Difficulty; + frame["level"] = start.Level; + } + + if (!string.IsNullOrEmpty(start.Profile)) + frame["profile"] = start.Profile; + + if (!string.IsNullOrEmpty(start.Base)) + frame["base"] = start.Base; + + if (start.Spot) + PutPosition(frame, new UnityEngine.Vector3(start.X, 0f, start.Z)); + + frame["warnings"] = warnings ?? new List(); + + if (reqId != null) + frame["reqId"] = reqId; + + EmitFrame(frame); + } + + private void EmitRaidError(string reqId, string reason, string message, List warnings) + { + Dictionary frame = Frame("raid.error", "reply"); + frame["reason"] = reason; + frame["message"] = message; + + if (warnings != null && warnings.Count > 0) + frame["warnings"] = warnings; + + if (reqId != null) + frame["reqId"] = reqId; + + _lastWorldSummary = $"RaidableBases spawn refused ({reason}): {message}"; + EmitFrame(frame); + } + // ---- ZLevels Remastered (PLAN_REDESIGNS §14.3.6, D369, D374) ---- // // Hello says whether it is loaded, and that is all. No level is read or sent: the @@ -11024,9 +12589,12 @@ namespace Oxide.Plugins // Not on record. That is either something a wipe or a prune already took — // gone, which is a success — or something this bridge never made. Only a // thing that is actually THERE can be the second, and only that is refused. - bool exists = id.StartsWith("rg-", StringComparison.Ordinal) - ? zm != null && zm.Call("CheckZoneID", id) != null - : FindEntity(id) != null; + // A raid base's id is this bridge's own (§20.12), so one off the record is gone. + bool exists = id.StartsWith(RaidIdPrefix, StringComparison.Ordinal) + ? false + : id.StartsWith("rg-", StringComparison.Ordinal) + ? zm != null && zm.Call("CheckZoneID", id) != null + : FindEntity(id) != null; (exists ? refused : gone).Add(id); } @@ -11064,6 +12632,13 @@ namespace Oxide.Plugins _zoneQuietUntil.Remove(e.Id); } + else if (e.Kind == WorldKindRaid) + { + // Off the record BEFORE the despawn: its `Despawn()` raises the plugin's hooks, + // and an ending still on the record would be told as an expiry (§20.12). + _world.Remove(e.Id); + wasThere = DespawnRaid(e); + } else { wasThere = e.Kind == WorldKindNpc ? KillWorldNpc(e) : KillEntity(e.Id); @@ -11118,6 +12693,8 @@ namespace Oxide.Plugins if (e.Kind == WorldKindZone) held = zm == null || zm.Call("CheckZoneID", e.Id) != null; + else if (e.Kind == WorldKindRaid) + held = RaidHeld(e); else held = FindEntity(e.Id) != null; @@ -11177,6 +12754,18 @@ namespace Oxide.Plugins row["remainingMs"] = Math.Max(0L, e.DeadlineMs - NowMs()); } + if (e.Kind == WorldKindRaid) + { + // The base's own name and its difficulty, both the plugin's (D386). + if (!string.IsNullOrEmpty(e.Name)) + row["name"] = e.Name; + + if (!string.IsNullOrEmpty(e.Difficulty)) + row["difficulty"] = e.Difficulty; + + row["started"] = e.Started; + } + return row; } @@ -11412,7 +13001,7 @@ namespace Oxide.Plugins [ConsoleCommand("rg.world")] private void CmdWorld(ConsoleSystem.Arg arg) { - int zones = 0, crates = 0, npcs = 0; + int zones = 0, crates = 0, npcs = 0, raidBases = 0; // Guarded Crate's `stop all` ends events without a hook; count what is really running. SeedGuardedCrates(null); @@ -11423,10 +13012,14 @@ namespace Oxide.Plugins zones++; else if (e.Kind == WorldKindNpc) npcs++; + else if (e.Kind == WorldKindRaid) + raidBases++; else crates++; } + Plugin raidHelper = RaidsHelperPlugin(); + arg.ReplyWith( $"protocol={ProtocolVersion} eventsEnabled={(_config != null && _config.EventsEnabled)} " + $"zones={zones} crates={crates} npcs={npcs} zoneManager={(ZoneManagerPlugin() != null)} " + @@ -11436,6 +13029,9 @@ namespace Oxide.Plugins $"guardedCrate={(GuardedCratePlugin() != null)} npcSpawn={(NpcSpawnPlugin() != null)} guarded={_guardedCrates.Count} gcPlaces={_gcPlacements.Count} " + $"gcEvents={(GuardedCrateEventNames() != null ? string.Join("|", GuardedCrateEventNames().ToArray()) : "none")} gcCommand={GuardedCrateCommand()} gcListReadable={_gcListReadable} " + $"zLevels={(ZLevelsPlugin() != null ? ZLevelsPlugin().Version.ToString() : "absent")} " + + $"raidableBases={(RaidableBasesPlugin() != null ? RaidableBasesPlugin().Version.ToString() : "absent")} raidsHelper={RaidsHelperState()} " + + $"raidGrid={(raidHelper != null && raidHelper.Call("GridReady") is bool gridReady ? (gridReady ? "ready" : "building") : "unknown")} " + + $"raids={_raids.Count} raidBases={raidBases} " + $"wipeId={(_wipeId ?? "none")} calls={_worldCalls.Count} consolePlugins={_consolePlugins} " + $"commandKeys={_commandRecords.Count} commandsRunning={_commandJobs.Count} " + $"credits={_vehicleCredit.Count} chinookCrates={_chinookCrates.Count} said={_worldEventsSaid} " + @@ -15053,7 +16649,9 @@ namespace Oxide.Plugins // Protocol 14 (§14.3.5): whether a step may place a guarded crate here, and which. { "guardedCrate", GuardedCrateBody() }, // Protocol 14 (§14.3.6): whether its XP boosts are here for a step to lend. - { "zLevels", IntegrationBody(ZLevelsPlugin()) } + { "zLevels", IntegrationBody(ZLevelsPlugin()) }, + // Protocol 14 (§20.12): its edition, its difficulties, the raids helper and its profiles. + { "raidableBases", RaidableBasesBody() } }; } @@ -16314,6 +17912,10 @@ namespace Oxide.Plugins { Dictionary row; + // A run's raid base is drawn on the raids layer, with its run (§20.12). + if (w.Kind == WorldKindRaid) + continue; + if (w.Kind == WorldKindZone) { row = At(new UnityEngine.Vector3(w.X, 0f, w.Z)); @@ -16465,6 +18067,9 @@ namespace Oxide.Plugins reply["players"] = players; reply["bases"] = bases; reply["pvp"] = PvpZoneRows(); + + // RaidableBases' live bases (§20.12): a layer of their own with its own audience (D114). + reply["raids"] = RaidRows(); reply["tookMs"] = clock.ElapsedMilliseconds; if (sleepersTruncated) diff --git a/overlay/oxide/plugins/RunicGatewayRaids.cs b/overlay/oxide/plugins/RunicGatewayRaids.cs new file mode 100644 index 0000000..998eaa5 --- /dev/null +++ b/overlay/oxide/plugins/RunicGatewayRaids.cs @@ -0,0 +1,1697 @@ +// Runic Gateway Raids — a helper for RaidableBases, shipped beside the Runic Gateway bridge. +// Copyright (C) Runic Gateway contributors. GPL-3.0-or-later. +// +// WHAT IT IS FOR (docs/modules/rust/PLAN_REDESIGNS.md §11.4, D168, D204–D208, D387–D389): +// +// nivex's RaidableBases pastes a CopyPaste building, stocks it and guards it, and its public API +// spawns nothing and despawns nothing (§11.4.1). An event's step wants more than the plugin offers +// another plugin: the base the run made and no other, despawned at the run's end; settings for that +// one spawn; the lock to the first attacker for that one base; and a spot an admin chose. So this +// helper reaches RaidableBases' internals, by reflection and three Harmony patches, which is the +// org lead's decision (D204–D207) and the reason it lives here and not in the bridge (D168): +// +// * **A run's base is tagged at birth.** A prefix on the spawn queue's `Add(RandomBase)` sees the +// request this helper's own call made (the plugin picks the spot), and a postfix on +// `OpenEvent(RandomBase)` sees the base that request became. So the base is the run's by +// identity, never by matching a name or a position against the operator's own bases (§11.4.4). +// * **Settings for one spawn go on a copy of its profile** (see `CopyProfile`): PvP, NPCs and +// their count, despawn minutes, and at a spot its height. The profile every other base shares +// is never touched — the plugin's own `Clone()` is shallow and leaked a height into it on the +// rig (§11.4.4). **PvP is set on the base itself after `OpenEvent`** too, because the plugin's +// config switches ("Convert PVE To PVP") are applied inside it and win over a profile (§11.4.2). +// * **The lock is per event (D206).** A prefix on each base's `BypassUseOwners()` answers from the +// run's own setting for a tagged base; every other base follows the plugin's config. +// * **A spot is checked, then pasted (D207, D208, D388, D389).** This helper's own look for players, +// buildings, deployables and raid bases, then the plugin's own area check with its reason, then +// the ocean level against the ground. Rocks and cliffs are warnings; the rest refuse. The base is +// built the way the plugin's "spawn where I look" builds one, from numbers instead of a view. +// * **One base is despawned through its own `Despawn()`**, which raises the plugin's usual hooks. +// * **Profiles reload under watch.** The site edits RaidableBases' profile files (D205), and the +// bridge reloads them through here and asks afterwards whether each loaded. +// +// Names are the operator's: every difficulty, profile and base name is read from the installed +// plugin, never written here (D386). The plugin's own enum is bound by name, since the paid edition +// inserted `Purchased` and moved the ints (§11.4.2). Both editions are served, free and paid, by +// what each has (D348): the paid edition's own `SpawnRandomBase(string, Vector3, int, int, bool)` +// for the plugin's pick, the free edition's private typed one otherwise. +// +// It is optional. Without it the bridge still reports bases on the feed and the map, and a step +// still spawns one where the plugin can be asked to (the paid call, or the free edition's console +// command), but nothing despawns it early and there are no settings, no lock and no spot; the site +// says so. A RaidableBases reload drops the patches with the old assembly, and this re-patches. +// `rgr.status` in the server console prints the state. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Reflection; +using HarmonyLib; +using Newtonsoft.Json; +using Oxide.Core; +using Oxide.Core.Plugins; +using UnityEngine; + +namespace Oxide.Plugins +{ + [Info("Runic Gateway Raids", "Runic Gateway", "0.1.0")] + [Description("Helper for Runic Gateway: RaidableBases spawns, overrides, locks and despawns one event's base, and reloads its profiles under watch.")] + public class RunicGatewayRaids : RustPlugin + { + private const string HarmonyId = "runicgateway.raids"; + + private const BindingFlags Any = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic; + + /// The radius a spot is checked at when the profile gives none: the plugin's own `rbe` uses 50 m. + private const float DefaultSpotRadius = 50f; + + /// A spot this far under the ocean surface is "in water" (§11.4.2: the water map reads −500 on the rigs). + private const float WaterMargin = 0.5f; + + /// How long an ended base's tag is still answered by : its last hooks follow its despawn. + private const float EndedTagSeconds = 120f; + + /// The instance the static patches report to. Null while unloaded. + private static RunicGatewayRaids _instance; + + private Harmony _harmony; + private readonly List _patched = new List(); + + /// patched, unsupported (RaidableBases is here but not the shape this reaches), or no-raidablebases. + private string _state = "no-raidablebases"; + private string _reason; + + // ── What one RaidableBases load is made of. A reload is a new assembly, so all of it is + // looked up again then. ───────────────────────────────────────────────────────────── + + private Plugin _rb; + private Type _rbType; + private Type _raidableBase; + private Type _randomBase; + private Type _spawnsType; + private Type _raidableType; + private object _manual; + private int _manualMask; + + private MethodInfo _openEvent; + private MethodInfo _queueAdd; + private MethodInfo _bypass; + private MethodInfo _despawn; + private MethodInfo _gridLoading; + private MethodInfo _pasteBuilding; + private MethodInfo _getBuilding; + private MethodInfo _getPasteData; + private MethodInfo _parseListed; + private MethodInfo _fileExists; + private MethodInfo _reloadProfiles; + private MethodInfo _getDistance; + private MethodInfo _areaSafe; + + /// Paid: SpawnRandomBase(string, Vector3, int, int, bool). Null on the free edition. + private MethodInfo _spawnApi; + + /// Free: SpawnRandomBase(RaidableType, string, bool, BasePlayer, IPlayer, bool). Null on the paid edition. + private MethodInfo _spawnTyped; + + private ConstructorInfo _spawnsCtor; + + // ── The run's bases ─────────────────────────────────────────────────── + + /// One base a run asked for: its settings, and what it became. + private sealed class Tag + { + public string Id; + public bool? Pvp; + public bool? Npcs; + public int? NpcCount; + public int? DespawnMinutes; + public bool? Lock; + public string BaseName; + public string Profile; + + /// The plugin's request object, until OpenEvent turns it into a base. + public object Request; + + /// The base, once open. + public object Raid; + + public Vector3 Location; + public bool Cancelled; + public float EndedAt = -1f; + } + + /// Requests this helper made, by the plugin's request object. Main thread. + private static readonly Dictionary Requested = new Dictionary(); + + /// Open bases a run owns, by the base. Read by the lock patch. + private static readonly Dictionary Tagged = new Dictionary(); + + private readonly Dictionary _byId = new Dictionary(StringComparer.Ordinal); + + /// Set for the length of this helper's own call into the plugin's pick, for the queue patch. + private static Tag _capturing; + + /// Profile reloads this helper finished, for the bridge's watch. + private int _reloadsFinished; + + private bool _reloading; + + // ── Lifecycle ───────────────────────────────────────────────────────── + + private void OnServerInitialized() + { + _instance = this; + Patch(); + } + + private void Unload() + { + Unpatch(); + ForgetAll(); + _instance = null; + } + + private void OnPluginLoaded(Plugin plugin) + { + if (plugin != null && plugin.Name == "RaidableBases") + { + Unpatch(); + ForgetAll(); + Patch(); + } + } + + private void OnPluginUnloaded(Plugin plugin) + { + if (plugin != null && plugin.Name == "RaidableBases") + { + // Its unload despawned every base; nothing tagged is left in the world. + Unpatch(); + ForgetAll(); + _rb = null; + _state = "no-raidablebases"; + _reason = null; + } + } + + private void ForgetAll() + { + Requested.Clear(); + Tagged.Clear(); + _byId.Clear(); + _capturing = null; + _reloading = false; + } + + // ── The API the bridge calls ───────────────────────────────────────── + + /// patched, unsupported or no-raidablebases. + private string State() => _state; + + /// Why the state is not patched, or null. + private string StateReason() => _reason; + + /// The helper's own version, for hello. + private string HelperVersion() => Version.ToString(); + + /// Whether the plugin's spawn grid is built. Until it is, the plugin refuses every spawn and every profile reload. + private bool GridReady() + { + if (_state != "patched") + return false; + + try + { + return !(bool)_gridLoading.Invoke(_rb, null); + } + catch (Exception) + { + return false; + } + } + + /// + /// Every profile the plugin loaded, as hello carries them: name, difficulty, + /// level, enabled, pvp, bases (only those whose CopyPaste file + /// exists), and seabed and float (whether it is built for water, D389). + /// + private List Profiles() + { + var rows = new List(); + + if (_state != "patched") + return rows; + + foreach (KeyValuePair kv in ProfileTable()) + { + object options = F(kv.Value, "Options"); + + if (options == null) + continue; + + var row = new Dictionary + { + { "name", kv.Key }, + { "difficulty", ModeOf(options) }, + { "level", LevelOf(options) }, + { "enabled", F(options, "Enabled") is bool on && on }, + { "pvp", F(options, "AllowPVP") is bool pvp && pvp }, + { "bases", BasesOf(kv.Key, options) } + }; + + object water = F(options, "Water"); + row["seabed"] = F(water, "Seabed") is float seabed && seabed > 0f; + row["float"] = F(water, "AllowSubmerged") is bool floats && floats; + rows.Add(row); + } + + return rows; + } + + /// + /// Main thread. Asks the plugin for one base for a run. carries + /// id (the bridge's), difficulty (a name) and level, profile, + /// base, and for a spot x, z with heightAdjust or height; + /// and the settings pvp, npcs, npcCount, despawnMinutes, + /// lock. Answers ok with what was asked for, or reason and message. + /// + private Dictionary Spawn(Dictionary req) + { + if (_state != "patched") + return Refuse("helper-unsupported", $"the RaidableBases helper is {_state}{(_reason != null ? $" ({_reason})" : "")}"); + + string id = Text(req, "id"); + + if (string.IsNullOrEmpty(id) || _byId.ContainsKey(id)) + return Refuse("malformed", "a spawn needs an id this helper has not seen"); + + if (!GridReady()) + return Refuse("rb-not-ready", "RaidableBases is still building its spawn grid, and refuses every spawn until it has"); + + if (Flag(_rb, "IsSpawnerBusy") || Flag(_rb, "IsLoaderBusy")) + return Refuse("rb-busy", "RaidableBases is pasting another base; it takes one at a time"); + + var tag = new Tag + { + Id = id, + Pvp = BoolOf(req, "pvp"), + Npcs = BoolOf(req, "npcs"), + NpcCount = IntOf(req, "npcCount"), + DespawnMinutes = IntOf(req, "despawnMinutes"), + Lock = BoolOf(req, "lock") + }; + + string refusal; + string reason; + Choice choice = Choose(req, Number(req, "x").HasValue, out reason, out refusal); + + if (choice == null) + return Refuse(reason, refusal); + + tag.BaseName = choice.BaseName; + tag.Profile = choice.ProfileName; + + Dictionary answer = Number(req, "x").HasValue + ? SpawnAt(req, tag, choice) + : SpawnPicked(tag, choice); + + if (answer.TryGetValue("ok", out object ok) && ok is bool done && done) + { + _byId[id] = tag; + answer["difficulty"] = choice.Difficulty; + answer["level"] = choice.Level; + + if (choice.ProfileName != null) + answer["profile"] = choice.ProfileName; + + if (choice.BaseName != null) + answer["base"] = choice.BaseName; + } + + return answer; + } + + /// + /// Main thread. The checks a spot must pass (D208, D388, D389), without spawning: + /// x, z, and the difficulty, profile or base whose + /// radius and water settings apply. Answers ok, the refusal's reason and + /// message, and warnings. + /// + private Dictionary Check(Dictionary req) + { + if (_state != "patched") + return Refuse("helper-unsupported", $"the RaidableBases helper is {_state}"); + + string reason; + string refusal; + Choice choice = Choose(req, true, out reason, out refusal); + + if (choice == null) + return Refuse(reason, refusal); + + var warnings = new List(); + Vector3 at; + + if (!CheckSpot(req, choice, warnings, out at, out reason, out refusal)) + { + Dictionary no = Refuse(reason, refusal); + no["warnings"] = warnings; + return no; + } + + return new Dictionary { { "ok", true }, { "warnings", warnings }, { "base", choice.BaseName } }; + } + + /// + /// Main thread. Takes one base away: removed (it was open and its own Despawn() + /// ran), cancelled (it was still on its way and will be taken away the moment it + /// opens), or gone. + /// + private string Despawn(string id) + { + Tag tag; + + if (string.IsNullOrEmpty(id) || !_byId.TryGetValue(id, out tag)) + return "gone"; + + if (tag.Raid != null && IsOpen(tag.Raid)) + { + try + { + _despawn.Invoke(tag.Raid, null); + return "removed"; + } + catch (Exception ex) + { + PrintWarning($"could not despawn base {id}: {(ex.InnerException ?? ex).Message}"); + return "gone"; + } + } + + if (tag.Raid == null && !tag.Cancelled && tag.EndedAt < 0f) + { + tag.Cancelled = true; + Dequeue(tag.Request); + return "cancelled"; + } + + return "gone"; + } + + /// open, queued (asked for and not open yet) or gone. + private string LiveState(string id) + { + Tag tag; + + if (string.IsNullOrEmpty(id) || !_byId.TryGetValue(id, out tag) || tag.Cancelled) + return "gone"; + + if (tag.Raid != null) + return IsOpen(tag.Raid) ? "open" : "gone"; + + return tag.EndedAt < 0f ? "queued" : "gone"; + } + + /// The id of the run's base at this spot, or null: how the bridge joins a lifecycle hook to a run. + private string TagAt(Vector3 at) + { + foreach (Tag tag in _byId.Values) + { + if (tag.Raid == null) + continue; + + if (tag.EndedAt >= 0f && Time.realtimeSinceStartup - tag.EndedAt > EndedTagSeconds) + continue; + + Vector3 where = tag.Raid is UnityEngine.Object unity && unity == null ? tag.Location : (F(tag.Raid, "Location") is Vector3 now ? now : tag.Location); + + if (Distance2D(where, at) <= 2f) + return tag.Id; + } + + return null; + } + + /// + /// Main thread. Has the plugin reload its profiles, as rb.reloadprofiles does. + /// False when it would refuse: its grid is still building, or a reload is running. + /// + private bool ReloadProfiles() + { + if (_state != "patched" || _reloading || !GridReady()) + return false; + + IEnumerator inner; + + try + { + // No player. Typed in the console, `rb.reloadprofiles` also reloads the per-base loot + // tables and logs a line; with no player the plugin skips both, and a profile edit + // touches neither (they are their own files, `Base_Loot/`). + inner = _reloadProfiles.Invoke(_rb, new object[] { null }) as IEnumerator; + } + catch (Exception ex) + { + PrintWarning($"could not start RaidableBases' profile reload: {(ex.InnerException ?? ex).Message}"); + return false; + } + + if (inner == null) + return false; + + _reloading = true; + ServerMgr.Instance.StartCoroutine(Watch(inner)); + return true; + } + + /// How many profile reloads have finished since this helper loaded. + private int ReloadsFinished() => _reloadsFinished; + + /// loaded, error (the plugin could not read it) or absent. + private string ProfileState(string name) + { + if (_state != "patched" || string.IsNullOrEmpty(name)) + return "absent"; + + foreach (KeyValuePair kv in ProfileTable()) + { + if (string.Equals(kv.Key, name, StringComparison.OrdinalIgnoreCase)) + return "loaded"; + } + + if (F(_rb, "profileErrors") is IEnumerable errors) + { + foreach (object file in errors) + { + string text = file as string; + + if (text != null && System.IO.Path.GetFileNameWithoutExtension(text).Equals(name, StringComparison.OrdinalIgnoreCase)) + return "error"; + } + } + + return "absent"; + } + + private IEnumerator Watch(IEnumerator inner) + { + try + { + yield return inner; + } + finally + { + _reloading = false; + _reloadsFinished++; + } + } + + // ── Choosing a base ────────────────────────────────────────────────── + + private sealed class Choice + { + public string BaseName; + public string ProfileName; + public object Profile; + public string Difficulty; + public int Level; + } + + /// + /// Main thread. Which base, profile and difficulty a request means, from the plugin's own + /// table. A base names its profile; a profile picks one of its bases at random; a + /// difficulty alone picks at random among its enabled profiles when a concrete base is + /// needed (a spot), and is left to the plugin otherwise. Null with a reason when the + /// names do not fit together or do not exist. + /// + private Choice Choose(Dictionary req, bool needBase, out string reason, out string refusal) + { + reason = null; + refusal = null; + + string wantBase = Text(req, "base"); + string wantProfile = Text(req, "profile"); + string wantDifficulty = Text(req, "difficulty"); + int? wantLevel = IntOf(req, "level"); + List> table = ProfileTable(); + + if (!string.IsNullOrEmpty(wantBase)) + { + foreach (KeyValuePair kv in table) + { + object options = F(kv.Value, "Options"); + + foreach (string b in BasesOf(kv.Key, options)) + { + if (!b.Equals(wantBase, StringComparison.OrdinalIgnoreCase)) + continue; + + if (!string.IsNullOrEmpty(wantProfile) && !kv.Key.Equals(wantProfile, StringComparison.OrdinalIgnoreCase)) + { + reason = "bad-base"; + refusal = $"base '{wantBase}' belongs to profile '{kv.Key}', not '{wantProfile}'"; + return null; + } + + Choice found = ChoiceOf(kv.Key, kv.Value, options, b); + + if (!FitsDifficulty(found, wantDifficulty, wantLevel, out refusal)) + { + reason = "bad-difficulty"; + return null; + } + + return found; + } + } + + reason = "bad-base"; + refusal = $"RaidableBases has no base '{wantBase}' with a CopyPaste file"; + return null; + } + + if (!string.IsNullOrEmpty(wantProfile)) + { + foreach (KeyValuePair kv in table) + { + if (!kv.Key.Equals(wantProfile, StringComparison.OrdinalIgnoreCase)) + continue; + + object options = F(kv.Value, "Options"); + List bases = BasesOf(kv.Key, options); + + if (bases.Count == 0) + { + reason = "bad-profile"; + refusal = $"profile '{kv.Key}' has no base with a CopyPaste file"; + return null; + } + + Choice found = ChoiceOf(kv.Key, kv.Value, options, bases[UnityEngine.Random.Range(0, bases.Count)]); + + if (!FitsDifficulty(found, wantDifficulty, wantLevel, out refusal)) + { + reason = "bad-difficulty"; + return null; + } + + return found; + } + + reason = "bad-profile"; + refusal = $"RaidableBases has no profile '{wantProfile}'"; + return null; + } + + // A difficulty, or nothing at all. + var candidates = new List(); + + foreach (KeyValuePair kv in table) + { + object options = F(kv.Value, "Options"); + + if (!(F(options, "Enabled") is bool on) || !on) + continue; + + Choice shape = ChoiceOf(kv.Key, kv.Value, options, null); + string ignored; + + if (!FitsDifficulty(shape, wantDifficulty, wantLevel, out ignored)) + continue; + + foreach (string b in BasesOf(kv.Key, options)) + candidates.Add(ChoiceOf(kv.Key, kv.Value, options, b)); + } + + if (candidates.Count == 0) + { + reason = string.IsNullOrEmpty(wantDifficulty) && !wantLevel.HasValue ? "bad-profile" : "bad-difficulty"; + refusal = string.IsNullOrEmpty(wantDifficulty) && !wantLevel.HasValue + ? "RaidableBases has no enabled profile with a base" + : $"RaidableBases has no enabled profile with a base at difficulty '{wantDifficulty ?? wantLevel.ToString()}'"; + return null; + } + + if (needBase) + return candidates[UnityEngine.Random.Range(0, candidates.Count)]; + + // The plugin picks the base among the difficulty's own (its cycle, its rules). With no + // difficulty asked for, one is picked here at random from those that have a base. + Choice pick = candidates[UnityEngine.Random.Range(0, candidates.Count)]; + return new Choice { Difficulty = pick.Difficulty, Level = pick.Level }; + } + + private Choice ChoiceOf(string name, object profile, object options, string baseName) + { + return new Choice + { + BaseName = baseName, + ProfileName = name, + Profile = profile, + Difficulty = ModeOf(options), + Level = LevelOf(options) + }; + } + + private static bool FitsDifficulty(Choice choice, string difficulty, int? level, out string refusal) + { + refusal = null; + + if (!string.IsNullOrEmpty(difficulty) && !string.Equals(choice.Difficulty, difficulty, StringComparison.OrdinalIgnoreCase)) + { + refusal = $"profile '{choice.ProfileName}' is difficulty '{choice.Difficulty}', not '{difficulty}'"; + return false; + } + + if (level.HasValue && string.IsNullOrEmpty(difficulty) && choice.Level != level.Value) + { + refusal = $"profile '{choice.ProfileName}' is level {choice.Level}, not {level.Value}"; + return false; + } + + return true; + } + + /// The plugin's profile table, name first. Empty when it cannot be read. + private List> ProfileTable() + { + var rows = new List>(); + + if (!(F(F(_rb, "Buildings"), "Profiles") is IDictionary profiles)) + return rows; + + foreach (DictionaryEntry kv in profiles) + { + if (kv.Key is string name && kv.Value != null) + rows.Add(new KeyValuePair(name, kv.Value)); + } + + rows.Sort((a, b) => string.Compare(a.Key, b.Key, StringComparison.OrdinalIgnoreCase)); + return rows; + } + + /// A profile's bases that have a CopyPaste file: the profile's own name, then its additional bases. + private List BasesOf(string profileName, object options) + { + var bases = new List(); + + if (Exists(profileName)) + bases.Add(profileName); + + if (F(options, "AdditionalBases") is IDictionary extra) + { + foreach (DictionaryEntry kv in extra) + { + if (kv.Key is string name && !bases.Contains(name) && Exists(name)) + bases.Add(name); + } + } + + return bases; + } + + private bool Exists(string file) + { + try + { + return _fileExists.Invoke(_rb, new object[] { file }) is bool yes && yes; + } + catch (Exception) + { + return false; + } + } + + /// The paid edition's difficulty name; the free edition has one, "Normal". + private static string ModeOf(object options) + { + return F(options, "Mode") is string mode && !string.IsNullOrEmpty(mode) ? mode : "Normal"; + } + + /// The paid edition's difficulty level; the free edition's hooks always carry 512. + private static int LevelOf(object options) + { + return F(options, "Level") is int level ? level : 512; + } + + // ── Spawning ───────────────────────────────────────────────────────── + + /// + /// The plugin picks the spot (D203). The queue patch sees the request our call makes and + /// tags it, with the settings on a copy of its profile. + /// + private Dictionary SpawnPicked(Tag tag, Choice choice) + { + object result = null; + _capturing = tag; + + try + { + if (_spawnApi != null) + { + result = _spawnApi.Invoke(_rb, new object[] { choice.BaseName, Vector3.zero, choice.Level, Convert.ToInt32(_manual), true }); + } + else + { + result = _spawnTyped.Invoke(_rb, new object[] { _manual, choice.BaseName, true, null, null, true }); + } + } + catch (Exception ex) + { + _capturing = null; + return Refuse("refused", $"RaidableBases threw: {(ex.InnerException ?? ex).Message}"); + } + finally + { + _capturing = null; + } + + if (tag.Request == null || !(result is bool queued) || !queued) + { + if (tag.Request != null) + { + Requested.Remove(tag.Request); + Dequeue(tag.Request); + } + + string said = result as string; + return Refuse("refused", $"RaidableBases did not queue a base: {(said ?? LastMessage() ?? "no reason given")}"); + } + + return new Dictionary { { "ok", true }, { "route", "pick" } }; + } + + /// + /// A spot an admin gave (D207): checked (D208), then built the way the plugin's own + /// "spawn where I look" builds one and pasted at once. + /// + private Dictionary SpawnAt(Dictionary req, Tag tag, Choice choice) + { + var warnings = new List(); + Vector3 at; + string reason; + string refusal; + + if (!CheckSpot(req, choice, warnings, out at, out reason, out refusal)) + { + Dictionary no = Refuse(reason, refusal); + no["warnings"] = warnings; + return no; + } + + // The base as the plugin builds it: an additional base comes back as a clone of its + // profile carrying that base's own paste options and name (§11.4.2: the paid edition + // finds no profile without the difficulty's name). + object built; + + try + { + object answer = _getBuilding.GetParameters().Length >= 4 + ? _getBuilding.Invoke(_rb, new object[] { _manual, choice.Difficulty, choice.BaseName, null }) + : _getBuilding.Invoke(_rb, new object[] { _manual, choice.BaseName, null }); + built = answer?.GetType().GetField("Item2")?.GetValue(answer); + } + catch (Exception ex) + { + return Refuse("refused", $"RaidableBases could not find base '{choice.BaseName}': {(ex.InnerException ?? ex).Message}"); + } + + if (built == null) + return Refuse("bad-base", $"RaidableBases will not build base '{choice.BaseName}' for an event: {LastMessage() ?? "no reason given"}"); + + object copy; + + try + { + copy = CopyProfile(built); + object options = F(copy, "Options"); + object setup = F(options, "Setup"); + double? adjust = Number(req, "heightAdjust"); + double? height = Number(req, "height"); + + if (height.HasValue) + { + S(setup, "EnableForcedHeight", true); + S(setup, "ForcedHeightValue", (float)height.Value); + } + else if (adjust.HasValue) + { + S(setup, "PasteHeightAdjustment", (F(setup, "PasteHeightAdjustment") is float now ? now : 0f) + (float)adjust.Value); + } + + ApplySettings(options, tag); + } + catch (Exception ex) + { + return Refuse("refused", $"could not copy profile '{choice.ProfileName}': {(ex.InnerException ?? ex).Message}"); + } + + object rb; + + try + { + rb = Activator.CreateInstance(_randomBase); + S(rb, "Instance", _rb); + S(rb, "BaseName", choice.BaseName); + S(rb, "Profile", copy); + S(rb, "Position", at); + S(rb, "type", _manual); + S(rb, "spawns", _spawnsCtor.Invoke(new object[] { _rb })); + S(rb, "pasteData", _getPasteData.Invoke(_rb, new object[] { choice.BaseName })); + Radii(rb); + _parseListed?.Invoke(_rb, new[] { rb }); + } + catch (Exception ex) + { + return Refuse("refused", $"could not build the spawn request: {(ex.InnerException ?? ex).Message}"); + } + + tag.Request = rb; + Requested[rb] = tag; + bool pasting; + + try + { + pasting = _pasteBuilding.Invoke(_rb, new[] { rb }) is bool yes && yes; + } + catch (Exception ex) + { + Requested.Remove(rb); + return Refuse("refused", $"RaidableBases threw while pasting: {(ex.InnerException ?? ex).Message}"); + } + + if (!pasting) + { + Requested.Remove(rb); + return Refuse("refused", "RaidableBases would not paste (is CopyPaste loaded?)"); + } + + // As the plugin's own queue does after a paste starts, so its queue waits for this one. + S(_rb, "IsSpawnerBusy", true); + S(rb, "IsPasting", true); + + var ok = new Dictionary { { "ok", true }, { "route", "spot" }, { "warnings", warnings } }; + ok["x"] = Math.Round(at.x, 1); + ok["z"] = Math.Round(at.z, 1); + return ok; + } + + /// The radii the plugin's own queue sets on a request, from the copied profile. + private void Radii(object rb) + { + try + { + object options = F(F(rb, "Profile"), "Options"); + + if (_getDistance != null) + S(rb, "typeDistance", _getDistance.Invoke(_rb, new[] { _manual })); + + float protection = ProtectionRadius(options); + S(rb, "protectionRadius", protection); + + float arena = F(F(options, "ArenaWalls"), "Radius") is float walls ? walls : 0f; + S(rb, "safeRadius", Mathf.Max(arena, protection)); + + float cupboard = F(F(F(F(_rb, "config"), "Settings"), "Management"), "CupboardDetectionRadius") is float c ? c : 0f; + S(rb, "buildRadius", Mathf.Max(105f, Mathf.Max(cupboard, Mathf.Max(arena, protection)) + 5f)); + } + catch (Exception ex) + { + PrintWarning($"could not set a spot's radii; the plugin's defaults stand: {(ex.InnerException ?? ex).Message}"); + } + } + + private float ProtectionRadius(object options) + { + try + { + MethodInfo radius = options?.GetType().GetMethod("ProtectionRadius", Any, null, new[] { _raidableType }, null); + + if (radius != null && radius.Invoke(options, new[] { _manual }) is float r && r > 0f) + return r; + } + catch (Exception) + { + } + + return DefaultSpotRadius; + } + + /// A spawn's settings, on its own copy of the profile's options. + private static void ApplySettings(object options, Tag tag) + { + if (tag.Pvp.HasValue) + S(options, "AllowPVP", tag.Pvp.Value); + + object npc = F(options, "NPC"); + + if (npc != null && (tag.Npcs.HasValue || tag.NpcCount.HasValue)) + { + if (tag.Npcs == false || tag.NpcCount == 0) + { + S(npc, "Enabled", false); + } + else + { + S(npc, "Enabled", true); + + if (tag.NpcCount.HasValue) + { + // The profile's own mix of the two kinds, at the count asked for, and not a + // random amount: the event said how many. + int murderers = Math.Max(0, F(npc, "SpawnAmountMurderers") is int m ? m : 0); + int scientists = Math.Max(0, F(npc, "SpawnAmountScientists") is int s ? s : 0); + int count = tag.NpcCount.Value; + int sci = murderers + scientists == 0 ? count : (int)Math.Round(count * (double)scientists / (murderers + scientists)); + S(npc, "SpawnAmountScientists", sci); + S(npc, "SpawnAmountMurderers", count - sci); + S(npc, "SpawnRandomAmountScientists", false); + S(npc, "SpawnRandomAmountMurderers", false); + } + } + } + + if (tag.DespawnMinutes.HasValue) + { + object despawn = F(options, "DespawnOptions"); + + if (despawn != null) + { + S(despawn, "OverrideConfig", true); + S(despawn, "DespawnMinutes", tag.DespawnMinutes.Value); + S(despawn, "DespawnMinutesInactive", tag.DespawnMinutes.Value); + } + } + } + + /// Takes a request off the plugin's queue, when it is still on it. + private void Dequeue(object request) + { + if (request == null) + return; + + try + { + object queue = F(F(_rb, "Queues"), "queue"); + + if (queue == null) + return; + + Type type = queue.GetType(); + + if (!(type.GetMethod("ToArray")?.Invoke(queue, null) is Array items)) + return; + + type.GetMethod("Clear")?.Invoke(queue, null); + MethodInfo enqueue = type.GetMethod("Enqueue"); + + foreach (object item in items) + { + if (!ReferenceEquals(item, request)) + enqueue.Invoke(queue, new[] { item }); + } + } + catch (Exception ex) + { + PrintWarning($"could not take a request off RaidableBases' queue; it is cancelled when it opens: {(ex.InnerException ?? ex).Message}"); + } + } + + // ── A spot's checks (D208, D388, D389) ─────────────────────────────── + + /// + /// Main thread. Whether a base may go at the request's x, z. Refuses for a + /// player, a building, a deployable, a raid base or water; rocks and cliffs are warnings. + /// + private bool CheckSpot(Dictionary req, Choice choice, List warnings, out Vector3 at, out string reason, out string refusal) + { + at = Vector3.zero; + reason = null; + refusal = null; + + double? x = Number(req, "x"); + double? z = Number(req, "z"); + + if (!x.HasValue || !z.HasValue) + { + reason = "malformed"; + refusal = "a spot needs x and z"; + return false; + } + + float half = World.Size / 2f; + + if (Math.Abs(x.Value) > half || Math.Abs(z.Value) > half) + { + reason = "off-map"; + refusal = $"({x.Value:0}, {z.Value:0}) is off this map, which runs from -{half:0} to {half:0}"; + return false; + } + + at = new Vector3((float)x.Value, 0f, (float)z.Value); + float ground = TerrainMeta.HeightMap.GetHeight(at); + at.y = ground; + + object options = choice.Profile != null ? F(choice.Profile, "Options") : null; + float radius = options != null ? ProtectionRadius(options) : DefaultSpotRadius; + + // Ours first: the plugin's check stops at the first thing it finds, and a boulder found + // first would hide a player's hut behind a warning. + if (!OwnChecks(at, radius, out reason, out refusal)) + return false; + + string said; + + if (!PluginAreaSafe(at, radius, out said)) + { + if (IsTerrainWarning(said)) + { + warnings.Add(said); + } + else + { + reason = "spot-blocked"; + refusal = $"RaidableBases' area check refused the spot: {said}"; + return false; + } + } + + // The ocean level against the ground. The water map read −500 everywhere on the rigs' + // map, so it cannot be used for this (§11.4.2). + float water = WaterSystem.OceanLevel; + + if (water > ground + WaterMargin) + { + object waterOptions = F(options, "Water"); + bool seabed = F(waterOptions, "Seabed") is float chance && chance > 0f; + bool floats = F(waterOptions, "AllowSubmerged") is bool yes && yes; + + if (!seabed && !floats) + { + reason = "in-water"; + refusal = $"the spot is {water - ground:0.0} m under water, and profile '{choice.ProfileName ?? "?"}' is not built for water"; + return false; + } + + warnings.Add($"the spot is {water - ground:0.0} m under water; profile '{choice.ProfileName}' allows it"); + } + + return true; + } + + /// Players, buildings, deployables and raid bases within the radius, by this helper's own look. + private bool OwnChecks(Vector3 at, float radius, out string reason, out string refusal) + { + reason = null; + refusal = null; + + foreach (BasePlayer player in BasePlayer.allPlayerList) + { + if (player == null || player.IsNpc || player.transform == null) + continue; + + ulong uid = player.userID; + + if (!uid.IsSteamId()) + continue; + + if (Distance2D(player.transform.position, at) <= radius) + { + reason = "spot-blocked"; + refusal = $"a player is within {radius:0} m ({player.displayName})"; + return false; + } + } + + if (F(_rb, "Raids") is IList raids) + { + foreach (object raid in raids) + { + if (raid == null || raid is UnityEngine.Object unity && unity == null) + continue; + + if (F(raid, "Location") is Vector3 where && Distance2D(where, at) <= radius * 2f) + { + reason = "spot-blocked"; + refusal = $"a raid base is within {radius * 2f:0} m"; + return false; + } + } + } + + var nearby = new List(); + Vis.Entities(at, radius, nearby); + + foreach (BaseEntity entity in nearby) + { + if (entity == null || entity.IsDestroyed) + continue; + + if (entity is BuildingPrivlidge || entity is BuildingBlock && entity.OwnerID.IsSteamId()) + { + reason = "spot-blocked"; + refusal = $"a player's building is within {radius:0} m"; + return false; + } + + if (entity.OwnerID.IsSteamId() && !(entity is BasePlayer)) + { + reason = "spot-blocked"; + refusal = $"a player's deployable ({entity.ShortPrefabName}) is within {radius:0} m"; + return false; + } + } + + return true; + } + + /// The plugin's own area check, bound by parameter count (§11.4.2), with its reason. + private bool PluginAreaSafe(Vector3 at, float radius, out string said) + { + said = null; + object controller = F(_rb, "SpawnsController"); + object messages = F(F(_rb, "Queues"), "Messages"); + + if (controller == null || _areaSafe == null) + return true; + + try + { + (F(messages, "_elements") as IDictionary)?.Clear(); + + ParameterInfo[] ps = _areaSafe.GetParameters(); + var args = new object[ps.Length]; + args[0] = at; + args[1] = 0f; + args[2] = radius; + args[3] = radius; + args[4] = radius; + args[5] = _manualMask; + args[6] = false; + args[7] = null; + + for (int i = 8; i < ps.Length; i++) + args[i] = ps[i].ParameterType == _raidableType ? _manual : ps[i].HasDefaultValue ? ps[i].DefaultValue : null; + + if (_areaSafe.Invoke(controller, args) is bool safe && safe) + return true; + + said = messages?.GetType().GetMethod("GetLast", Any)?.Invoke(messages, new object[] { null }) as string; + said = Clean(said) ?? "no reason given"; + return false; + } + catch (Exception ex) + { + // The check itself broke: advice lost, not a refusal. + PrintWarning($"RaidableBases' area check threw: {(ex.InnerException ?? ex).Message}"); + return true; + } + } + + /// + /// Whether the plugin's reason is the terrain, which D388 makes a warning. Its messages + /// are English literals in its source (not its lang file), so the words are its own. + /// + private static bool IsTerrainWarning(string said) + { + return said != null && (said.IndexOf("Rock is too large", StringComparison.OrdinalIgnoreCase) >= 0 + || said.IndexOf("Cliff formation is too large", StringComparison.OrdinalIgnoreCase) >= 0); + } + + /// The plugin's debug line ("1x - Rock is too large: x y z") down to its words. + private static string Clean(string said) + { + if (string.IsNullOrEmpty(said)) + return null; + + int dash = said.IndexOf(" - ", StringComparison.Ordinal); + string text = dash >= 0 && dash < 8 ? said.Substring(dash + 3) : said; + return text.Trim(); + } + + private string LastMessage() + { + try + { + object messages = F(F(_rb, "Queues"), "Messages"); + + if (!(F(messages, "_elements") is IDictionary elements) || elements.Count == 0) + return null; + + return Clean(messages.GetType().GetMethod("GetLast", Any)?.Invoke(messages, new object[] { null }) as string); + } + catch (Exception) + { + return null; + } + } + + // ── Patching ───────────────────────────────────────────────────────── + + private void Patch() + { + _rb = plugins.Find("RaidableBases"); + + if (_rb == null) + { + _state = "no-raidablebases"; + _reason = null; + return; + } + + _rbType = _rb.GetType(); + _raidableBase = Nested("RaidableBase"); + _randomBase = Nested("RandomBase"); + _spawnsType = Nested("RaidableSpawns"); + _raidableType = Nested("RaidableType"); + Type queueType = Nested("QueueController"); + Type spawnsController = Nested("SpawnsControllerManager"); + + _manual = null; + + try + { + if (_raidableType != null && _raidableType.IsEnum) + _manual = Enum.Parse(_raidableType, "Manual"); + } + catch (Exception) + { + _manual = null; + } + + _openEvent = _randomBase == null ? null : _rbType.GetMethod("OpenEvent", Any, null, new[] { _randomBase }, null); + _pasteBuilding = _randomBase == null ? null : _rbType.GetMethod("PasteBuilding", Any, null, new[] { _randomBase }, null); + _parseListed = _randomBase == null ? null : _rbType.GetMethod("ParseListedOptions", Any, null, new[] { _randomBase }, null); + _queueAdd = queueType == null || _randomBase == null ? null : queueType.GetMethod("Add", Any, null, new[] { _randomBase }, null); + _bypass = _raidableBase?.GetMethod("BypassUseOwners", Any, null, Type.EmptyTypes, null); + _despawn = _raidableBase?.GetMethod("Despawn", Any, null, Type.EmptyTypes, null); + _gridLoading = _rbType.GetMethod("IsGridLoading", Any, null, Type.EmptyTypes, null); + _getPasteData = _rbType.GetMethod("GetPasteData", Any, null, new[] { typeof(string) }, null); + _fileExists = _rbType.GetMethod("FileExists", Any, null, new[] { typeof(string) }, null); + _reloadProfiles = FindMethod(_rbType, "ReloadProfiles", m => m.GetParameters().Length == 1 && typeof(IEnumerator).IsAssignableFrom(m.ReturnType)); + _getDistance = _raidableType == null ? null : _rbType.GetMethod("GetDistance", Any, null, new[] { _raidableType }, null); + _getBuilding = FindMethod(_rbType, "GetBuilding", m => m.GetParameters().Length >= 3); + _areaSafe = spawnsController == null ? null : FindMethod(spawnsController, "IsAreaSafe", m => m.GetParameters().Length >= 9 && m.GetParameters()[0].ParameterType == typeof(Vector3)); + _spawnApi = FindMethod(_rbType, "SpawnRandomBase", m => Shape(m, typeof(string), typeof(Vector3), typeof(int), typeof(int), typeof(bool))); + _spawnTyped = _raidableType == null ? null : FindMethod(_rbType, "SpawnRandomBase", m => + { + ParameterInfo[] ps = m.GetParameters(); + return ps.Length == 6 && ps[0].ParameterType == _raidableType && ps[1].ParameterType == typeof(string); + }); + _spawnsCtor = _spawnsType == null ? null : Array.Find(_spawnsType.GetConstructors(Any), c => c.GetParameters().Length == 1); + + try + { + FieldInfo mask = _rbType.GetField("manualMask", Any); + _manualMask = mask != null ? Convert.ToInt32(mask.GetValue(null)) : 0; + } + catch (Exception) + { + _manualMask = 0; + } + + string missing = + _raidableBase == null ? "RaidableBase" : + _randomBase == null ? "RandomBase" : + _manual == null ? "RaidableType.Manual" : + _openEvent == null ? "OpenEvent(RandomBase)" : + _queueAdd == null ? "QueueController.Add(RandomBase)" : + _bypass == null ? "RaidableBase.BypassUseOwners()" : + _despawn == null ? "RaidableBase.Despawn()" : + _gridLoading == null ? "IsGridLoading()" : + _pasteBuilding == null ? "PasteBuilding(RandomBase)" : + _getBuilding == null ? "GetBuilding" : + _getPasteData == null ? "GetPasteData(string)" : + _fileExists == null ? "FileExists(string)" : + _reloadProfiles == null ? "ReloadProfiles(IPlayer)" : + _spawnsCtor == null ? "RaidableSpawns(RaidableBases)" : + _spawnApi == null && _spawnTyped == null ? "SpawnRandomBase" : + _manualMask == 0 ? "manualMask" : + null; + + if (missing != null) + { + Fail($"this RaidableBases ({_rb.Version}) has no {missing}"); + return; + } + + try + { + _harmony = new Harmony(HarmonyId); + PatchOne(_queueAdd, prefix: nameof(BeforeQueueAdd)); + PatchOne(_openEvent, postfix: nameof(AfterOpenEvent)); + PatchOne(_bypass, prefix: nameof(BeforeBypassUseOwners)); + } + catch (Exception ex) + { + Fail($"Harmony could not patch RaidableBases: {ex.Message}"); + return; + } + + _state = "patched"; + _reason = null; + + // Not a refusal: the spot check loses only the plugin's half and says why in the log. + if (_areaSafe == null) + PrintWarning($"this RaidableBases ({_rb.Version}) has no SpawnsController.IsAreaSafe: a spot is checked by this helper's own look and the water test only"); + + Puts($"patched RaidableBases {_rb.Version} ({(_spawnApi != null ? "paid" : "free")} edition's spawn call): " + + "an event's base can be tagged, set, locked, placed and despawned"); + } + + private void PatchOne(MethodBase target, string prefix = null, string postfix = null) + { + _harmony.Patch(target, + prefix: prefix == null ? null : new HarmonyMethod(typeof(RunicGatewayRaids).GetMethod(prefix, BindingFlags.Static | BindingFlags.NonPublic)), + postfix: postfix == null ? null : new HarmonyMethod(typeof(RunicGatewayRaids).GetMethod(postfix, BindingFlags.Static | BindingFlags.NonPublic))); + _patched.Add(target); + } + + private void Unpatch() + { + if (_harmony != null) + { + foreach (MethodBase target in _patched) + { + try + { + _harmony.Unpatch(target, HarmonyPatchType.All, HarmonyId); + } + catch (Exception ex) + { + PrintWarning($"could not remove a RaidableBases patch: {ex.Message}"); + } + } + } + + _patched.Clear(); + _harmony = null; + } + + private void Fail(string reason) + { + Unpatch(); + _state = "unsupported"; + _reason = reason; + PrintWarning($"{reason}. Runic Gateway still reports raid bases, but an event cannot despawn, set, lock or place its own."); + } + + private Type Nested(string name) => _rbType?.GetNestedType(name, Any); + + private static MethodInfo FindMethod(Type type, string name, Predicate fits) + { + if (type == null) + return null; + + foreach (MethodInfo m in type.GetMethods(Any)) + { + if (m.Name == name && fits(m)) + return m; + } + + return null; + } + + private static bool Shape(MethodInfo m, params Type[] types) + { + ParameterInfo[] ps = m.GetParameters(); + + if (ps.Length != types.Length) + return false; + + for (int i = 0; i < ps.Length; i++) + { + if (ps[i].ParameterType != types[i]) + return false; + } + + return true; + } + + // ── The patches. Static, as Harmony needs; nothing thrown here may reach RaidableBases. ── + + /// Prefix on QueueController.Add(RandomBase): tags the request our own pick made. + private static void BeforeQueueAdd(object __0) + { + try + { + Tag tag = _capturing; + + if (tag == null || __0 == null || _instance == null) + return; + + _capturing = null; + tag.Request = __0; + Requested[__0] = tag; + _instance.CopySettingsOnto(__0, tag); + } + catch (Exception) + { + // Never RaidableBases' problem. + } + } + + /// Postfix on OpenEvent(RandomBase): the request has become a base. + private static void AfterOpenEvent(object __result, object __0) + { + try + { + Tag tag; + + if (__result == null || __0 == null || !Requested.TryGetValue(__0, out tag)) + return; + + Requested.Remove(__0); + tag.Raid = __result; + tag.Request = null; + Tagged[__result] = tag; + + if (F(__result, "Location") is Vector3 where) + tag.Location = where; + + // The config's Convert switches were applied inside OpenEvent, after the profile. + if (tag.Pvp.HasValue) + S(__result, "AllowPVP", tag.Pvp.Value); + + if (tag.Cancelled && _instance != null) + { + RunicGatewayRaids self = _instance; + self.NextTick(() => self.Despawn(tag.Id)); + } + } + catch (Exception) + { + // Never RaidableBases' problem. + } + } + + /// Prefix on RaidableBase.BypassUseOwners(): a run's base answers from its own lock (D206). + private static bool BeforeBypassUseOwners(object __instance, ref bool __result) + { + try + { + Tag tag; + + if (__instance != null && Tagged.TryGetValue(__instance, out tag) && tag.Lock.HasValue) + { + // Locked to the first attacker means the owner rules are NOT bypassed. + __result = !tag.Lock.Value; + return false; + } + } + catch (Exception) + { + } + + return true; + } + + /// The pick route's settings, on a copy of the request's profile. + private void CopySettingsOnto(object request, Tag tag) + { + if (!tag.Pvp.HasValue && !tag.Npcs.HasValue && !tag.NpcCount.HasValue && !tag.DespawnMinutes.HasValue) + return; + + object copy = CopyProfile(F(request, "Profile")); + ApplySettings(F(copy, "Options"), tag); + S(request, "Profile", copy); + } + + // ── The plugin's own hooks, for the tags' bookkeeping ──────────────── + + private void OnRaidableBaseEnded(Vector3 location, int level, bool allowPvp, string id, float a, float b, float loadTime, ulong ownerId, + BasePlayer owner, List raiders, List intruders, List entities, string baseName, + DateTime spawn, DateTime despawn, float protectionRadius, int lootRemaining) + { + foreach (Tag tag in _byId.Values) + { + if (tag.Raid != null && tag.EndedAt < 0f && Distance2D(tag.Location, location) <= 2f) + tag.EndedAt = Time.realtimeSinceStartup; + } + + // Forget what ended long ago, so the table stays the size of what is live. + var stale = new List(); + + foreach (Tag tag in _byId.Values) + { + if (tag.EndedAt >= 0f && Time.realtimeSinceStartup - tag.EndedAt > EndedTagSeconds) + stale.Add(tag.Id); + } + + foreach (string gone in stale) + { + if (_byId.TryGetValue(gone, out Tag tag) && tag.Raid != null) + Tagged.Remove(tag.Raid); + + _byId.Remove(gone); + } + } + + // ── Small things ───────────────────────────────────────────────────── + + private static bool IsOpen(object raid) + { + if (raid == null || raid is UnityEngine.Object unity && unity == null) + return false; + + return !(F(raid, "IsDespawning") is bool despawning && despawning); + } + + private static bool Flag(object o, string name) => F(o, name) is bool on && on; + + private static object F(object o, string name) + { + if (o == null) + return null; + + Type type = o.GetType(); + FieldInfo field = type.GetField(name, Any); + + if (field != null) + return field.GetValue(o); + + PropertyInfo property = type.GetProperty(name, Any); + return property != null && property.GetIndexParameters().Length == 0 ? property.GetValue(o) : null; + } + + private static void S(object o, string name, object value) + { + if (o == null) + return; + + Type type = o.GetType(); + FieldInfo field = type.GetField(name, Any); + + if (field != null) + { + field.SetValue(o, value); + return; + } + + PropertyInfo property = type.GetProperty(name, Any); + + if (property != null && property.CanWrite) + property.SetValue(o, value); + } + + private static readonly MethodInfo MemberwiseCloneMethod = typeof(object).GetMethod("MemberwiseClone", BindingFlags.Instance | BindingFlags.NonPublic); + + private static object ShallowCopy(object o) => o == null ? null : MemberwiseCloneMethod.Invoke(o, null); + + /// + /// A copy of a profile one spawn may change without touching the profile every other + /// base shares (§11.4.4). The profile and its options are copied member by member, so the + /// loot list, the spawn table and every value the plugin computes after it reads a + /// profile are carried over as they are — a JSON round trip lost those, and the base it + /// opened broke the plugin's damage hooks on the rig (build, 2026-10-16). The three + /// objects a spawn's settings write into (Setup, NPC, DespawnOptions) + /// are copied again, which is what the plugin's own shallow Clone() does not do and + /// why its height leaked into the shared profile (§11.4.4). Returns the profile's copy; + /// its Options is the options' copy. + /// + private static object CopyProfile(object profile) + { + object copy = ShallowCopy(profile); + object options = ShallowCopy(F(profile, "Options")); + + foreach (string nested in new[] { "Setup", "NPC", "DespawnOptions" }) + { + object inner = F(options, nested); + + if (inner != null) + S(options, nested, ShallowCopy(inner)); + } + + S(copy, "Options", options); + return copy; + } + + private static Dictionary Refuse(string reason, string message) + { + return new Dictionary { { "ok", false }, { "reason", reason }, { "message", message } }; + } + + private static string Text(Dictionary req, string key) + { + return req != null && req.TryGetValue(key, out object value) && value != null ? value.ToString() : null; + } + + private static double? Number(Dictionary req, string key) + { + string text = Text(req, key); + double value; + return text != null && double.TryParse(text, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out value) + ? value + : (double?)null; + } + + private static int? IntOf(Dictionary req, string key) + { + double? value = Number(req, key); + return value.HasValue ? (int)Math.Round(value.Value) : (int?)null; + } + + private static bool? BoolOf(Dictionary req, string key) + { + if (req == null || !req.TryGetValue(key, out object value) || value == null) + return null; + + if (value is bool b) + return b; + + string text = value.ToString().Trim().ToLowerInvariant(); + return text == "true" || text == "1" ? true : text == "false" || text == "0" ? false : (bool?)null; + } + + private static float Distance2D(Vector3 a, Vector3 b) + { + float dx = a.x - b.x; + float dz = a.z - b.z; + return Mathf.Sqrt(dx * dx + dz * dz); + } + + // ── Console ────────────────────────────────────────────────────────── + + [ConsoleCommand("rgr.status")] + private void StatusCommand(ConsoleSystem.Arg arg) + { + if (arg.Connection != null && !arg.IsAdmin) + return; + + int open = 0, queued = 0; + + foreach (Tag tag in _byId.Values) + { + string live = LiveState(tag.Id); + + if (live == "open") + open++; + else if (live == "queued") + queued++; + } + + arg.ReplyWith($"Runic Gateway Raids {Version}: {_state}{(_reason != null ? $" ({_reason})" : "")}" + + (_state == "patched" ? $"; {(_spawnApi != null ? "paid" : "free")} spawn call; grid {(GridReady() ? "ready" : "building")}; " + + $"{ProfileTable().Count} profiles; event bases open {open}, on their way {queued}" : "")); + } + } +} -- 2.49.1 From 38f854d5807340c330e79302fef193f215dc73ae Mon Sep 17 00:00:00 2001 From: wtclaude Date: Wed, 21 Oct 2026 13:59:10 -0500 Subject: [PATCH 14/14] fix(bridge): the last fixes' bridge half (W3, W9, W12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 3 of docs/modules/rust/PLAN_LAST_FIXES.md. - W3: a Travelling Vendor call while one is on the map answers world.error reason `already-out` instead of "called 1" with nothing spawned. Not counted against the run's budget and not kept under its key, so a retry looks again. Module-Rust#55 reads it as called 0. - W9: a rolled-back RaidableBases profile write carries RaidableBases' own log entry for the file (its path and the exception's message, without the stack), read from the log since the write began, instead of the last 8 KB of the server log. Its other entries since the write stand in when none names the profile, and nothing when it said nothing. - W12: raid.ended carries the base's id, and its runId and key, when a run made it, as PROTOCOL §20.12 says. A teardown takes the base off the record before its despawn raises Ended, so the live base keeps its run and key too. No protocol change: W3's reason is a new value of an existing field, and W12 sends the fields PROTOCOL already lists. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- overlay/oxide/plugins/RunicGateway.cs | 210 +++++++++++++++++++++++++- 1 file changed, 202 insertions(+), 8 deletions(-) diff --git a/overlay/oxide/plugins/RunicGateway.cs b/overlay/oxide/plugins/RunicGateway.cs index 0be829e..d69bd1a 100644 --- a/overlay/oxide/plugins/RunicGateway.cs +++ b/overlay/oxide/plugins/RunicGateway.cs @@ -6792,6 +6792,10 @@ namespace Oxide.Plugins ReloadsBefore = helper.Call("ReloadsFinished") is int done ? done : 0 }; + // RaidableBases' own words on a profile it cannot read are logged during the reload, + // so the log is read from here when it is rolled back (W9). + MarkLogPosition(job); + if (!(helper.Call("ReloadProfiles") is bool asked) || !asked) { RestoreConfig(staged); @@ -6847,7 +6851,7 @@ namespace Oxide.Plugins if (failed.Count == 0) SettleConfigJob(job, true, false, null, null, null); else - RollBackRaidProfiles(job, $"RaidableBases did not load profile {string.Join(", ", failed.ToArray())}", LogTail()); + RollBackRaidProfiles(job, $"RaidableBases did not load profile {string.Join(", ", failed.ToArray())}", RaidProfileErrors(job, failed)); } else { @@ -6863,7 +6867,7 @@ namespace Oxide.Plugins return; if (job.Phase == ConfigPhase.Edit) - RollBackRaidProfiles(job, $"RaidableBases' profile reload did not finish within {ConfigReloadCeilingSeconds:0}s", LogTail()); + RollBackRaidProfiles(job, $"RaidableBases' profile reload did not finish within {ConfigReloadCeilingSeconds:0}s", RaidProfileErrors(job, null)); else SettleConfigJob(job, false, true, false, $"{job.RollbackReason}; the restored profiles did not finish reloading either", job.RollbackLog); } @@ -6884,6 +6888,148 @@ namespace Oxide.Plugins SettleConfigJob(job, false, true, false, $"{reason}; the files are back, but RaidableBases would not reload them", log); } + /// The most lines of one of RaidableBases' entries a rollback carries: its file, the exception, its message. + private const int RaidProfileErrorLinesMax = 6; + + /// + /// A log line that begins an entry: Oxide's 06:50 [Info] or Carbon's + /// [2026.10.21 18:45:45] [INFO] . Anything else continues the entry above it. + /// + private static readonly System.Text.RegularExpressions.Regex LogEntryStart = + new System.Text.RegularExpressions.Regex(@"^(\d{2}:\d{2} |\[\d{4}\.\d{2}\.\d{2} \d{2}:\d{2}:\d{2}\] )\[", System.Text.RegularExpressions.RegexOptions.CultureInvariant); + + /// + /// Main thread. What RaidableBases itself logged since the profile write began (W9), for + /// a rollback: an unreadable profile is one entry, the file's path and then the exception + /// (Could not convert string to integer: not a number. Path 'Difficulty Level', line 3), + /// and that much is carried, without the stack. With , only the + /// entries naming one of those profiles; with none of those, or none asked for, every + /// entry of its own since the write. Null when it said nothing: the reason line stands + /// alone rather than borrowing the server log's tail, which is other plugins' lines. + /// + private string RaidProfileErrors(ConfigWriteJob job, List failed) + { + string text = LogSinceMark(job); + + if (string.IsNullOrEmpty(text)) + return null; + + Plugin rb = RaidableBasesPlugin(); + var tags = new List { "[RaidableBases]" }; + + if (rb != null && !string.IsNullOrEmpty(rb.Title)) + tags.Add("[" + rb.Title + "]"); + + var named = new StringBuilder(); + var all = new StringBuilder(); + StringBuilder entry = null; + bool namesFailed = false; + int kept = 0; + + Action close = () => + { + if (entry == null) + return; + + all.Append(entry); + + if (namesFailed) + named.Append(entry); + + entry = null; + }; + + foreach (string raw in text.Split('\n')) + { + string line = raw.TrimEnd('\r'); + + if (LogEntryStart.IsMatch(line)) + { + close(); + + if (!tags.Exists(t => line.IndexOf(t, StringComparison.OrdinalIgnoreCase) >= 0)) + continue; + + entry = new StringBuilder(line).Append('\n'); + kept = 1; + namesFailed = failed != null && failed.Exists(n => line.IndexOf(n + ".json", StringComparison.OrdinalIgnoreCase) >= 0); + continue; + } + + // The stack is the plugin's, not the operator's: the message above it is the error. + if (entry == null || kept >= RaidProfileErrorLinesMax || line.TrimStart().StartsWith("at ", StringComparison.Ordinal)) + { + if (entry != null) + kept = RaidProfileErrorLinesMax; + + continue; + } + + if (line.Trim().Length == 0) + continue; + + entry.Append(line).Append('\n'); + kept++; + } + + close(); + + string found = (named.Length > 0 ? named : all).ToString().TrimEnd('\n'); + + if (found.Length == 0) + return null; + + return found.Length > ConfigLogTailBytes ? found.Substring(0, ConfigLogTailBytes) : found; + } + + /// + /// Main thread. The log written since , at most + /// ConfigLogPollMaxBytes, without moving the job's own reading place. A log that + /// rotated since is read on from its mark and then from the new file's start. + /// + private string LogSinceMark(ConfigWriteJob job) + { + var text = new StringBuilder(); + + try + { + string newest = NewestLogFile(); + + if (!string.IsNullOrEmpty(job.LogPath) && File.Exists(job.LogPath)) + text.Append(ReadLogFrom(job.LogPath, job.LogOffset, ConfigLogPollMaxBytes)); + + if (newest != null && !string.Equals(newest, job.LogPath, StringComparison.OrdinalIgnoreCase) && text.Length < ConfigLogPollMaxBytes) + text.Append(ReadLogFrom(newest, 0, ConfigLogPollMaxBytes - text.Length)); + } + catch (Exception) + { + // A log that cannot be read leaves the rollback its reason line. + } + + return text.ToString(); + } + + private static string ReadLogFrom(string path, long offset, int max) + { + using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + { + // A log shorter than the mark was truncated: read it from the top. + if (stream.Length < offset) + offset = 0; + + int take = (int)Math.Min(stream.Length - offset, max); + + if (take <= 0) + return string.Empty; + + stream.Seek(offset, SeekOrigin.Begin); + var buffer = new byte[take]; + int read = stream.Read(buffer, 0, take); + + return Encoding.UTF8.GetString(buffer, 0, read); + } + } + /// /// Main thread. If a write touched this plugin's own config, take the one setting that /// is safe to change live — EventsEnabled — from what was written. Returns @@ -10525,6 +10671,13 @@ namespace Oxide.Plugins /// The run's id for it (a world registry id), or null for a base no run made. public string Id; + + /// + /// The run and step key behind , kept here too: a teardown takes the + /// base off the record before its despawn raises Ended (W12). + /// + public string RunId; + public string Key; } private readonly List _raids = new List(); @@ -10782,6 +10935,8 @@ namespace Oxide.Plugins entry.Started = true; entry.Name = baseName; entry.Difficulty = live.Difficulty; + live.RunId = entry.RunId; + live.Key = entry.Key; SaveWorld(); } @@ -10873,11 +11028,11 @@ namespace Oxide.Plugins _raids.Remove(live); string ours = RaidTagAt(location) ?? live?.Id; - WorldEntry entry; + WorldEntry entry = null; // A run's base the plugin ended first, on its own timer or because it was raided: the // run's resource is marked expired (D170). One the run's teardown despawned is already - // off the record. + // off the record, and its id, run and key come from the live base instead. if (ours != null && _world.TryGetValue(ours, out entry)) { _world.Remove(ours); @@ -10888,8 +11043,9 @@ namespace Oxide.Plugins if (!WorldEventsLive()) return; - LiveRaid told = live ?? new LiveRaid { At = location, Level = level, Difficulty = RaidDifficultyName(level), Pvp = allowPvp, Base = baseName, Id = ours }; - EmitFrame(RaidFrame(RaidEndedKind, told, null)); + LiveRaid told = live ?? new LiveRaid { At = location, Level = level, Difficulty = RaidDifficultyName(level), Pvp = allowPvp, Base = baseName }; + told.Id = ours; + EmitFrame(RaidFrame(RaidEndedKind, told, entry)); } private void OnRaidableBasePurchased(string userId, UnityEngine.Vector3 location, string grid, int level, bool allowPvp, string baseName, @@ -10948,6 +11104,17 @@ namespace Oxide.Plugins frame["runId"] = entry.RunId; frame["key"] = entry.Key; } + else if (!string.IsNullOrEmpty(live.Id)) + { + // A run's base no longer on the record: a teardown's despawn (W12). + frame["id"] = live.Id; + + if (!string.IsNullOrEmpty(live.RunId)) + frame["runId"] = live.RunId; + + if (!string.IsNullOrEmpty(live.Key)) + frame["key"] = live.Key; + } return frame; } @@ -11027,7 +11194,7 @@ namespace Oxide.Plugins continue; int level = TupleItem(row, 3) is int l ? l : 0; - _raids.Add(new LiveRaid + var live = new LiveRaid { At = at, Level = level, @@ -11036,7 +11203,16 @@ namespace Oxide.Plugins Base = TupleItem(row, 14) as string, DespawnAtMs = TupleItem(row, 16) is DateTime d ? RaidMs(d) : 0L, Id = RaidTagAt(at) - }); + }; + WorldEntry entry; + + if (live.Id != null && _world.TryGetValue(live.Id, out entry)) + { + live.RunId = entry.RunId; + live.Key = entry.Key; + } + + _raids.Add(live); } } catch (Exception ex) @@ -19475,6 +19651,14 @@ namespace Oxide.Plugins } } + // The game runs one Travelling Vendor at a time, and its event quietly starts nothing + // while one is out (W3). Said, not counted, and not kept: a retry looks again. + if (ev == "travelling_vendor" && TravellingVendorOut()) + { + EmitWorldError(reqId, "already-out", "a Travelling Vendor is already out, and the game runs one at a time; nothing was called"); + return; + } + int used = 0; foreach (WorldCall c in _worldCalls.Values) @@ -19527,6 +19711,16 @@ namespace Oxide.Plugins EmitFrame(frame); } + /// Main thread. Whether a Travelling Vendor is on the map now. + private static bool TravellingVendorOut() + { + foreach (TravellingVendor v in UnityEngine.Object.FindObjectsOfType()) + if (v != null && !v.IsDestroyed) + return true; + + return false; + } + /// The game's own schedule for an event, enabled or not: an operator who switched one off may still call it. private static EventSchedule ScheduleFor(string key) { -- 2.49.1