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() }
};
}