From e3dc9184004e2c90baa425cca4a569f42184132c Mon Sep 17 00:00:00 2001 From: wtclaude Date: Mon, 12 Oct 2026 14:35:54 -0500 Subject: [PATCH] =?UTF-8?q?feat(bridge):=20Backpacks=20on=20protocol=2014?= =?UTF-8?q?=20=E2=80=94=20read=20a=20backpack=20(PLAN=5FREDESIGNS=20=C2=A7?= =?UTF-8?q?11.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