docs(rust): phase 13b plan of record (PLAN.md §29) #269

Merged
whitlocktech merged 1 commits from docs/rust-phase-13b into main 2026-09-24 11:33:17 +00:00

View File

@@ -4693,6 +4693,239 @@ run:**
- **The zone deadline through core.** It was walked at the sidecar (a 60-second zone erased itself
and emitted `world.expired`), and not inside a run.
## 29. Phase 13b — the rewards (plan of record, 2026-09-24)
The second half of phase 13 (D80). 13a made things in the world. 13b records **who was there**, gives
them **something they can redeem**, and can **tell the server**. That is three pieces of work:
- **The participation tally.** `rust.participation.open` / `.collect`, counted by the game (D81) in a
zone this run opened, or on the whole server.
- **The reward.** `rust.kit.entitle` and its kit source. It grants the kit's `RequiredPermission`
through the site mirror's per-run rows (D84), and one extra use of the kit (D103).
- **The chat verb.** `rust.announce` and the announce leg (D90), both on one plugin verb.
Protocol 10. Three repositories: [Rust-Plugins][rp], [Rust-Link][rl] and [Module-Rust][mr], into
`edge`. This document goes into `main`. **Core is not expected to change and `MODULE_API` does not
move.** The row's criterion (§5, row 13b): *a reward granted at 03:00 is waiting in the kit menu when the
player next logs in, and a revert withdraws it.*
### 29.0 The decisions this phase needed
D81–D84 and D89–D90 in §28.0 settled most of the shape. These eight came from the org lead on
2026-09-24, asked before this section was written.
| # | Decision |
|---|---|
| **D98** | **Kill credit is chosen per step, and it goes to the last hit.** `participation.open` takes `killsOf`: `players`, `npcs` or `both`. The killer is `HitInfo.InitiatorPlayer` and must be inside the area when the victim dies. Rejected: crediting every damager, which is UO's rule. Rust keeps no damage-entry list, so that rule would need a per-victim damage table fed by `OnEntityTakeDamage`, one of the game's hottest hooks. |
| **D99** | **A "both" score is minutes present plus `killWeight` times kills.** This is UO's formula, but the weight is a **step param** (default 5, so a kill is worth five minutes), not operator config. The plugin computes the score, and core stores a decimal it never interprets. |
| **D100** | **A tally counts only in a zone this run opened**, named by that `rust.zone.open` step's `name` and resolved when the step runs. Without a zone, the whole server counts (D82). Rejected: counting in any ZoneManager zone, such as an operator's permanent arena. That would have put another plugin's zones inside the tally. |
| **D101** | **Who receives a reward is a per-event choice among five modes.** `recipients` is one of `everyone` (every participant who scored above zero), `top` (the N highest scores), `minScore` (a score of at least X), `random` (N drawn from everyone who took part) and `topPercent` (the highest X per cent). A second field, `count`, carries N, X or the percentage. |
| **D102** | **An earned entitlement applies only on the server of the kit it grants.** A kit and its `RequiredPermission` belong to one server's Kits install, and another server may not have that kit at all. |
| **D103** | **Each reward is also one more use of the kit, for the player who earned it.** The site holds the credits beside the permission rows and pushes them like grants. The plugin spends a credit **last**: only when a redemption has used up the kit's own `MaximumUses`. A revert withdraws the credits not yet spent, and a redemption already made is kept (R16's posture). Rejected: credits held in the plugin's own file, which would stop the site being the author of record for half the reward. Also rejected: raising the kit's `MaximumUses`, which would reward everybody. |
| **D104** | **The announce leg posts news in game only on servers that opt in, and the default is off.** Core enqueues every registered leg for every published news post, so without a switch the day this module updates, every news post would start appearing in every server's chat. The switch is per server, on the admin page. A server that is down is skipped, never queued: a chat line is gone the moment it is said, and news posted an hour late in a restarted server's chat is noise. |
| **D105** | **`rust.announce` posts to one server, or to every server.** It costs one `rust.announcements` per server reached. The plugin remembers recent keys, so a retried step never says the same line twice. |
### 29.1 Facts 13b rests on
**From Kits 4.4.9** (`umod.org/plugins/Kits.cs`, pulled 2026-09-24, 3 835 lines):
- **Kits registers every kit's `RequiredPermission` on load**, and only when that permission does
not already exist (line 1224). An entitlement therefore grants a permission Kits owns. That grant
would have silently failed before D85's owner fix, which is why 13a had to land first.
- **`GetKitInfo(name)` answers `permission`, `max` (`MaximumUses`) and `cooldown`**, and
`GetKitNames` lists the kits. This is everything the source needs.
- **The redeem check is permission, then wipe cooldown, then cooldown, then `MaximumUses`**, in
`CanClaimKit` (lines 168–213). None of it can be vetoed per kit: `CanRedeemKit` carries the player
and not the kit, so no hook can say "this player may go over the limit for *this* kit".
- **`SetPlayerKitUses` is a no-op for a player who has no usage record of that kit** (line 3287:
`if (!_usageData.TryGetValue(name, ...)) return`). A record is created only by redeeming. That
means **Kits cannot be told in advance** that a player owns an extra use. D103's credits therefore
have to live outside Kits and be spent from `OnKitRedeemed(player, kitName)`. That hook fires after
a successful redemption, when the record exists.
- **`OnNewSave` clears all usage data when the operator has set `Wipe player data when the server is
wiped`**, which is off by default. A credit that has been moved into Kits' counter and not yet
redeemed is lost with that data. The plugin has to notice this, or the next redemption double-spends.
- **A kit with an empty `RequiredPermission` is open to everybody** (R16). With D103, such a kit is
still a real reward if it has a `MaximumUses`, because the credit is an extra use. Only a kit with
**no permission and no use limit** rewards nothing. The source flags it, and the verb refuses it.
**From the bridge** (`Rust-Plugins`, `edge`):
- **`OnEntityDeath` and `OnPlayerDeath` already exist** and already read `info.InitiatorPlayer`. An
NPC victim is a `BasePlayer` with `IsNpc` (scientists, bandits, tunnel dwellers) or a `BaseNpc`
(animals). Protocol 2's `NpcKills` counter counts both. The tally hooks into these two methods; it
does not add new ones.
- **The zone registry from 13a** knows every zone this run opened, by run id and by the step's
`name`. That is how D100 resolves a zone name to a ZoneManager id.
- **`OnEnterZone` / `OnExitZone` are not subscribed today.** 13b subscribes them only while at least
one tally names a zone (R17's "subscribe selectively").
- **There is no chat verb.** Every line the bridge has sent a player so far has been
`PrintToChat` to one player (the link code).
**From core** (`website`, `main`):
- **A leg's `dispatch(post)` gets a post and nothing else**: no server and no run. That is why D104's
switch is per server and lives in this module.
- **`core.announce` can name any registered leg**, so an operator can also reach Rust chat through
core's own verb. It reaches the same servers the switch allows. `rust.announce` is the way to pick a
server.
- **Participants ride the success envelope** (`events/participants.js`): `memberKey` (at most 190
characters), an optional `userId`, `score`, `joinedAt` and an opaque `meta`, with at most 5 000 per
step. `UNIQUE (run_id, member_key)` makes a retried collect an upsert.
- **An action is never handed the participants.** So `rust.kit.entitle` reads the tally from the
plugin, as `uo.item.grant` reads it from the shard. It does not depend on a collect step having run.
### 29.2 The 13b catalogue
**Budgets** (D89): `rust.grants` (entitlements granted: one per recipient, each carrying its
permission and its credit) and `rust.announcements` (chat lines: one per server reached).
**Option sources**:
| Source | Values | Filled from |
|---|---|---|
| `rust.options.kits` | `<serverId>/<kit name>` | each server's live `GetKitNames` plus `GetKitInfo`. The label flags **"open to everyone"** (no permission), **"N uses"** (a `MaximumUses`), and **"rewards nothing"** (neither) |
| `rust.options.runZones` | a zone `name` | none: free text, checked at dispatch. The zones a run will open do not exist when it is authored |
**Actions**:
| Action | `risk` | `reversible` | Params | `cost()` |
|---|---|---|---|---|
| `rust.participation.open` | `inspect` | `ledger` | `server`, optional `zone` (D100), `score` (`seconds` / `kills` / `both`), `killsOf` (D98, required unless `seconds`), `killWeight` (D99, `both` only, default 5), optional `minutes` | none |
| `rust.participation.collect` | `inspect` | `none` | `server` | none |
| `rust.kit.entitle` | `change` | `ledger` | `kit` (from the source, which names the server), `recipients` (D101), `count` | `{ 'rust.grants': <cap> }`, see below |
| `rust.announce` | `notify` | `none` | `server` (or `*`, every server), `message` | `{ 'rust.announcements': <servers> }` |
- **`participation.open` is `inspect` but ledgered**, as `uo.participation.open` is: the game holds a
tally for the run and teardown gives it back. A tally closes itself after `minutes`, or after
7 days if `minutes` is not set, so a tally never outlives its event even with the site gone (the
D96 rule for zones). Each server holds at most `EventsMaxTallies` (default 8), and one run holds at
most one tally per server.
- **`collect`** files each person as `memberKey` = Steam id (this module's member vocabulary, as in
the team provider) and `userId` from `rust_account_links`. `meta` carries `name`, `seconds` and
`kills` so a results table can say why somebody scored what they did.
- **`kit.entitle`'s cost is the most it could grant**, because it is priced before the tally is read:
`count` for `top` and `random`, and the server's `EventsMaxRecipients` (default 100) for
`everyone`, `minScore` and `topPercent`. The step's `detail` reports what was actually granted.
Core charges the price declared, so an author who wants a tight cap uses a count-based mode.
- **`topPercent` rounds up and keeps ties**, the same as `top`: 10 % of 11 people is 2, and everyone
tied with the last one in is also in. `random` draws with a generator **seeded from the step's
idempotency key**, so a retry after a lost answer draws the same winners.
- **`rust.announce` is `retry`-safe but not revertible**: a line said in chat cannot be taken back.
A server that is down makes the step `retry` when it is the only server named. With `*`, the step
succeeds for every server that took the line and names the rest in `detail`, for D104's reason.
### 29.3 What 13b builds
**Rust-Plugins**: protocol 10, and `overlay.toml`'s protocol advanced to match.
- **The tally** (`tally.open`, `tally.snapshot`, `tally.close`). It is persisted in a data file beside
`world.json`, keyed by run id, so a restart or a reload keeps it. A wipe keeps it too, because it
records people, not the map. A zone tally whose zone the wipe erased stops gaining presence and
keeps what it has.
- **Presence** accrues on a 5-second timer. For each tally, it walks either the members inside its
zone (a set fed by `OnEnterZone`/`OnExitZone`, reseeded from ZoneManager when the zone is
re-created) or `BasePlayer.activePlayerList`. It accrues the seconds that actually passed, as
UO's does, so changing the interval never rewrites history. Sleepers and the dead do not accrue.
- **Kills** are added in the two existing death hooks, gated by `killsOf` and by the killer being
inside the area. Killing yourself is never a kill.
- **The score is computed at snapshot**: seconds, kills, or `minutes + killWeight × kills`.
- **`kits.list`**: `GetKitNames` plus `GetKitInfo`, for the source. If Kits is not loaded, this is
refused with the reason, as every base-plugin dependency is.
- **The credits (D103)**. `perm.sync`'s payload gains `credits: [{ steamId, kit, count }]`. The plugin
keeps, per player and kit, how many credits the site says they have and how many are **spent**.
- Spend-last rule: **after** `OnKitRedeemed`, if the kit has a `MaximumUses`, the player's uses
have reached it, and a credit is unspent, the plugin lowers the Kits use count by one and marks
one credit spent. The same check runs whenever a sync raises a player's credits, so a player
who used up the kit before earning the reward can redeem it at once.
- When a sync lowers credits below `spent` (a revert), the plugin puts back each lowered use that
has **not** been redeemed since, and keeps what has. It never raises a use count above
`MaximumUses`.
- When Kits' usage data is empty for a player after `OnNewSave`, the plugin resets that player's
`spent` to zero. The credits stay, because the site still says they were earned.
- The report counts `creditsApplied`, the way `notLanded` counts grants.
- **`chat.say`**: `Server.Broadcast` of one flattened line, with a bounded length (`EventsMaxChat`,
default 256), and a remembered set of recent keys (10 minutes) so a repeat is answered `ok` and not
said again. Gated by `EventsEnabled` when a run sends it. The news leg is gated by the site's switch
(D104), not by `EventsEnabled`, because news is not an event.
**Rust-Link**: protocol 10. `POST /tally/open`, `GET /tally/snapshot?runId=`, `POST /tally/close`,
`GET /kits` and `POST /chat`, stamped and forwarded like the world routes. `perm.sync`'s new field
passes through untouched.
**Module-Rust**:
- **`rust_perm_run_grants`**: one row per run, step, user and permission, holding `server_id`,
`steam_id` (the account that took part), `kit`, `credit` (0 or 1) and `granted_at`. This is D84's
per-run table. `buildDesired` unions its permission rows with `rust_perm_grants` for the server,
reaching every linked account as D28 does. Credits go to **the Steam id that took part**, never to
every linked account, because one win is one use. The `purge.sql` pair is updated with it.
- **`rust.kit.entitle`**: `perform` validates the kit (it exists, and it rewards something), the
mode and `count`, and answers `verify` there. Then it reads `tally.snapshot`, picks recipients by
the mode, writes rows for the **linked** ones, marks the server dirty, and answers
`{ ok: true, resources: [{ kind: 'entitlement', ref: '<server>:<runId>:<stepId>' }], detail }`.
`detail` holds granted, missed (unlinked, named by in-game name) and the mode. A repeated key finds
its rows already written and changes nothing. `revert` deletes the step's rows and marks the server
dirty, and a missing row counts as success. `reconcile` answers every row as in force: the site
holds the entitlement, so a wipe cannot take it away.
- **`rust.participation.open` / `.collect`**, `rust.announce`, the two budgets and
`rust.options.kits`, all joined to the one `registerEventOptionSources` batch (§28.7).
- **The announce leg `rust.chat`** (label *"Rust in-game chat"*). `dispatch` sends the post's title,
or failing that its excerpt, to every server whose switch is on. `classify` answers `done` when
every switched-on server that is up took the line, or when no server is switched on. It answers
`retry` only when every switched-on server is down.
- **The switch**: `rust_servers.announce_news` (added with an `ALTER`, default 0), and a toggle on the
server's admin page. The admin route change is documented and its swagger fragment regenerated.
- **`rust.kit.entitled`** (deferred from phase 10, D64): emitted once per recipient user when their
rows are written, with ceiling `owner` and the user as `ownerUserId` (§25.1: there is no `self`).
The subject is the run and step, and the payload is the kit's name, the server and the mode, with
an engagement seed in a **new rule group** (R7: an appended rule reaches fresh installs only).
`engagement-triggers.json` is regenerated.
- `ci/bundle.json` and the frozen manifests are regenerated if they move.
**docs**: this section as built, the phase rows, §9's catalogue corrected, and
`rust-link/PROTOCOL.md` §16 for protocol 10.
### 29.4 13b is done when
The row's criterion, plus the parts D98–D105 added. Walked on the Oxide rig against real core, with
event runs authored in the admin UI. **Kits must be installed on both rigs first** (it is on neither,
§27.7), with a test kit that has a `RequiredPermission` and `MaximumUses: 1`.
1. **The tally, with a person.** A run opens a zone, then a tally in it, with `score: both`. The org
lead's account walks into the zone, stays, kills an NPC placed by `rust.npc.place`, and walks out.
`collect` files one participant, who is linked, with seconds, one kill and the D99 score. A second
run with no zone counts the whole server. This is §28.8's unproven "real player in a zone".
2. **The entitlement at 03:00.** `kit.entitle` runs while the player is offline. On their next login
the kit is redeemable in the Kits menu. After the first redemption (the kit's one use), the
credit makes it redeemable once more, and then it is exhausted.
3. **The revert withdraws it.** On a second run: teardown before redemption leaves the kit locked
again. With a redemption made, the permission goes and the redemption stands.
4. **An admin grant of the same kit survives an event's revert** (D84's reason for the table).
5. **Each recipient mode** gives the expected set against a tally seeded with fixed scores. `top` and
`topPercent` keep ties, a retried `random` draws the same winners, and an unlinked participant is
named as missed.
6. **Restart and wipe.** A server restart mid-run keeps the tally. A wipe keeps the entitlement,
because the site re-pushes it, and with Kits' `WipeData` on, the credit is not double-spent.
7. **Chat.** `rust.announce` to one server and to `*` with one server down. A published news post
reaches the chat of a switched-on server and not a switched-off one. With every server off, the
leg reads `done`.
8. **Carbon.** The tally, one entitlement redeemed, and one chat line on the Carbon rig.
### 29.5 Readings the org lead may overrule
The build needs these, and none of them was asked. They are written down so review can overrule
them before code.
- **"NPCs" includes animals**, because protocol 2's `NpcKills` already counts them and a tally that
disagreed with the leaderboard would be a second meaning of the same word.
- **A run holds one tally per server.** A second `participation.open` for the same run on the same
server is refused, unless it repeats the first one's key.
- **`EventsMaxRecipients` (100) and `EventsMaxTallies` (8)** are new plugin bounds. They are refused,
never clamped (D95's rule), and the site mirrors them.
- **The news leg sends the title when there is one**, because a full article does not fit a chat
line.
---
[aa]: https://gitea.whitlocktech.com/RunicGateway/Android-app