Compare commits

..

1 Commits

Author SHA1 Message Date
runic-docs-bot
631cf81f6b docs(tree): sync website/PROJECT_TREE.md from RunicGateway/website@ca7ec53 [skip ci] 2026-10-12 09:54:03 +00:00
4 changed files with 12 additions and 732 deletions

View File

@@ -1455,7 +1455,7 @@ push, and Play (M6–M8) follow the designed app.
| Screen | Route | Reads |
| --- | --- | --- |
| **My Rust account** | `player/rust` | `GET /player/rust/links`, `POST /player/rust/link`, `DELETE …/links/{steamId}`, `GET /player/rust/permissions`, `GET /player/rust/economics` (the Coins section, 2026-10-12, [`PLAN_REDESIGNS.md`](../modules/rust/PLAN_REDESIGNS.md) §11.2: one card per server, "last known" when a server did not answer, the reason where it has no Economics, and left out on a 404 from an older module), `GET /player/rust/servers` + `GET /player/rust/backpacks` (the Backpack section, 2026-10-12, §11.3: one card per server, an Open button per linked account that reads once per press, the slots as pages of four-wide rows named by the game, D340, overflow marked; "not installed" on a 409, and left out with Coins on that 404) |
| **My Rust account** | `player/rust` | `GET /player/rust/links`, `POST /player/rust/link`, `DELETE …/links/{steamId}`, `GET /player/rust/permissions` |
#### The gate is `rust`, and `PLAYER` means staff too

View File

