feat(bridge): Backpacks on protocol 14 — read a backpack (PLAN_REDESIGNS §11.3) #33

Merged
whitlocktech merged 1 commits from feat/rust-backpacks into edge 2026-10-12 14:41:35 -05:00
3 changed files with 298 additions and 6 deletions

View File

@@ -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
```

View File

@@ -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 ──────────────────────────────────────────────────────

View File

@@ -62,7 +62,8 @@ namespace Oxide.Plugins
/// one-time reward messages <c>perm.sync</c> carries as <c>notices</c> (D333); and
/// Economics (§20.3, PLAN_REDESIGNS.md §11.2): <c>economics.pay</c>,
/// <c>economics.balances</c>, <c>economics.adjust</c>, the tally's <c>balance</c> and the
/// <c>economics.wiped</c> event.
/// <c>economics.wiped</c> event; and Backpacks (§20.4, PLAN_REDESIGNS.md §11.3):
/// <c>backpacks.read</c> and hello's <c>integrations.backpacks.sizes</c>.
/// </para>
/// </summary>
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.";
/// <summary>Backpacks' own bound on a page, and its default "Max size per page".</summary>
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";
}
/// <summary>
/// Hello's <c>integrations.backpacks</c>: loaded and version like every optional mod, and
/// <c>sizes</c>, the N of every registered <c>backpacks.size.N</c>, ascending. They are
/// the operator's own <i>Permission sizes</i>, and what the site may lease (D337).
/// </summary>
private Dictionary<string, object> BackpacksBody()
{
Plugin backpacks = BackpacksPlugin();
Dictionary<string, object> body = IntegrationBody(backpacks);
if (backpacks != null)
body["sizes"] = BackpackSizes();
return body;
}
private List<int> BackpackSizes()
{
var sizes = new SortedSet<int>();
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<int>(sizes);
}
/// <summary>
/// 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).
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>
private void HandleBackpacksRead(Dictionary<string, object> 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<string, object> 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<Dictionary<string, object>> 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<string, object> 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);
}
/// <summary>
/// Backpacks' item data reduced to what a grid shows, by slot. <paramref name="nest"/>
/// keeps one level of <c>contents</c> — a weapon's attachments, a container's items —
/// and no deeper.
/// </summary>
private static List<Dictionary<string, object>> BackpackStacks(Newtonsoft.Json.Linq.JArray data, bool nest)
{
var stacks = new List<Dictionary<string, object>>();
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;
}
/// <summary>
/// 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 <c>itemId</c>. A blueprint names what it is for (D341):
/// <c>blueprint</c> is the target's shortname and <c>display</c> the target's name.
/// </summary>
private static Dictionary<string, object> BackpackStack(Newtonsoft.Json.Linq.JObject item, bool nest)
{
int itemId = JsonInt(item["ID"]);
ItemDefinition def = ItemManager.FindItemDefinition(itemId);
var stack = new Dictionary<string, object>
{
{ "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<string, object> frame = Frame("backpacks.error", "reply");
frame["reason"] = reason;
frame["message"] = message;
if (reqId != null)
frame["reqId"] = reqId;
EmitFrame(frame);
}
// ── Chat ──────────────────────────────────────────────────────────────────
/// <summary>
@@ -13098,7 +13383,8 @@ namespace Oxide.Plugins
{ "popupNotifications", IntegrationBody(PopupPlugin()) },
{ "zoneDomes", ZoneDomesBody() },
{ "runicNpc", RunicNpcBody() },
{ "economics", EconomicsBody() }
{ "economics", EconomicsBody() },
{ "backpacks", BackpacksBody() }
};
}