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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user