docs(rust): phase 13b as built — the rewards (PLAN.md §29.6–29.9, PROTOCOL.md §16)

- PLAN.md §29.6: D106 (the news switch is a card on the visibility page),
  D107 (tally.open behind EventsEnabled), D108 (a tally is forgotten seven
  days after it opened).
- §29.7 as built: where the build departed from §29.3 (presence asked of
  ZoneManager, idem_key on the run-grant rows, fixed-choice sources) and
  the walk, on both rigs, without a player.
- §29.8 findings: real core refused a camelCase option-source id; Carbon
  refuses a grant to a Steam id it has never seen; panel-created data
  directories are not game-writable.
- §29.9 what is not proven: the player half, now PLAYER_WALK.md's rewards walk.
- PROTOCOL.md §16: protocol 10 — the tally, kits.list, kit credits on
  perm.sync, chat.say, three bounds, the sidecar's five routes.
- The 13b phase row, and §9/§10 correction notes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
This commit is contained in:
2026-09-24 07:49:00 -05:00
parent 5777e0757c
commit 86dc0ac804
3 changed files with 362 additions and 3 deletions

View File

@@ -276,7 +276,7 @@ start the run.
| # | Author this, start it, then | You should see |
|---|---|---|
| 1 | **A `core.lease` on `rust.group.permission`**: target `<server>/default/<the kit's RequiredPermission>`, value `true`, 10 minutes. Open the kit menu before and after starting it | The kit goes from **locked** to **available** when the step runs, and back to locked when the run ends or its ten minutes pass — whichever comes first. Redeem it once while it is open: the items stay in your inventory after it locks again, which is correct (the lease borrowed the *right*, not the loot) |
| 2 | **`rust.zone.open`** at a monument you are standing next to, radius 30, 15 minutes, a name you will recognise. At the console run `rgp.inzone` with you **outside** the zone, then walk in and run it again | `zone_list` at the console lists the zone with your name (`rg-<run>-…`). `rgp.inzone` answers *nobody* for it while you are outside and **your name** once you are in — that is ZoneManager's own `IsPlayerInZone` saying a player is inside a zone the bridge made, which is §28.8's first item. (`rgp.inzone` is the rig-only probe, `RgProbe13`, on both rigs; ZoneManager itself has no command that says who is in a zone.) Nothing reaches the website yet: the bridge subscribes to `OnEnterZone`/`OnExitZone` in 13b, not 13a |
| 2 | **`rust.zone.open`** at a monument you are standing next to, radius 30, 15 minutes, a name you will recognise. At the console run `rgp.inzone` with you **outside** the zone, then walk in and run it again | `zone_list` at the console lists the zone with your name (`rg-<run>-…`). `rgp.inzone` answers *nobody* for it while you are outside and **your name** once you are in — that is ZoneManager's own `IsPlayerInZone` saying a player is inside a zone the bridge made, which is §28.8's first item. (`rgp.inzone` is the rig-only probe, `RgProbe13`, on both rigs; ZoneManager itself has no command that says who is in a zone.) Nothing reaches the website from this step. 13b's tally asks ZoneManager the same question every five seconds rather than subscribing to `OnEnterZone`/`OnExitZone` (PLAN.md §29.7) |
| 3 | **Leave step 2's run going and restart the server.** Rejoin, walk back into the zone and run `rgp.inzone` | `zone_list` shows the zone **back** after the restart, at the same place and with the same name, re-created from the bridge's own record because ZoneManager forgets temporary zones. `rgp.inzone` names you again. On the site the run's zone row still reads `confirmed` |
| 4 | **`rust.crate.place`**: `crate.hackable` ×1 and `crate.elite` ×3 at the same monument, spread 8. Loot one elite crate empty; start the hack on the locked crate. Then cancel the run | The crates are there and lootable, the hack timer runs. At teardown the untouched crates vanish, and the looted one is simply **gone** already — its row reads `reverted` all the same (§15.2: reverting something a player took is a success) |
| 5 | **`rust.npc.place`**: `npc.scientist` ×3 at the monument. Kill one; let the others see you | They are hostile and fight. The one you killed is just gone; at teardown the survivors vanish mid-fight. Every row reads `reverted`. If an NPC spawned **inside a wall or fell through the ground**, note the monument — some offsets land off the navmesh, and that is a finding about where to put things, not a bug to hide |
@@ -291,3 +291,39 @@ a zone there.
the zone goes, the crates and NPCs vanish — with no website action beyond starting and cancelling the
run; a restart in the middle keeps the zone; and nothing a player took or killed shows up as a
failure on the run console.
## The rewards walk (phase 13b, protocol 10)
The parts of [`PLAN.md`](../modules/rust/PLAN.md) §29.4 that need somebody in the game. Everything
around them was walked without a player (§29.7): the recipient modes, the push, the credits in both
directions, the revert, a restart and a wipe, and chat. What is left is a person standing in a zone,
landing a kill, and opening the Kits menu.
**Before you start:**
1. **Kits 4.4.9 is installed on both rigs**, with two test kits: `rgreward` (`RequiredPermission`
`kits.rgreward`, `MaximumUses` 1, 100 wood) and `rgopen` (no permission, `MaximumUses` 1,
100 stones). Both were installed on 2026-09-24.
2. **`EventsEnabled` on**, and **`rust.kit.entitle` switched on** in Admin → Events → Actions with a
`rust.grants` cap. It arrives off: it is a `change`. The participation verbs and
`rust.announce` arrive on.
3. **Your Steam account linked on the site** (`/link` in game, then the code on the site). A reward
reaches only a linked account, and an unlinked winner is named in the step's detail as missed.
4. **You have joined the server at least once.** On Carbon this is not optional: Carbon refuses a
grant to a Steam id it has never seen (§29.8).
| # | Author this, start it, then | You should see |
|---|---|---|
| 1 | **A zone and a tally in it.** Phase 1: `rust.zone.open` at a monument you are near, radius 30, 30 minutes, named `Arena`; `rust.participation.open` on the same server with zone `Arena`, score `both`, kills of `npcs`, 20 minutes; `rust.npc.place` one `npc.scientist.tethered` at the same monument. Phase 2, after 12 minutes: `rust.participation.collect` | Walk into the zone and stay about five minutes. Kill the scientist while you are inside it, then walk out. At the console, `rg.rewards` shows the run's tally with you in it. The run's participants on the site list you, linked, with `seconds` near 300, one kill, and a score near 5 + 5 × 1 = 10 (D99) |
| 2 | **The same, with no zone.** Leave the zone empty and pick score `seconds` | You accrue anywhere on the server. Standing still counts; being dead or asleep does not |
| 3 | **A reward while you are offline.** Phase 1: `rust.participation.open`, score `seconds`, 10 minutes. Be on the server for a few minutes, then **log off**. Phase 2, after 12 minutes: `rust.kit.entitle` with kit `<server>/rgreward` and recipients `everyone` | Log back in and open the Kits menu: **`rgreward` is available**. Redeem it (100 wood). It stays available: the credit is one more use (D103). Redeem it again, and now it is exhausted. `rg.rewards` shows your credit as `applied=1 redeemed=1`. An in-app notice *"You earned rgreward on …"* is waiting if you turned on the `rewards-v1` rule |
| 4 | **A revert after one redemption.** Repeat step 3, redeem once, then cancel the run | The kit locks again: the permission is gone. The wood stays in your inventory, because the redemption stands (R16). `rg.rewards` shows no credit left |
| 5 | **A revert before any redemption.** Repeat step 3 and cancel the run before you open the menu | The kit is locked when you look |
| 6 | **Chat.** `rust.announce` to your server, then to `*` | The line appears in your chat once, even if the step retried. With news switched on for your server (Admin → Rust visibility → *News in game chat*), publishing a news post puts its title in your chat |
**Run steps 1, 3 and 6 on Carbon as well.**
**What counts as a pass:** the tally counts you while you are in the zone and not while you are out
of it; the kill lands on the tally; a reward granted while you were offline is in the menu when you
come back; its extra use works exactly once; and a revert takes away the right to redeem but never
what you already redeemed.

