docs(rust): the Economics spike and its design — D335, D336 (PLAN_REDESIGNS §11.2)

§11's first item. Economics 3.9.2 was pulled from uMod and spiked on
both rigs (Rust 25877464, Oxide 2.0.7826, Carbon 2.0.262); both
frameworks answered identically. §11.2.1 records the API and hooks as
found: an unseen account reads as the starting balance, a deposit can
report true while adding less, withdraw is all or nothing, and an
amount must be a double.

Two decisions from the org lead: coins paid by an event are kept
(D335; core reverts a ledgered step on every terminal path, a normal
finish included), and the richest column ranks only balances above
the starting balance (D336). §11.2.2 is the design to build on
protocol 14; §11.2.3 joins the end-of-plan walk.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-10-12 06:35:40 -05:00
parent 32b419baa5
commit 168208d21c

View File

@@ -9,7 +9,8 @@ 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 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 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 [`../../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). **Every item left is built, tested and PR'd first; every walk — 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). **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).** 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 This is a companion to [`PLAN_FIXES.md`](PLAN_FIXES.md) and [`PLAN.md`](PLAN.md). Where they disagree, this
@@ -885,6 +886,9 @@ 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 | | `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 | | ~~`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 | | `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`) ## 8. MODULE_API 1.13.0 (core, website `main`)
@@ -988,6 +992,13 @@ 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 | | **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 | | **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 |
## 11. First-class optional plugins (D199–D202) ## 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 The org lead named four plugins the module supports as first-class integrations. Kits already is one. The other
@@ -1016,10 +1027,13 @@ Personal GitHub mirrors are not a source: k1lly0u's mirror of Kits is two major
### 11.2 Economics (D200) ### 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 - **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 `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, revert withdraws what the step paid.~~ **Superseded by D335: the coins are kept.** Core reverts a ledgered
takes what is there and says so in the run log. It never goes negative. step at the end of every run, a normal finish included, so a ledgered payment would take back a prize.
- **Balances.** A new read verb, `economics.balances {steamIds[]}` (bounded like `kits.uses`), and the - **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 `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. player's last balance per server. The player's account page and the app show it; staff see anyone's.
@@ -1027,7 +1041,137 @@ Personal GitHub mirrors are not a source: k1lly0u's mirror of Kits is two major
an activity row with the amount before and after. an activity row with the amount before and after.
- **Richest.** A leaderboard column (the last known balance) and a title condition, *Magnate* by default, - **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 admin-renamable like every other category (D175). It is a *best* column in §5.5's sense, the current value
rather than a sum. 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.3 Backpacks (D201) ### 11.3 Backpacks (D201)