docs(rust): phase 13b plan of record (PLAN.md §29) #269
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user