View File

@@ -50,7 +50,7 @@ it is listening without one.
## 2. Versioning
The wire version is a single integer — **9** as of the world verbs (§15) — declared in
The wire version is a single integer — **10** as of the rewards (§16) — declared in
**four** places that must agree:
| Where | Repo |
@@ -207,6 +207,7 @@ Every response carries `X-RustLink-Version`, including `/health` and including e
| `GET /ws` | broadcast | The live feed; sends `{"kind":"ws.hello","protocol":1}` on connect |
| `GET /lease`, `POST /lease`, `POST /lease/release` | the plugin (RPC) | Protocol 8, the leases (§14) |
| `GET /world/monuments`, `GET /world/owned?runId=`, `POST /world/zone`, `POST /world/place`, `POST /world/revert` | the plugin (RPC) | Protocol 9, the world verbs (§15) |
| `POST /tally/open`, `GET /tally/snapshot?runId=`, `POST /tally/close`, `GET /kits`, `POST /chat` | the plugin (RPC) | Protocol 10, the rewards (§16) |
### 4.1 The split between store-backed and live is deliberate
@@ -1536,3 +1537,187 @@ game is down:
| `POST /world/revert` | `world.revert` | The same |
`world.expired` is an `event`, filed and served like every other (§8.1).
## 16. Protocol 10 — the rewards
Added in phase 13b ([`PLAN.md`](../modules/rust/PLAN.md) §29). This protocol records who took part in a
run, what they may redeem, and puts a line in chat. **It adds five commands, one new field on
`perm.sync` and its report, three plugin config keys and one new hook.** As before, the plugin holds
what only the game can know: who stood in a zone, who landed the last hit, and what Kits has
recorded. The website holds the reward, and the sidecar forwards five routes.
### 16.1 The tally
```json
{"cmd":"tally.open","reqId":"r-4","runId":"20","key":"534fca5f…","score":"both","killsOf":"npcs",
"killWeight":5,"holdMs":5400000,"zone":"Walk6 arena"}
```
| Command | Answers | |
|---|---|---|
| `tally.open` | `tally.ok` or `tally.error` | Starts counting for a run. `score` is `seconds`, `kills` or `both`. `killsOf` is `players`, `npcs` or `both`, and is required unless the score is `seconds`. `killWeight` (0–1000, default 5) is read for `both` only. `holdMs` is 1 minute to 7 days (default 7 days). `zone` is optional |
| `tally.snapshot` | `tally.snapshot` or `tally.error` | Who has taken part in `runId` so far. Each `people` row carries `steamId`, `name`, `seconds`, `kills`, `score` and `joinedAt` (epoch ms). The reply also carries `score`, `killsOf`, `killWeight`, `counting`, `remainingMs`, `overflow` and `maxRecipients`, and for a zone tally `zone` and `zoneStanding` |
| `tally.close` | `tally.ok` | Forgets `runId`'s tally. `closed: false` means it was already gone, which is a success |
**A run holds one tally per server.** A second `tally.open` for the same `runId` is refused with
`already-open`. The exception is a repeat of the first one's `key`, which is answered with
`repeat: true`.
**`zone` names a zone that this run opened** (PLAN.md D100), by the `name` its `world.zone` gave it,
compared without case. It is resolved against the world registry (§15.2) when the tally opens. If the
run has no zone of that name, the answer is `no-zone`; if it has two, the answer is
`ambiguous-zone`. Without a zone, the whole server counts.
**Presence** accrues every five seconds. For a zone tally the plugin reads the members from
**ZoneManager's own membership** (`GetPlayersInZone`); for the whole server it reads
`BasePlayer.activePlayerList`. The plugin asks rather than mirroring `OnEnterZone`/`OnExitZone`,
because ZoneManager already holds the set, and a mirror would have to be reseeded every time a zone is
re-created (§15.3).
A tick credits the time that actually passed, capped at a minute. So a longer interval never rewrites
history, and a jump in the clock is not counted as time anybody spent in a zone. Sleepers, the dead,
NPCs and disconnected players do not accrue. A zone that is not standing gains nobody, and the tally
keeps what it has.
**A kill goes to the last hit** (`HitInfo.InitiatorPlayer`, D98). Kills are counted in the two death
hooks the read path already has: a player victim in `OnPlayerDeath`, an NPC in `OnEntityDeath`.
"NPCs" includes animals, as it does for protocol 2's `npcKills`. The killer must be inside the zone
(`IsPlayerInZone`) when the victim dies. Killing yourself is never a kill.
**The score is computed at snapshot.** It is whole `seconds`, or `kills`, or for `both` it is
`minutes + killWeight × kills`, rounded to two places (D99).
**A tally is persisted** in `tallies.json`, beside `world.json`. It survives a plugin reload, a
restart and a wipe, because it records people, not the map. It is written at most once a minute while
counting, and on every open, close, unload and shutdown, so a hard kill loses at most a minute of
presence. After a load, accrual resumes from the moment of the load, never from before the restart.
Counting stops at `holdMs`. **The tally is forgotten seven days after it opened**, whatever its
`holdMs` (D108), so the collect and reward steps that come after it have until then.
A tally holds at most 5 000 people, which is core's bound per step. Anybody after that is counted in
`overflow`.
### 16.2 `kits.list`
This is the kit catalogue, from Kits' own `GetKitNames` and `GetKitInfo`. Each row has:
- `name`
- `permission`, empty when the kit is open to everybody
- `max`, the kit's `MaximumUses`, or 0 for no limit
- `cooldown`, `hidden` and `description`
The reply also carries `maxRecipients`, `maxTallies`, `maxChat` and `eventsEnabled`. When Kits is not
loaded the answer is `kits.error` with `kits-missing`.
### 16.3 Kit credits on `perm.sync`
A reward is two things (PLAN.md D103):
- the kit's `RequiredPermission`, pushed as a grant like any other (§10);
- **one more use of the kit** for the account that earned it.
The uses travel on the sync:
```json
{"cmd":"perm.sync", …, "credits":[{"steamId":"76561190000000004","kit":"rgreward","count":1}]}
```
`credits` is the website's **whole** set for this server, like the rest of the sync, so a player and
kit it no longer names have earned nothing. **An absent field is not an empty one.** A sync from an
older website carries no `credits`, and leaves every credit alone.
Kits cannot be told about a use in advance. Its `SetPlayerKitUses` does nothing for a player with no
usage record of that kit, and a record exists only after a redemption. So the plugin keeps
`credits.json`, with one row per player and kit:
- `Earned`: the site's number;
- `Applied`: uses moved into Kits' counter;
- `Consumed`: moved uses that have since been redeemed.
It settles each row in three steps, at the end of every sync and after every `OnKitRedeemed`:
1. **Notice a redemption.** A moved credit left the use count one below the kit's limit. If the count
is back at the limit, the credit was redeemed. This also catches a redemption made while the
plugin was unloaded.
2. **Withdraw what the site took back.** When `Earned` drops below `Applied`, each moved use not yet
redeemed is put back, never above `MaximumUses`. A redemption already made stands (R16), and it
is not held against a credit the player earns later.
3. **Spend the next credit, last.** This happens only when the player has used the kit up, and one
credit at a time: the plugin sets the count to `MaximumUses − 1` and reads it back. A kit with no
use limit has no "one more use", so its permission is the whole reward.
The report gains four fields:
- `creditsApplied` and `creditsWithdrawn`, read back from Kits, as `notLanded` is for grants;
- `creditsUnresolved`, the kits this server does not have;
- `creditsWaiting`, true when Kits is not loaded, so nothing could be settled.
**At a new save**, Kits may wipe its own use counts. If Kits' config has `Wipe player data when the
server is wiped` set, every `Applied` and `Consumed` is reset to zero, because the uses they describe
have just been wiped. `Earned` stays, because the website still says it was earned. The plugin reads
the setting from `Kits.json` in the framework's config directory, because it is not in Kits' API.
### 16.4 `chat.say`
```json
{"cmd":"chat.say","reqId":"r-28","key":"news:9","message":"Wipe tonight at 20:00","event":true}
```
This says one line to everybody on the server (`Server.Broadcast`), flattened to one line. A `key`
said in the last ten minutes is answered `chat.ok` with `said: false, repeat: true`, and the line is
not said again, so a retried step never repeats itself. `chat.ok` also carries `players`, the number
of players online.
**A line with `event: true` is behind `EventsEnabled`.** A news post is sent without it, because the
website's per-server switch (D104) is its consent. The refusals are `events-disabled`, `malformed`
and `too-long`.
### 16.5 `EventsEnabled`, and three more bounds
`EventsEnabled` gates `tally.open` (D107) and a run's `chat.say`. `tally.snapshot`, `tally.close` and
`kits.list` are never behind it.
Three config keys are written into an existing config the first time protocol 10 loads. An operator
may lower each one, and a value above its ceiling is clamped, as in §15.4:
| Key | Default | Bounds |
|---|---|---|
| **`EventsMaxRecipients`** | 100 | The most people one reward step may name. The plugin publishes it, and the website refuses a step over it rather than trimming it |
| **`EventsMaxTallies`** | 8 | Tallies counting at once. Over it, `tally.open` is refused `too-many` |
| **`EventsMaxChat`** | 256 | Characters in one line |
**`tally.error`, `chat.error` and `kits.error` reasons**:
| `reason` | Means | Worth retrying |
|---|---|---|
| `events-disabled` | `EventsEnabled` is off | no |
| `malformed` | a missing or unknown field | no |
| `out-of-range` | a weight or duration outside the plugin's bounds | no |
| `already-open` | this run already has a tally on this server | no |
| `no-zone` / `ambiguous-zone` | the run opened no zone of that name, or two | no |
| `too-many` | `EventsMaxTallies` are already counting | no |
| `no-tally` | this run has no tally on this server | no |
| `too-long` | the line is over `EventsMaxChat` | no |
| `kits-missing` | Kits is not loaded | no |
### 16.6 The hook, and the console
`OnKitRedeemed(BasePlayer, string)` joins the declared hooks, and like every other it is void.
**`rg.rewards`** prints the tallies (run, score, zone, people, time left) and every kit credit
(earned, applied, redeemed).
### 16.7 The sidecar
`PROTOCOL_VERSION` becomes 10. There are five routes, each a correlated round trip that fails when
the game is down:
| Route | Command | |
|---|---|---|
| `POST /tally/open` | `tally.open` | Opaque object; `cmd` and `reqId` written over the caller's |
| `GET /tally/snapshot?runId=` | `tally.snapshot` | `runId` required |
| `POST /tally/close` | `tally.close` | Opaque object |
| `GET /kits` | `kits.list` | |
| `POST /chat` | `chat.say` | Opaque object |
`perm.sync`'s `credits` field passes through untouched, like the rest of that body.