@@ -9,9 +9,7 @@ rig walk still to come (§5.8, D209); §3, built and walked on the rigs and the
rows still to come (§3.4); §4, built 2026-09-29 (§4.1). **§6 was spiked on 2026-09-30, and the org lead
chose neither route: Runic Gateway writes its own NPC plugin, RunicNPC, planned in
[`../../runicnpc/PLAN.md`](../../runicnpc/PLAN.md) (D214–D314), and the rest of this plan waits for it.** RunicNPC v1.0.0 shipped on 2026-10-09, and the plan
resumed with §2 the same day (D331–D334); §2 was built on 2026-10-12, and §11's Economics spike the same day
raised D335–D336 (§11.2.1). Economics was built on 2026-10-12 too (§11.2.4). The Backpacks spike followed and raised D337–D339 (§11.3.1), and Backpacks was built the same day (D340, D341, §11.3.4). The org lead then added permission rewards (§12,
D342–D346), planned the same day. **Every item left is built, tested and PR'd first; every walk —
resumed with §2 the same day (D331–D334). **Every item left is built, tested and PR'd first; every walk —
theirs and the ones still owed by §3 and §5 — happens together at the end (D331, §9).**
This is a companion to [`PLAN_FIXES.md`](PLAN_FIXES.md) and [`PLAN.md`](PLAN.md). Where they disagree, this
@@ -26,7 +24,6 @@ document is later and wins. Its decisions continue PLAN_FIXES' numbering at **D1
| 5 | Chat titles: twenty-three conditions | §4.6, D172–D175 | Rust-Plugins, Module-Rust |
| 6 | NPCs — now RunicNPC, its own plan ([`runicnpc/PLAN.md`](../../runicnpc/PLAN.md)) | §4.7 | **runicnpc-rust**, Rust-Plugins, Rust-Link, Module-Rust, installer |
| 11 | First-class optional plugins: Economics, Backpacks, RaidableBases (Kits stays required) | — (added 2026-09-28) | Rust-Plugins, Module-Rust, installer, Android-app |
| 12 | Permissions as event rewards: until the event ends, or for good | — (added 2026-10-12) | **core** (website `main`, MODULE_API 1.14.0), Module-Rust |
**Protocol.** §1, §3, §4 and §5 joined **protocol 13**, which shipped on 2026-10-09 with RunicNPC v1.0.0
(bundle 2026.10.09). The wire changes still to come, §2's and §11's, **open protocol 14** on `edge` and are
@@ -888,9 +885,6 @@ them. §6's row was never built: RunicNPC replaced route A.
| `perm.sync`: `notices` (the reward message, D333), and the report's `noticesTold` — **protocol 14** | site ⇄ game | 2.4 |
| ~~`world.place` for an NPC~~ — not built; RunicNPC (D214) | — | 6 |
| `player.tally`: `animalKills`, `weaponKills`, `headshots`, `bestPvpM`, `bestPveM`, `apcKills`, `heliKills`, `crafted`, `built`, `repaired`, `healed`, `rockets`, `explosives`, `missions` | game → site | 5.1 |
| hello: `integrations.economics` `{ loaded, version, startingBalance, wipesWithMap }` — **protocol 14** | game → site | 11.2.2 |
| `economics.pay`, `economics.balances`, `economics.adjust` and their replies — **protocol 14** | site → game | 11.2.2 |
| `player.tally`: `balance` (a value); `economics.wiped` — **protocol 14** | game → site | 11.2.2 |
## 8. MODULE_API 1.13.0 (core, website `main`)
@@ -994,31 +988,6 @@ The plan resumed on 2026-10-09, after RunicNPC v1.0.0, with four more:
| **D333** | **A kit reward can tell its player in game.** `rust.kit.entitle` gains an optional `message` and `delivery`; the notice rides `perm.sync` and is told once, at once or on the player's next wake; a blank message tells nobody. | The engagement notification as the only notice. | 2.4 |
| **D334** | **An optional boolean param is three buttons, [Not set] [Yes] [No]; a required one is a toggle.** | A two-state toggle everywhere, which saves No for a field nobody touched. | 2.1 |
The Economics spike raised two more, answered on 2026-10-12:
| # | Decision | Rejected | § |
|---|---|---|---|
| **D335** | **Coins paid by an event are kept.** `rust.coins.pay` is `reversible: 'none'`: a run that finishes, is cancelled or is aborted after paying leaves the coins where they are, and staff take coins back by hand from the player's admin page. | Kept on a finish but taken back on a cancel or abort (core would have to tell `revert()` how the run ended, MODULE_API 1.14.0); a per-step choice; always taken back at the end, like a kit weekend's credits. | 11.2.2 |
| **D336** | **The richest column and *Magnate* rank only balances above the server's starting balance**, read when a player connects and whenever it changes. A fresh wipe's untouched players are not ranked; a balance carried over from the last wipe is, at once. | Ranking a player only once their balance moves this wipe; ranking everyone, ties at the starting balance included. | 11.2.2 |
The Backpacks spike raised three more, answered on 2026-10-12:
| # | Decision | Rejected | § |
|---|---|---|---|
| **D337** | **Extra backpack space is a size permission leased for the run.** `rust.backpack.size` grants one of the server's `backpacks.size.N` to each recipient through `rust_perm_run_grants`, as a kit reward is granted, and the run's end revokes it. It reaches offline players, needs no experimental setting, and needs no plugin code. The highest size a player holds wins. | Backpacks' `API_AddBackpackCapacity` (+N slots), which throws unless the owner turns on *Dynamic Size (EXPERIMENTAL)*, is clamped to a size profile, and needs the player online; offering both; dropping the reward. | 11.3.2 |
| **D338** | **Taking the space back is Backpacks' own behaviour:** nothing moves at the revoke, and on the owner's next open the stacks past their size go to their inventory, or drop at their feet when it is full. Tested on both rigs at the org lead's request; the step's default message warns about it. | Holding the space until the extra slots are empty; never taking it back. | 11.3.1, 11.3.2 |
| **D339** | **The Carbon permission leak found by the spike stays out of §11.3**: filed as Rust-Plugins#32, for later. | Fixing it in §11.3; only noting it. | 11.3.1 |
The build raised two more, answered on 2026-10-12 before any code: §11.3.2's grid named an "item catalogue" the
module does not have, and R9 keeps item icons out of the asset bridge.
| # | Decision | Rejected | § |
|---|---|---|---|
| **D340** | **A backpack slot shows the item's name, not an icon.** The plugin sends each stack's English name from the game (`display`), so "Wood ×1000" and "Assault Rifle + Weapon Lasersight" need no item catalogue and nothing loads from outside the site. R9 stands. | Icons from a third-party host keyed by shortname; shortnames alone. | 11.3.4 |
| **D341** | **A blueprint names what it makes.** Backpacks stores a blueprint as `blueprintbase` with a target; the stack carries `blueprint` (the target's shortname), its `display` is the target's name, and the slot reads "Assault Rifle blueprint". | Every blueprint shown as the same anonymous "Blueprint". | 11.3.4 |
Permission rewards, added on 2026-10-12, were decided as D342–D346; they are tabled with their section, in §12.8.
## 11. First-class optional plugins (D199–D202)
The org lead named four plugins the module supports as first-class integrations. Kits already is one. The other
@@ -1042,18 +1011,15 @@ Personal GitHub mirrors are not a source: k1lly0u's mirror of Kits is two major
| Plugin | Author · version · price | API we would use | Hooks it raises | Risk |
|---|---|---|---|---|
| **Economics** | Wulf · 3.9.2 · free | `Balance(id)` → double; `Deposit`, `Withdraw`, `SetBalance(id, amount)` → bool; `Transfer` | `OnEconomicsDeposit`, `OnEconomicsWithdrawl` (sic), `OnEconomicsTransfer`, `OnEconomicsBalanceUpdated`, `OnEconomicsDataWiped` | Last released about two years ago; fine as optional, never load-bearing |
| **Backpacks** | WhiteThunder · 3.17.7 (3.17.8 at the spike) · free | `API_ReadBackpackContentsAsJson(ownerId)`; the capacity family (`API_GetBackpackCapacity`, `API_AddBackpackCapacity`, `API_SetBackpackCapacity`) | `OnBackpackOpened`, `OnBackpackClosed`, `OnBackpackDropped`, `CanOpenBackpack` | Newer builds add `EncryptedValue<ulong>` overloads; one community report of backpack data lost across a **Carbon** restart — a rig test before shipping (**not reproduced, §11.3.1**) |
| **Backpacks** | WhiteThunder · 3.17.7 · free | `API_ReadBackpackContentsAsJson(ownerId)`; the capacity family (`API_GetBackpackCapacity`, `API_AddBackpackCapacity`, `API_SetBackpackCapacity`) | `OnBackpackOpened`, `OnBackpackClosed`, `OnBackpackDropped`, `CanOpenBackpack` | Newer builds add `EncryptedValue<ulong>` overloads; one community report of backpack data lost across a **Carbon** restart — a rig test before shipping |
| **RaidableBases** | nivex · free 3.2.0 on uMod, paid on Codefling | `EventTerritory(Vector3)`, `SpawnRandomBase(type)`; listing and despawn not yet confirmed | `OnRaidableBaseStarted` (two overloads), `OnRaidableBaseEnded`, `OnRaidableBasePrivilegeDestroyed`, `OnRaidableBaseDespawn` — signatures not yet confirmed from source | Two products with different features and release lines; data path differs by framework; needs a PvE-damage plugin (TruePVE) for players to raid |
### 11.2 Economics (D200)
This is the plan as the org lead approved it on 2026-09-28. The spike (§11.2.1) changed two points of it, and
**§11.2.2 is the design to build**:
- **Coins as a reward.** A step `rust.coins.pay` gives each recipient N coins (the same recipient modes as
`rust.kit.entitle`, plus §2.5's `linked`). ~~It is ledgered per recipient like D103's kit credits, and
revert withdraws what the step paid.~~ **Superseded by D335: the coins are kept.** Core reverts a ledgered
step at the end of every run, a normal finish included, so a ledgered payment would take back a prize.
`rust.kit.entitle`, plus §2.5's `linked`). It is ledgered per recipient like D103's kit credits, and
**revert withdraws what the step paid**. A withdrawal the player cannot cover, because they have spent it,
takes what is there and says so in the run log. It never goes negative.
- **Balances.** A new read verb, `economics.balances {steamIds[]}` (bounded like `kits.uses`), and the
`OnEconomicsBalanceUpdated` hook folded into the tally (rule 2: aggregates only), so the site keeps each
player's last balance per server. The player's account page and the app show it; staff see anyone's.
@@ -1061,371 +1027,19 @@ This is the plan as the org lead approved it on 2026-09-28. The spike (§11.2.1)
an activity row with the amount before and after.
- **Richest.** A leaderboard column (the last known balance) and a title condition, *Magnate* by default,
admin-renamable like every other category (D175). It is a *best* column in §5.5's sense, the current value
rather than a sum. **It ranks only balances above Economics' starting balance (D336).**
#### 11.2.1 The spike (2026-10-12)
The source is Economics 3.9.2, Wulf, pulled from `umod.org/plugins/Economics.cs` (845 lines) on 2026-10-12.
A throwaway plugin called its API and logged every hook on both rigs, on Rust 25877464 with Oxide 2.0.7826 and
Carbon 2.0.262. **Both frameworks answered identically, line for line.** The spike and Economics were then
removed from both rigs, with their data and config.
**The API** is five private methods, each with a `string` and an `object` overload (the `object` one takes a
`ulong`). Both overloads bind through `Call` on both frameworks.
| Call | Returns | Found |
|---|---|---|
| `Balance(id)` | `double` | **An account Economics has never seen reads as the starting balance** (1000 by default). Nothing is stored for it, and at load Economics deletes every account that sits exactly at the starting balance ("Remove unused accounts", on by default). So "1000" cannot tell a new player from one who earned and spent back to it |
| `Deposit(id, amount)` | `bool` | Refuses an amount of 0 or less. **Returns `true` while adding less than asked:** the balance is rounded to two places and silently clamped to the balance limit, so `Deposit(id, 0.004)` answers `true` and changes nothing. What was paid is the balance after less the balance before, never the amount asked |
| `Withdraw(id, amount)` | `bool` | **All or nothing.** It refuses an amount above the balance (unless the config allows negative balances); there is no partial withdrawal |
| `SetBalance(id, amount)` | `bool` | Refuses a negative amount unless the config allows one; rounds and clamps like `Deposit` |
| `Transfer(from, to, amount)` | `bool` | A withdrawal and a deposit. Not needed |
- **The amount must be a `double`.** `Call("Deposit", id, 10)`, with an `int`, matches no overload on either
framework: `Call` returns `null` and nothing happens. The bridge boxes every amount as `double`.
- **A string id is not checked.** `Deposit("notanid", 1.0)` stored an account named `notanid`. The bridge
sends only ids it has parsed as Steam ids.
- **Cost:** 500 `Balance` calls took 2.6 ms (Carbon) and 2.8 ms (Oxide) on the main thread.
- **Balances survive a reload** on both frameworks (`data/Economics.json`, written at unload and at every
server save).
- **What the API does not give:** the starting balance, the balance limit, and whether balances wipe with the
map. They are in `Economics.json` in the framework's config directory, which the bridge already reads for
Kits ([PROTOCOL.md](../../rust-link/PROTOCOL.md) §16.3). Economics' own chat and console commands find a player through covalence, so
they cannot reach a Steam id the server has never seen; the API can.
**The hooks**, in the order one deposit raises them:
| Hook | Arguments | When |
|---|---|---|
| `OnEconomicsBalanceUpdated` | `(string playerId, double balance)` | **Every** change, including the ones below, first. The new balance, not the change |
| `OnEconomicsDeposit` | `(string playerId, double amount)` | After a deposit; the amount asked, even when less landed |
| `OnEconomicsWithdrawl` (sic) | `(string playerId, double amount)` | After a withdrawal |
| `OnEconomicsTransfer` | `(string from, string to, double amount)` | After a transfer's withdrawal and deposit |
| `OnEconomicsDataWiped` | none, **or** `(IPlayer player)` | None at a new save when the config wipes balances; the `IPlayer` after `ecowipe`. One method taking an `IPlayer` binds both shapes on both frameworks, with `null` for the first |
**Two traps on the rigs**, neither Economics' fault: a Rust update needed **two** panel restarts on each rig,
because the first boot ran the old build with the download unfinished (`StateFlags` 1158 and 1062). And
**Carbon kept Economics loaded after its file was deleted**, until `c.unload`; Oxide unloaded it.
#### 11.2.2 The design to build (D335, D336)
**Hello.** `integrations.economics` is `{ loaded, version, startingBalance, wipesWithMap }`. The last two
come from `Economics.json`. When the file cannot be read, they are absent, the site assumes Economics'
default of 1000, and the servers page says so.
**Paying coins: `economics.pay`, site → game.** A one-shot command, because the coins are kept (D335):
```json
{"cmd":"economics.pay","reqId":"r-31","key":"7:4","amount":500,"steamIds":["76561190000000001"],
"message":"You earned {amount} coins","delivery":"popup"}
{"kind":"economics.paid","type":"reply","reqId":"r-31","key":"7:4","repeat":false,
"paid":[{"steamId":"76561190000000001","before":1000,"after":1500}],"failed":[]}
```
- **Main thread, one call for the whole list.** For each id it reads the balance, deposits, and reads it
again. `paid` holds the before and after values; a deposit that moved nothing, at the balance limit, is
listed in `failed` with `at-limit`.
- **The key is remembered with its reply**, on disk (`data/RunicGateway/payments.json`) for seven days, as
tallies are. A retried step is answered from the first reply with `repeat: true`, and never pays twice.
- **The message** is told once to each player paid: at once if they are awake, otherwise when they next
wake on this server, as reward notices are (PROTOCOL.md §20.2). The untold ids are kept in `payments.json` with the key.
`{amount}` is the amount the player received. A blank message tells nobody.
- **Bounds:** at most `EventsMaxRecipients` ids, and an amount from 0.01 to 1,000,000. Refusals are
`economics.error`: `economics-missing`, `events-disabled`, `bad-amount`, `too-large`.
**Reading balances: `economics.balances {steamIds[]}`**, answered with `{balances: [{steamId, balance}],
startingBalance}`, and bounded by the sync's own bound (`PermMaxOps`), like `kits.uses`. A balance at the
starting value may belong to an account Economics has never seen (§11.2.1).
**Staff: `economics.adjust {key, steamId, op, amount}`**, where `op` is `deposit`, `withdraw` or `set`.
It answers `{before, after}`, and a remembered key is answered with `repeat: true` like a payment. A withdrawal
above the balance is refused with `insufficient` and the balance, because Economics will not take part of
it. Staff then see "has only 340".
**The tally.** `player.tally` gains `balance`: **the current balance, a value and not a change**, like the
`best*` fields. It is sent when it moved since the last flush, and it is read:
- when a player connects, so a balance carried over from last wipe is known without waiting for it to move
(D336); and
- from `OnEconomicsBalanceUpdated`, for an id that parses as a Steam id. **The player need not be online:** a
payment or a staff adjustment reaches an offline player, and that frame carries no `name`.
**`economics.wiped`**, an event from `OnEconomicsDataWiped` in either shape. The site clears the server's
current-wipe balances, since everyone is back at the start.
**The sidecar** adds `POST /economics/pay`, `POST /economics/balances` and `POST /economics/adjust`, thin
forwards like `/kits/uses`. It keeps nothing.
**The website (Module-Rust).**
- **`rust.coins.pay`**, *"Pay coins"*: `risk: 'irreversible'`, `reversible: 'none'` (D335), as Module-uo's
`uo.item.grant` is. Its params are the server, the amount, the recipients and count (the modes of
`rust.kit.entitle`, including `linked`), and the message and delivery. One payment goes to each website
user: of two linked accounts that both took part, the higher-scoring one is paid, as for kits (D103). The
run log lists what each person received, anyone missed for having no linked account, and anyone at the
limit. **A run cancelled or aborted after the payment keeps it;** staff take coins back by hand.
- **Balances** are stored in `rust_player_wipe_stats.balance`, written as a value, never summed. They are
shown on the player's own Rust page, per linked account and server, with a new `GET /player/rust/economics`
that reads live and falls back to the last known balance, and on a user's admin page beside their links.
- **Staff adjust** through `POST /admin/users/:userId/rust/economics`, `{ serverId, steamId, op, amount }`.
Each adjustment is a core activity row: "Deposited 500 coins to *name* on *server*: 1,000 → 1,500".
- **Richest.** A leaderboard column on servers that report Economics, and a title category `richest`,
*Magnate*, whose standing is the current balance, **counted only above that server's starting balance
(D336)**. A fresh wipe's 40 untouched players at 1,000 are not on it; a player carrying 5,000 over is.
- **Every surface that needs Economics refuses with a reason where it is absent** (D199): the step's verify,
the adjust form, and the account page ("Economics is not installed on this server").
**The app (Android-app).** The Rust account screen shows the player's balances from the same route,
capability-gated like the rest of that screen (R10).
**Not touched:** the installer and the egg. `requires_plugins` stays Kits and ZoneManager (D199).
#### 11.2.3 The walk (deferred to the end, D331)
On both rigs, with Economics installed:
- pay three players from a tally, one of them offline, and see the popup on wake;
- retry the step, and see `repeat` and no second payment;
- pay at the balance limit, and see `at-limit`;
- deposit, withdraw (including more than the balance) and set from the admin page, and read the
activity rows;
- watch the richest column and *Magnate* on a fresh wipe, then with a carried-over balance;
- run `ecowipe`, and see the column clear;
- read a balance on the account page and in the app.
#### 11.2.4 Built (2026-10-12)
Built to §11.2.2 on protocol 14, and specified in [PROTOCOL.md](../../rust-link/PROTOCOL.md) §20.3. The
bridge compiled on both rigs (Rust 25877464; Oxide 2.0.7826, Carbon 2.0.262), which were then given back their
protocol 13 bridge. Six points the design left open were settled in the code, and are named here so they can
be overruled:
- **`economics.wiped` carries `newSave`.** After `ecowipe` the site clears the wipe's balances; at a new save it
clears nothing, because the new map's balances arrive as players connect, and the last wipe's stay as the
record of how it ended.
- **A balance frame with no `name` is not play.** The plugin names only a connected player, and the site writes
such a frame's balance without moving anybody's last-seen.
- **The Coins column is per wipe.** A balance is a value, so there is no all-time one: the column and
`sort=balance` exist only when one wipe of a server that reports Economics is shown.
- **A payment's `failed` reasons** are `at-limit`, `refused`, `unmeasured` (deposited, but not measured — remembered
with the key so a retry pays nothing more) and `bad-id`.
- **Payments and adjustments keep their keys apart**, so a step's key can never be answered with a staff
adjustment's reply. The admin form sends a fresh key with each submission.
- **The step spends a new `rust.coins` budget**, priced like the kit reward: the amount times the count, or
times the recipient bound for a mode that is not a count.
rather than a sum.
### 11.3 Backpacks (D201)
This is the plan as the org lead approved it on 2026-09-28. The spike (§11.3.1) changed how the extra space
is given, and **§11.3.2 is the design to build**:
- **Read, never write (R2).** A new verb, `backpacks.read {steamId}`, returns
`API_ReadBackpackContentsAsJson` reduced to shortname, amount, skin and slot. It never uses the write
counterpart, so the site cannot change a backpack.
- **Staff** open any player's backpack from the player's admin page. **A player** sees their own on the
account page and in the app, only for accounts they have linked (the player tier's usual scoping).
- **Extra space as a reward.** ~~A step `rust.backpack.capacity` adds N slots for the event's duration through
- **Extra space as a reward.** A step `rust.backpack.capacity` adds N slots for the event's duration through
`API_AddBackpackCapacity`, and takes them back at teardown, ledgered like a lease. If the player is offline
at either end, it waits for their next connection: the capacity calls take a `BasePlayer`.~~
**Superseded by D337: the step leases one of Backpacks' size permissions for the run instead.** The capacity
calls work only on a server whose owner has turned on an experimental setting (§11.3.1).
at either end, it waits for their next connection: the capacity calls take a `BasePlayer`.
- **The spike first:** the Carbon restart report. Fill a backpack on `rust-carbon`, restart, and check it.
**Done; the loss did not reproduce (§11.3.1).**
#### 11.3.1 The spike (2026-10-12)
The source is Backpacks **3.17.8** (one patch past the 3.17.7 that §11.1 names), WhiteThunder, pulled from
`umod.org/plugins/Backpacks.cs` (9,782 lines) on 2026-10-12. A throwaway plugin made stand-in players with
Steam-shaped ids (the RunicNPC 9d pattern), called the API and logged every hook, on both rigs, on Rust
25877464 with Oxide 2.0.7826 and Carbon 2.0.262. **Both frameworks answered identically everywhere the plugin
was concerned.** The spike and Backpacks were then removed from both rigs, with their data, config and lang
files, and the permissions the stand-ins held.
**The restart report did not reproduce.** A backpack changed after the last save (the save interval raised
to an hour first, so no timed save could run) was intact after a panel restart on both frameworks, for an
online owner and an offline one alike. A plugin reload kept it too. Backpacks saves at every server save and again at
unload. **The one way the spike lost a backpack** was its own: the first run killed its
stand-ins at unload, Backpacks took each kill for a death, and *Drop on death*, on by default, dropped the
contents on the ground. On Carbon, Backpacks was still loaded when the spike unloaded during shutdown; on
Oxide it was not, so only Carbon showed it. That is Backpacks working as configured: **any plugin that kills
a player entity drops that player's backpack.** The bridge kills no players.
**Reading.**
| Call | Found |
|---|---|
| `API_ReadBackpackContentsAsJson(ulong)` | A JSON string, or **`null` for a player who has never had a backpack**. It loads an offline player's backpack from disk. 0.2–7 ms once loaded; **55–117 ms the first time after a boot** (Oxide logs its own "took 116ms" warning), on the main thread |
| `API_CountBackpackItems(ulong, null)` | The number of stacks |
| `API_GetBackpackCapacityById(ulong, string)` | The capacity an id would have. **Caches it** (see the trap below) |
The JSON is a list of Backpacks' own `ItemData`, not of Rust items:
```json
[{"ID":-151838493,"Position":0,"Amount":1000},
{"ID":1545779598,"Position":2,"Amount":1,"Contents":[{"ID":567235583,"Position":0,"Amount":1}]},
{"ID":1751045826,"Skin":10129,"Position":3,"Amount":1},
{"ID":1414245162,"Name":"spike-a","Position":4,"Amount":1}]
```
- **`ID` is the item id, not the shortname**; the bridge maps it with `ItemManager.FindItemDefinition`.
- **`Position` runs across pages**: page two of a 96-slot backpack starts at 48 ("Max size per page").
- **`Contents`** holds a weapon's attachments or a container's items, nested. `Name` is a renamed item's
name. Condition, ammo, fuel and the rest appear when they differ from the default.
**Capacity.** A player's size comes from permissions: `backpacks.use` gives the default (6), and the
highest `backpacks.size.N` they hold sets it, for each N in the config's *Permission sizes* (6, 12, 18, 24, 30,
36, 42, 48, 96 and 144 by default). Backpacks registers each one, so the bridge can list them.
- **`API_AddBackpackCapacity` and `API_SetBackpackCapacity` throw** (`InvalidOperationException`, logged with a
stack trace on both frameworks; `Call` returns `null`) unless the config's **"Dynamic Size
(EXPERIMENTAL)"** is on, and it is off by default. With it on, each player is clamped to the initial and
maximum size of a *size profile* they hold (`backpacks.size.profile.6-48` …); a player with no profile gains
nothing. They take a `BasePlayer`, so an offline player cannot be given space. This is why D337 leases a
permission instead.
- **A size permission takes effect at once, both ways, for a player whose backpack is loaded** (an online
player who has opened it, or whose backpack anything has read): granted `backpacks.size.48`, 6 became 48;
revoked, 48 became 6. An offline player's grant is simply there when they connect.
- **The trap: a capacity cached without its backpack.** Backpacks clears its capacity cache on a permission
change only for a backpack it has loaded. If anything reads an online player's capacity while their backpack
is not loaded (`API_GetBackpackCapacity*`), the value is pinned: a size granted afterwards did not show, even
after the owner opened the backpack, **until they disconnected**. Same on both frameworks. Backpacks itself
reads capacity only through a loaded backpack, so only another plugin's call can cause it. **The bridge never
reads a capacity except through a backpack it has just loaded with a read.**
**Taking space back (the org lead's test, 2026-10-12).** A stand-in held `backpacks.size.96`, six stacks on
page one and five notes on page two (slots 48–52); the permission was revoked.
| State | What happened, on both frameworks |
|---|---|
| After the revoke, before the owner opens it | Nothing moves. Capacity reads 6, and **the read still returns all eleven stacks**: the five past slot 6 wait in the backpack |
| The owner opens it, inventory has room | The five notes **moved into the owner's inventory** (main 5/24), and the backpack read six stacks. Backpacks tells the owner in chat: "Your backpack was over capacity. Overflowing items were added to your inventory or dropped." |
| The owner opens it, inventory full (30/30) | The five notes **dropped on the ground at the owner's feet** as loose items, and the backpack read six stacks |
That is Backpacks' default, the behaviour a VIP page that lapses already has, and **it stands (D338).** A
staff member looking at someone else's backpack sees the overflow too; only the owner's own open moves it.
**Hooks.** `OnBackpackOpened(BasePlayer looter, ulong ownerId, ItemContainer)` and `OnBackpackClosed` fired
for the owner's open and close; `OnBackpackDropped(ulong ownerId, List<DroppedItemContainer>)` for each
death drop. None is needed by the design.
**Two quirks the bridge never meets,** because it never writes: `API_TryDepositBackpackItem` took only the
first item into a backpack nobody had opened yet, and `API_GetBackpackContainer` returns `null` for a backpack
that does not exist yet. A page that has never been opened is sized to what it holds until its owner opens it.
**Found on the way, outside Backpacks:** on Carbon, a permission granted to a Steam id the store has no user
record for leaks onto every such id. Filed as
[Rust-Plugins#32](https://gitea.whitlocktech.com/RunicGateway/Rust-Plugins/issues/32) and kept out of
§11.3 (D339). The size lease grants through `perm.sync` like a kit reward, so it inherits whatever that issue
decides.
#### 11.3.2 The design to build (D337, D338)
**Hello.** `integrations.backpacks` is `{ loaded, version, sizes }`. `sizes` lists the N of every
registered `backpacks.size.N` permission, ascending (`[6, 12, 18, 24, 30, 36, 42, 48, 96, 144]` by
default), read from the permission store, so an operator's own *Permission sizes* are what the site offers.
**Reading a backpack: `backpacks.read {steamId}`, site → game.**
```json
{"cmd":"backpacks.read","reqId":"r-40","steamId":"76561190000000001"}
{"kind":"backpacks.contents","type":"reply","reqId":"r-40","steamId":"76561190000000001","exists":true,
"capacity":12,"pageSize":48,
"items":[{"slot":0,"shortname":"wood","amount":1000},
{"slot":2,"shortname":"rifle.ak","amount":1,
"contents":[{"slot":0,"shortname":"weapon.mod.small.scope","amount":1}]},
{"slot":3,"shortname":"hoodie","amount":1,"skin":10129},
{"slot":4,"shortname":"note","amount":1,"name":"spike-a"},
{"slot":14,"shortname":"note","amount":1,"overflow":true}]}
```
- **One id per call, on the main thread.** It calls `API_ReadBackpackContentsAsJson`, and only when that
answered a backpack does it read `API_GetBackpackCapacityById`: that backpack is now loaded, so the
capacity follows later grants (§11.3.1's trap). A player who has never had one is `exists: false`, with no
capacity.
- **Each stack is reduced** to slot, shortname, amount, and skin and name when set; nested `contents` keep the
same shape, one level deep. An id no item definition knows is sent as `shortname: null` with its `itemId`.
- **`overflow: true`** marks a stack at or past the capacity: it is waiting for its owner's next open to be
moved (§11.3.1).
- **Refusals** are `backpacks.error`: `backpacks-missing`, `bad-id`.
**Extra space: a size lease (D337).** A new step, **`rust.backpack.size`**, *"Bigger backpack"*, grants one of
`integrations.backpacks.sizes` for the run, to the recipients a mode picks.
- **It is a kit reward with a different permission.** It writes one `rust_perm_run_grants` row per recipient,
with `permission` = `backpacks.size.N` and no kit. The permission mirror pushes it in the next `perm.sync`,
so a recipient who is offline has it when they connect, and a wipe cannot take it away. Its revert deletes the
step's rows, and the next sync revokes them. **So the space lasts until the run ends, a normal finish
included** (core reverts every ledgered step at the end of a run, §11.2).
- **No plugin code.** The grant, the revoke and their read-backs are `perm.sync`'s.
- **Params:** the server; the size (a select of the server's `sizes` above the smallest, refused when it is
not one of them); the recipients and count (`rust.kit.entitle`'s modes, `linked` included); and a message
and delivery, told once like a kit reward's notice (D333). The default message warns about the end:
*"Your backpack holds {size} until the event ends. Anything past your normal size then moves to your
inventory, or drops at your feet if it is full."*
- **The highest size wins.** A recipient who already holds a size at least as big gains nothing, and nothing is
taken from them at the end. The step's description says so.
- **The take-back is Backpacks' own (D338):** nothing moves at the revoke, and the overflow goes to the owner's
inventory, or the ground, the next time they open the backpack.
- `risk` and `reversible` are `rust.kit.entitle`'s (`change`, `ledger`). It spends a new `rust.backpacks`
budget, *Backpack sizes*: one per recipient, or the recipient bound for a mode that is not a count, as kit
rewards are priced.
- **It refuses where Backpacks is absent** (D199): "Backpacks is not installed on this server".
**The sidecar** adds `POST /backpacks/read`, a thin forward like `/kits/uses`. It keeps nothing.
**The website (Module-Rust).**
- **Staff** see a player's backpack on the user's admin page, per linked account and server, through
`GET /admin/users/:userId/rust/backpacks?serverId=&steamId=`. **A player** sees their own on their Rust
page through `GET /player/rust/backpacks?serverId=&steamId=`, for their own linked accounts only.
- **Live, never stored.** Each view is one `backpacks.read`; nothing is written to the database. A server that
is down or lacks Backpacks says so on the panel. Opening the panel reads once; there is no polling.
- **The grid** draws the slots as pages of `pageSize`, with each stack's icon from the item catalogue, its amount,
and an *overflow* mark.
**The app (Android-app).** The Rust account screen shows the player's own backpack from the player route,
capability-gated like the rest of that screen (R10).
**Not touched:** the installer and the egg. `requires_plugins` stays Kits and ZoneManager (D199). The bridge
uses none of Backpacks' hooks.
#### 11.3.3 The walk (deferred to the end, D331)
On both rigs, with Backpacks installed and a real player:
- open a staff view of a full backpack, a two-page one, and one that has never been used;
- read your own on the account page and in the app, and see another player's refused;
- run a *Bigger backpack* step, online and offline at the grant, and see the size on the next open;
- end the run with items on the extra page, open the backpack, and see them move to the inventory; then
again with a full inventory, and see them on the ground;
- remove Backpacks, and see the step and the panels refuse with a reason.
The walk's players must have real Steam ids: the bridge reads only a 17-digit id at or above
76561197960265728, so the spike's `7656119000000000N` stand-ins are refused `bad-id` (§11.3.4).
#### 11.3.4 Built (2026-10-12)
Built to §11.3.2 on protocol 14, and specified in [PROTOCOL.md](../../rust-link/PROTOCOL.md) §20.4. The
bridge compiled on both rigs (Rust changeset 167584; Oxide 2.0.7826, Carbon 2.0.262), which were then given back
the bridge they ran before. Before any code the grid's "item catalogue" turned out not to exist; D340 and D341
answered it. These points the design left open were settled in the code, and are named here so they can be
overruled:
- **Each stack carries `display`**, the game's English name (D340); for a blueprint, the name of what it makes
(D341). The grids on the site and in the app show it, then the shortname, then `item <id>` for an item the game
no longer knows.
- **A third refusal, `unreadable`**, for contents the bridge cannot parse. Backpacks writes the JSON itself, so it
should never happen; it is a sentence rather than an exception if it does.
- **The step's message is never silent.** Left empty, it is the default take-back warning, because a player
nobody warned is the one surprised by D338. An author can reword it, with `{size}`.
- **The size picker** offers each server's sizes above its smallest, grouped by server, from the last hello. The
step checks the pair it is given, and names the sizes the server does offer when it refuses one.
- **The panels offer every enabled server** and learn that one lacks Backpacks when the read answers `409`, so
nothing about which mods a server runs reaches the public server list.
- **The player route is rate-limited** to 20 reads a minute, because a read runs on the game's main thread.
- **A skin id is text** on the site's API: workshop ids are labels, and some pass 2^53.
- **The app gates the section on the Coins read's 404**: Backpacks and Economics ship in the same protocol, so an
older module has neither.
### 11.4 RaidableBases (D202)
@@ -1650,167 +1264,3 @@ The org lead answered §11.4.4's three questions the same day:
These are new verbs, events and hello fields. **They join protocol 14 (D332)**, which §2 opens: protocol 13
shipped on 2026-10-09, before §9 reached item 7.
---
## 12. Permissions as event rewards (D342–D346)
The org lead asked for this on 2026-10-12, during the Backpacks build: an event reward that grants **any
permission the server's plugins registered**, so a perk nobody planned for (night vision, a chat colour, a
`/remove` command) is a reward without code written for it. The picker sorts by mod, then by permission
inside the mod. It is its own item, after Backpacks, and planned first. Five decisions, D342–D346, were answered
the same day (§12.8).
Two steps come out of it. **Permissions until the event ends** lends them for the run, as a kit reward does.
**Permissions for good** gives them for keeps, as a staff member's grant in the permission manager does. Both
take several permissions in one step, which needs one small addition to core's step editor (§12.3).
### 12.1 What it builds on
Nothing below is new machinery; both steps are rows in tables the permission manager already pushes.
- **The lease is a kit reward's** (PLAN.md §29, D84). `rust.kit.entitle` and `rust.backpack.size` (D337) write
one `rust_perm_run_grants` row per recipient, the permission mirror pushes it in the next `perm.sync`, and the
step's revert deletes the rows so the next sync revokes them. The push reads the **union** of these rows and
the standing grants, so a player who already held the permission keeps it after the revert.
- **A standing grant is the permission manager's own** (§1): a `rust_perm_grants` row for a website user,
scoped to a server, reaching every account the user has linked (D28). It is `UNIQUE` on (user, permission,
scope), so granting one twice leaves the first.
- **The catalogue** is `rust_perm_catalogue`: per server, every registered permission and the plugin that
registered it (`owner`, the plugin's `Name`). A permission no plugin owns, as Carbon's built-in modules
register theirs, has no owner, and the permission manager groups it by its prefix (§1).
- **The message is D333's notice**, which rides `perm.sync` and is told once, at once or on the player's
next wake.
- **So there is no plugin code, no sidecar code and no protocol change.** Protocol 14 is untouched.
### 12.2 The picker: `rust.options.permissions` (D342)
- **Every permission in the catalogue of every enabled server, once each.** Its `group` is the plugin that
registered it. A permission with no owner is grouped by its prefix, labelled *"&lt;prefix&gt; (not
registered by a plugin)"*, the same rule the permission manager draws.
- **Sorted by group, then by permission inside the group**, both case-insensitively. The editor keeps a
source's order.
- **A row says where it is missing.** A permission that is not in every enabled server's catalogue carries
a row `note` (MODULE_API 1.13.0): *"Only on Main, PvP"*.
- **Not `searchable`.** A server registers a few hundred permissions; the multi-select's own filter box
(§12.3) narrows them in the browser. If a deployment ever passes the 2,000-row bound, the source says so, as
every bounded source does.
- **No blocklist and no allowlist** (D343). The bridge's own `runicgateway.*` permissions are in the list like
any other.
- **The step checks the server it is given.** Every picked permission must be in that server's catalogue; one
that is not is refused by name: *"PvP has no permission nightvision.allowed — its plugins did not register
it"*. The check runs in the dry run too.
### 12.3 The core addition: a param that holds several values (D344, MODULE_API 1.14.0)
The step editor has six param types, all single values. A step that takes several permissions needs a
list, so core gains one declaration flag. **It is an addition only**: a module that does not use it is
unchanged, and Module-uo's `coreApi: "^1.10.0"` still resolves.
- **`multiple: true` on an action param** of type `string` or `int`, with an optional `max` (1 to 100, 20 if
left out). It is refused on any other type, on a `duration` param, and on a template's inputs, which this
version does not extend.
- **The value is a JSON array**: each element checked as the type's literal, duplicates dropped, order kept, at
most `max`. `required` means at least one. A bare value is read as a list of one, so a spec written by hand
or over the API does not trip on it. The module's `perform()` and `cost()` receive the array.
- **The catalog serves `multiple` and `max`** beside the rest of the declaration (`GET
/admin/events/catalog`).
- **The editor draws a multi-select.**
- With a `source`: the chosen values as chips above a grouped checklist, with a filter box over it. Ticking
past `max` is refused with the number.
- With a `searchable` source: the typeahead adds a chip per pick.
- With no source: a tag box, a value per Enter.
- **The definition view and the run console** show a list as its values joined by commas.
- **Docs:** `website/MODULE_API.md` gains 1.14.0's entry and the flag in the declaration reference;
`website/EVENTS.md` §F names the control.
### 12.4 Permissions until the event ends: `rust.perm.lend` (D345, D346)
*"Lends the permissions you pick to the people a mode picks from this run's tally — or every linked player of
the server — until the run ends. Waits for them if they are offline. Someone who already held one keeps it
when the event ends."*
- **Params:**
- the server;
- the permissions (`multiple`, `max` 10, from §12.2);
- the recipients and count (`rust.kit.entitle`'s modes, `linked` included);
- a message and its delivery;
- **`confirm`**, a required boolean: *"I am sure I want to give these permissions as a reward"* (D343).
- **One row per recipient per permission** in `rust_perm_run_grants`, with no kit and no credit; the notice
goes on one row per recipient, so each is told once.
- **Unticked, it refuses** in the dry run and at run time, naming what it would have given: *"Tick "I am sure"
to make nightvision.allowed, betterchat.color a reward"*. Being a required toggle (D334), it cannot be left
unset.
- **`risk: 'change'`, `reversible: 'ledger'`**, as a kit reward is. Its revert and reconcile are
`rust.kit.entitle`'s, and core reverts it at the end of every run, a normal finish included.
- **The budget is new: `rust.permissions`, *Permissions lent*.** One per recipient per permission; a mode
that is not a count is priced at the recipient bound times the permissions, as kit rewards are.
### 12.5 Permissions for good: `rust.perm.give` (D345, D346)
*"Gives the permissions you pick to the people a mode picks, for good. Ending or cancelling the event does not
take them back; staff remove them in the permission manager."*
- **The same params as §12.4**, the confirm included.
- **A standing grant per website user per permission**, scoped to the step's server, with source `event` and
the note *"event run &lt;run&gt;, step &lt;step&gt;"*. A grant already there is left alone and counted as
*already held*.
- **`risk: 'irreversible'`, `reversible: 'none'`**, as `rust.coins.pay` is (D335). Like every step that changes
the world it arrives off until an admin turns it on under Actions; unlike §12.4's, its failure aborts the run
by default instead of pausing it, and Actions lists it as irreversible, so an admin enables the two
separately.
- **The budget is new too: `rust.permissions.kept`, *Permissions given for good***, priced as §12.4's.
- **Taking one back is the permission manager's.** The grant shows there with its source and note, and staff
delete it like any other.
- **The message rides a notice-only row** in `rust_perm_run_grants`: no permission, no kit, no credit. The
push grants nothing for it and tells the notice once. Nothing reverts the step, so the row stays; it is a
sentence that has been said.
### 12.6 Settled in this plan, and overrulable
- **A blank message tells nobody**, as a kit reward's does (D333), rather than a default sentence. A
permission's name (`nightvision.allowed`) means nothing to a player, so only the author can say what it does:
*"You can use /nv until the event ends"*. `{permissions}` puts the names in.
- **At most 10 permissions in one step**: enough for a perk bundle, and the price stays readable.
- **A permission the server's catalogue lacks is refused, not skipped.** The plugin may be unloaded for the
wipe, and a reward that silently gives less than it says is the worse failure.
- **The for-good grant is scoped to the step's server**, not every server. An author who wants it everywhere
adds a step per server.
### 12.7 Where the work lands, and in what order
1. **Website (core), on `main`:** §12.3, MODULE_API **1.13.0 → 1.14.0**, with its tests (`registries`,
`spec`, the editor's multi-select) and Module-uo's frozen-manifest proof against the new core, as 1.11.0's and
1.13.0's were run.
2. **Module-Rust, on `edge`**, after the website PR merges: the option source, the two actions, the two
budgets; `coreApi` `^1.14.0`; `ci/core-ref.json` re-pinned to the merge; `routes.manifest` regenerated.
3. **docs:** this section's *Built* notes; `website/MODULE_API.md`; `website/EVENTS.md`.
**Not touched:** Rust-Plugins, Rust-Link, the protocol, the installer, Android-app (the app does not author
events) and Module-uo. The Integration Kit's pin is at 1.12.0 and moves only when someone re-reads its
chapters for a newer core; it skipped 1.13.0 the same way.
### 12.8 Decisions of record
Answered by the org lead on 2026-10-12.
| # | Decision | Rejected | § |
|---|---|---|---|
| **D342** | **An event reward may be any permission the server's plugins registered**, picked from the catalogue grouped by the plugin that registered it and sorted by plugin, then permission. `rust.backpack.size` stays beside it, with its sizes and its take-back warning. | A fixed list of perks; folding the backpack step into the generic one. | 12.2 |
| **D343** | **No blocklist and no allowlist. The safety is a required "I am sure" toggle in the step**; unticked, the dry run and the run refuse, naming the permissions. | A pop-up when the step is saved (a core change); a blocklist of dangerous permissions. | 12.2, 12.4 |
| **D344** | **One step gives several permissions, through a multi-select picker**: a `multiple` flag on an action param, added to core as MODULE_API 1.14.0. | One permission per step; five fixed picker slots; a comma-separated text box. | 12.3 |
| **D345** | **A permission reward lasts until the run ends, or for good**, the author's choice. | Until the run ends only. | 12.4, 12.5 |
| **D346** | **The two durations are two steps**, `rust.perm.lend` (`change`) and `rust.perm.give` (`irreversible`), each with its own budget, because core gives an action one risk class. | One step with a "keep it" switch, rated `irreversible` throughout. | 12.4, 12.5 |
### 12.9 The walk (deferred to the end, D331)
On both rigs, with a plugin that registers a perk (NightVision or similar):
- open the picker and see it grouped by plugin, sorted inside each, a permission missing from a server noted,
and the unowned ones grouped by prefix;
- run a *Permissions until the event ends* step with two permissions, unticked (refused) and ticked; see the
perks work online and offline at the grant, and gone after the run ends, except for a player who already held
one;
- run a *Permissions for good* step; see it stay after the run ends, show in the permission manager with its
note, and go when staff delete it;
- unload the plugin and see the step refuse the permission by name.

View File

@@ -2641,7 +2641,7 @@ loaded.
---
## 20. Protocol 14 — the step editor and the optional plugins
## 20. Protocol 14 — the step editor
Opened by [`PLAN_REDESIGNS.md`](../modules/rust/PLAN_REDESIGNS.md) §2 after protocol 13 shipped with RunicNPC
v1.0.0 (D332). It is built on `edge` in Rust-Plugins, Rust-Link and Module-Rust and released once, after the
@@ -2701,175 +2701,3 @@ optional `message` and `delivery`; the site writes them on each grant row, and e
- **Absent is not empty**, as for `credits`: a sync with no `notices` field leaves the plugin's set alone. The
site sends the field on every sync from protocol 14, empty when there is nothing to say. The notices are part
of the sync's digest, so a new one is pushed at once.
### 20.3 Economics — coins, balances, staff adjust (§11.2, D199, D335, D336)
Wulf's **Economics** (3.9.2, uMod), optional like BetterChat and PopupNotifications (D199): every verb below
answers `economics-missing` without it, and nothing else in the bridge needs it. `requires_plugins` keeps Kits
and ZoneManager only. The design is [`PLAN_REDESIGNS.md`](../modules/rust/PLAN_REDESIGNS.md) §11.2.2; what the
spike found, which shapes every call, is §11.2.1 there.
**How the plugin calls it.** Economics' API is private methods called by name, each with a `string` and an
`object` overload. The bridge sends **only ids it has parsed as Steam ids** (17 digits, at or above the
individual base), because Economics makes an account of any string it is given, and **boxes every amount as
a `double`**: an `int` matches no overload, and `Call` answers null having done nothing. What a deposit paid is
**the balance after less the balance before**: `Deposit` answers `true` while rounding to two places and
clamping to Economics' balance limit.
**Hello and status.** `integrations` gains `economics`:
```json
"economics": { "loaded": true, "version": "3.9.2", "startingBalance": 1000, "wipesWithMap": false }
```
`startingBalance` and `wipesWithMap` come from `Economics.json` in the framework's config directory
(`"Starting account balance (0 or higher)"`, `"Wipe balances on new save file"`), read again only when the file
changes. **Each is absent when the file cannot say**; the site then assumes Economics' default of 1000 and the
servers page says so. Hello is sent again when Economics loads or unloads.
**`economics.pay` — coins from an event step, kept once paid (D335).**
```json
{"cmd":"economics.pay","reqId":"r-31","key":"7:4","amount":500,"steamIds":["76561190000000001"],
"message":"You earned {amount} coins","delivery":"popup"}
{"kind":"economics.paid","type":"reply","reqId":"r-31","key":"7:4","repeat":false,"amount":500,
"paid":[{"steamId":"76561190000000001","before":1000,"after":1500}],"failed":[]}
```
- **Main thread, one call for the list.** For each id: read the balance, deposit, read it again. `paid` holds
each player's before and after. `failed` lists the rest by reason: `at-limit` (the deposit moved nothing —
the balance is at Economics' limit), `refused` (`Deposit` answered false), `unmeasured` (deposited, but a
balance read failed — not told an amount, and still remembered with the key), `bad-id` (not a Steam id).
- **The key is remembered with its reply**, in `data/RunicGateway/payments.json`, for seven days (the tally
ceiling, D108). A key already paid is answered from the first reply with `repeat: true`, and pays nobody. A
refusal (`economics.error`) is not remembered: nothing moved.
- **The message** (optional; blank tells nobody) is said once to each player paid, with `{amount}` replaced by
**what that player received**: at once if they are connected and awake, otherwise on `OnPlayerSleepEnded`,
the next time they wake on this server — §20.2's rule. Who has been told is kept in `payments.json` beside
the key. A popup where `delivery` is `popup` and PopupNotifications is loaded, chat otherwise; cut at the
chat bound.
- **Bounds:** at most `EventsMaxRecipients` ids; an amount from 0.01 to 1,000,000. Behind `EventsEnabled`, as
every event verb is.
- Refusals are `economics.error`: `economics-missing`, `events-disabled`, `malformed` (no key), `bad-amount`,
`too-large`.
**`economics.balances` — what some players hold.**
```json
{"cmd":"economics.balances","reqId":"r-32","steamIds":["76561190000000001"]}
{"kind":"economics.balances","type":"reply","reqId":"r-32","balances":[{"steamId":"76561190000000001","balance":1500}],"startingBalance":1000}
```
Bounded by the sync's own bound (`PermMaxOps`), like `kits.uses`; ids that are not Steam ids are left out.
**A balance at the starting value may belong to an account Economics has never seen**: it answers the
starting balance for one and stores nothing (§11.2.1). `startingBalance` is absent where the config cannot be
read. Refusals: `economics-missing`, `too-large`.
**`economics.adjust` — staff deposit, withdraw or set one balance.**
```json
{"cmd":"economics.adjust","reqId":"r-33","key":"adj-3f2a9c1e-…","steamId":"76561190000000001","op":"withdraw","amount":500}
{"kind":"economics.adjusted","type":"reply","reqId":"r-33","key":"adj-3f2a9c1e-…","repeat":false,"op":"withdraw","amount":500,
"steamId":"76561190000000001","before":1500,"after":1000}
```
- `op` is `deposit`, `withdraw` or `set`; an amount from 0.01 to 1,000,000, and from 0 for `set`. Not behind
`EventsEnabled`: it is staff, not an event.
- **A withdrawal above the balance is refused** with `insufficient` and the `balance`, because Economics takes
all or nothing. A deposit that moved nothing is `at-limit`, with the balance.
- The key is remembered seven days like a payment's, in its own namespace, so a resent form answers `repeat`
and moves nothing. Other refusals: `economics-missing`, `malformed`, `bad-amount`, `refused`.
**The tally.** `player.tally` gains **`balance`: the current balance, a value and not a change**, like the
`best*` fields (§19.10). It is set when a player connects (so a balance carried over from last wipe is known at
once, D336), when Economics loads with players on, and from `OnEconomicsBalanceUpdated` for any id that parses
as a Steam id. **The player need not be online**: a payment or an adjustment reaches an offline player, and
that frame **carries no `name`** — the plugin names only a connected player, and the site reads a nameless
balance frame as not play: it moves nobody's last-seen.
**`economics.wiped`**, an event from `OnEconomicsDataWiped` in both of its shapes (one method taking an
`IPlayer`, null for the no-argument call):
```json
{"kind":"economics.wiped","type":"event","newSave":false}
```
`newSave` is true for the no-argument call — a new save, with Economics set to wipe balances with the map — and
false after `ecowipe`. Balances not yet flushed are dropped. **The site clears the wipe's balances after
`ecowipe`**; at a new save it does nothing, because the new map's balances arrive as players connect and the
last wipe's stay as the record of how it ended.
**The sidecar** adds `POST /economics/pay`, `POST /economics/balances` and `POST /economics/adjust`, thin
forwards like `/kits/uses`. It keeps nothing.
**The website (Module-Rust)** stores the tally's balance in `rust_player_wipe_stats.balance`, written over and
never summed. It serves `GET /player/rust/economics` (live, falling back to the last known) and
`GET`/`POST /admin/users/:id/rust/economics`, pays through the `rust.coins.pay` step (`risk: 'irreversible'`,
`reversible: 'none'`), and ranks the richest — a leaderboard column for one wipe, and the `richest` title
category, *Magnate* — **only above the server's starting balance** (D336).
### 20.4 Backpacks — read a backpack, lend a bigger one (§11.3, D199, D337, D338, D340, D341)
WhiteThunder's **Backpacks** (3.17.8, uMod), optional like Economics (D199): `backpacks.read` answers
`backpacks-missing` without it. `requires_plugins` keeps Kits and ZoneManager only. The design is
[`PLAN_REDESIGNS.md`](../modules/rust/PLAN_REDESIGNS.md) §11.3.2, and what the spike found, which shapes the
read, is §11.3.1 there.
**Read, never write (R2).** `backpacks.read` is the only verb, and the bridge calls no Backpacks method that
changes a backpack or a capacity. The bigger backpack an event lends is one of Backpacks' own
`backpacks.size.N` permissions, leased like a kit reward through an ordinary `perm.sync` (§20.2, D337), so it
adds nothing to this protocol beyond the sizes hello reports. None of Backpacks' hooks is used.
**Hello and status.** `integrations` gains `backpacks`:
```json
"backpacks": { "loaded": true, "version": "3.17.8", "sizes": [6, 12, 18, 24, 30, 36, 42, 48, 96, 144] }
```
`sizes` is the N of every registered `backpacks.size.N` permission, ascending and read from the permission
store, so it is the operator's own *Permission sizes*. A `backpacks.size.profile.…` permission (a dynamic-size
profile) is not a size and is left out. `sizes` is absent when Backpacks is not loaded. Hello is sent again
when Backpacks loads or unloads.
**`backpacks.read` — one player's backpack, live.**
```json
{"cmd":"backpacks.read","reqId":"r-40","steamId":"76561198000000001"}
{"kind":"backpacks.contents","type":"reply","reqId":"r-40","steamId":"76561198000000001","exists":true,
"capacity":12,"pageSize":48,
"items":[{"slot":0,"shortname":"wood","display":"Wood","amount":1000},
{"slot":2,"shortname":"rifle.ak","display":"Assault Rifle","amount":1,
"contents":[{"slot":0,"shortname":"weapon.mod.lasersight","display":"Weapon Lasersight","amount":1}]},
{"slot":3,"shortname":"hoodie","display":"Hoodie","amount":1,"skin":10129},
{"slot":4,"shortname":"note","display":"Note","amount":1,"name":"spike-a"},
{"slot":5,"shortname":"blueprintbase","display":"Assault Rifle","blueprint":"rifle.ak","amount":1},
{"slot":14,"shortname":"note","display":"Note","amount":1,"overflow":true}]}
```
- **One id per call, on the main thread.** Only an id parsed as a Steam id is read (17 digits, at or above the
individual base, as §20.3). It calls `API_ReadBackpackContentsAsJson`, which loads an offline player's
backpack from disk, and **only when that answered a backpack** reads `API_GetBackpackCapacityById`: the
backpack is loaded by then, so the capacity follows a size granted later (§11.3.1's trap). A player who has
never had a backpack is `"exists": false`, with no `capacity`, `pageSize` or `items`.
- **Each stack is reduced** to `slot`, `shortname`, `display` (the game's English name, D340), `amount`, and
`skin` and `name` (a renamed item's name) when set. An id no item definition knows is sent with
`"shortname": null` and its `itemId`, and no `display`. **A blueprint** (Backpacks stores `blueprintbase` with
a target) carries `blueprint`, the target's shortname, and its `display` is the target's name (D341).
`contents` (a weapon's attachments, a container's items) keeps the same shape, one level deep, and is sorted
by slot like the stacks.
- **`slot` runs across pages:** the first slot of page two is `pageSize`, which is Backpacks' *Max size per
page* from `Backpacks.json`, clamped to 1–48 as Backpacks clamps it, and 48 when the file does not say.
- **`overflow: true`** marks a stack at or past `capacity`. Nothing moves it until the owner next opens the
backpack; then Backpacks moves it to their inventory, or drops it at their feet when that is full (D338).
- **Refusals** are `backpacks.error`: `backpacks-missing`, `bad-id`, and `unreadable` (contents the bridge could
not parse, which Backpacks' own JSON should never be).
**The sidecar** adds `POST /backpacks/read`, a thin forward like `/kits/uses`. It keeps nothing.
**The website (Module-Rust)** serves `GET /player/rust/backpacks?serverId=&steamId=` for the caller's own
linked accounts (rate-limited) and `GET /admin/users/:id/rust/backpacks?serverId=&steamId=` for staff, scoped to
that user's links. **Each view is one read; nothing is stored, and nothing polls.** It lends a bigger backpack
through the `rust.backpack.size` step (*Bigger backpack*, `risk: 'change'`, `reversible: 'ledger'`, budget
`rust.backpacks`): one `rust_perm_run_grants` row per recipient with `backpacks.size.N` as its permission and no
kit, pushed by the next `perm.sync` and revoked when the run's revert deletes the rows. Its message, a §20.2
notice, defaults to a warning about the take-back.

View File

@@ -292,6 +292,7 @@ website/
│ │ ├── engagementRules.test.js
│ │ ├── eventAuthoring.test.js
│ │ ├── eventCalendar.test.js
│ │ ├── eventStepEditor.test.js
│ │ ├── featureGate.test.js
│ │ ├── format.test.js
│ │ ├── heroLayout.test.js
@@ -792,6 +793,7 @@ website/
│ │ ├── eventSeries.test.js
│ │ ├── eventSpec.test.js
│ │ ├── eventsRoles.test.js
│ │ ├── eventStepEditor.test.js
│ │ ├── eventVerify.test.js
│ │ ├── honeypot.test.js
│ │ ├── htmlShell.test.js