docs(rust): protocol 14 — Economics, as built (PROTOCOL §20.3, PLAN_REDESIGNS §11.2.4)

PROTOCOL.md §20.3 specifies what §11.2.2 designed and the code now does:
integrations.economics, economics.pay / .balances / .adjust and their replies
and refusals, the tally's `balance` (a value; a nameless frame is an offline
player), economics.wiped with `newSave`, the three sidecar forwards, and the
website's half. §20's heading now covers the optional plugins too.

PLAN_REDESIGNS §11.2.4 records the build (compiled on both rigs, then given
back their protocol 13 bridge) and names the six points the design left open
that the code settled, so the org lead can overrule any of them. The app
plan's Rust account row gains GET /player/rust/economics.

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 07:14:51 -05:00
parent 0f4d99c9fa
commit 6fad1f07af
3 changed files with 130 additions and 3 deletions

View File

@@ -2641,7 +2641,7 @@ loaded.
---
## 20. Protocol 14 — the step editor
## 20. Protocol 14 — the step editor and the optional plugins
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,3 +2701,109 @@ 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).