diff --git a/modules/rust/PLAN.md b/modules/rust/PLAN.md index b3b5a01..3763a34 100644 --- a/modules/rust/PLAN.md +++ b/modules/rust/PLAN.md @@ -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` | `/` | 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': }`, see below | +| `rust.announce` | `notify` | `none` | `server` (or `*`, every server), `message` | `{ 'rust.announcements': }` | + +- **`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: '::' }], 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