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:
@@ -1000,7 +1000,7 @@ Each phase ends with its findings written down, as every workstream here does.
|
||||
| 11 | **Android leg C** (R10). ✅ **Built and walked 2026-09-23 — plan, as built and findings in §26 (D69–D72).** The link check behind personal streams asked `module-uo` and locked the raid alert on every Rust site; it now asks the site's own module and holds back only push-on. A tickle is titled from the inbox row it names, and two links stopped opening the browser. **The relay hop was walked on `ntfy.sh` for the first time on any site**: a rig raid reached the emulator in 19 seconds, and after push was switched off in the app the next raid enqueued in-app only | Android-app + docs | A Rust notification arrives on a phone and can be switched off there |
|
||||
| 12 | **Events: option sources and the leases** (§9, **as corrected by §27**). ✅ **Built and walked 2026-09-24 on both rigs — as built and findings in §27.5–27.7.** 21 keys walked live, two `spawn.max_*` left out; two defects of its own fixed (the Oxide grant owner, `EventsEnabled` not applying); four found outside it and raised. [kit][kit] ch. 5's own ordering — leases before actions — and every key verified live before it is advertised. **Plan of record in §27 (D73–D79)**: the target names the server, game convars only (vanilla Rust has no gather/craft/smelt rate), the weekend-VIP lease is a group-wide permission, an `EventsEnabled` switch off by default, seven-day holds, and no budget dimension until phase 13; protocol 8 | Module-Rust + 2 + docs | A leased value is observed changing in the running game and restored, per key; `rust.group.permission` expires without core asking |
|
||||
| 13a | **Events: the world verbs** (§9, R17, **split by D80**). ✅ **Built and walked 2026-09-24 on both rigs — plan in §28 (D80–D95), as built and findings in §28.5–28.8 (D96–D97).** A restart mid-run found that the reconcile asked a world that had not loaded, and the plugin pruned live crates on the empty answer; fixed with `worldReady` (§28.7). The owner fix was walked before and after. The placing verb became two (D97), because core infers cap boxes from examples. The phase-7 owner fix (D85); `rust.zone.open` and `rust.prefab.place` (crates and NPCs, D88) at a monument or raw coordinates (split into `rust.crate.place` and `rust.npc.place` by D97); the monument and prefab option sources; the plugin's ownership registry, keyed by the idempotency key; and **`reconcile()` with the boot-id and wipe watch calling `ctx.events.reconcile()`** (§11.1). Protocol 9 | all 3 + docs | A wipe reconciles the ledger instead of stranding it, and each world verb's teardown is observed in the game |
|
||||
| 13b | **Events: the rewards** (§9, R3, R16). The participation tally kept by the game (D81–D83), `rust.kit.entitle` through the site mirror's per-run rows (D84) with the kit option source flagging kits with no permission gate, and `rust.announce` with the announce leg (D90). Plan of record in §29, written before its code. Protocol 10 | all 3 + docs | A reward granted at 03:00 is waiting in the kit menu when the player next logs in, and a revert withdraws it |
|
||||
| 13b | **Events: the rewards** (§9, R3, R16). ✅ **Built and walked 2026-09-24 on both rigs, without a player — plan in §29 (D98–D105), as built and findings in §29.6–29.9 (D106–D108).** Real core refused the module over one camelCase option-source id, which 310 green tests had not caught (§29.8). The steps that need a person are the rewards walk in `PLAYER_WALK.md`. The participation tally kept by the game (D81–D83), `rust.kit.entitle` through the site mirror's per-run rows (D84) with the kit option source flagging kits with no permission gate, and `rust.announce` with the announce leg (D90). Plan of record in §29, written before its code. Protocol 10 | all 3 + docs | A reward granted at 03:00 is waiting in the kit menu when the player next logs in, and a revert withdraws it |
|
||||
| 14 | **The live map** (R9). The map image over the bridge — request/reply, two-stage, one in flight, its own derivation version, no import on boot — plus the live layers and a per-layer public/players/admin switch built on **our own** visibility layer (§11.2 — `shardVisibility` is `module-uo`'s, not core's) | all 3 + docs | The map renders for the current wipe, and a player layer is invisible until an operator deliberately opens it |
|
||||
| 15 | **Android leg D** (R10). Map and events | Android-app | The map renders on a phone with the same layer gates |
|
||||
| 16 | **Discord slash commands** (R11). A small read-only set, every refusal deferred ephemeral | Module-Rust + docs | A refusal does not go public in the channel |
|
||||
@@ -1202,6 +1202,12 @@ its reasoning as the starting point rather than inventing a parallel set.
|
||||
> location is a monument by kind and instance or raw coordinates (D87, D93), `prefab.place` covers
|
||||
> crates and NPCs with NPCs on their own `rust.npcs` budget (D88, D89), and a reward's recipients
|
||||
> come from a participation tally the game keeps (D81).
|
||||
>
|
||||
> **And by phase 13b (§29, 2026-09-24).** `rust.announce` goes to chat through `Server.Broadcast`, not
|
||||
> PopupNotifications, to one server or every server (D105). Two tally verbs join the actions,
|
||||
> `rust.participation.open` and `.collect`. `rust.options.kits` reads `GetKitNames` / `GetKitInfo` and
|
||||
> flags a kit by what a reward of it gives (permission, uses, or nothing). The fixed choices ride on
|
||||
> lowercase option sources, because core has no enum type (§29.7).
|
||||
|
||||
### Budgets — what core counts and bounds
|
||||
|
||||
@@ -1276,6 +1282,9 @@ which is exactly why R16 could change what it grants without touching anything o
|
||||
> **Corrected by §25 (2026-09-23).** The table below is the catalogue as first planned. The one that
|
||||
> shipped is §25.2: `self` is not a ceiling core has, `rust.clan.member.added` duplicates core's own
|
||||
> Team trigger, `rust.kit.entitled` waits for phase 13, and the two player audiences were one set.
|
||||
>
|
||||
> **`rust.kit.entitled` shipped in phase 13b (§29.3)**, with ceiling `owner` (not `self`), the run and
|
||||
> step as its subject, and its own `rewards-v1` rule group.
|
||||
|
||||
Added 2026-09-15, answering "check the default alerts Rust can expose". R7 settled that the set
|
||||
ships; this is what goes in it.
|
||||
@@ -4926,6 +4935,135 @@ them before code.
|
||||
- **The news leg sends the title when there is one**, because a full article does not fit a chat
|
||||
line.
|
||||
|
||||
### 29.6 Three more decisions the build needed
|
||||
|
||||
All three came from the org lead on 2026-09-24. They were asked mid-build because the tree and the
|
||||
plan answered differently.
|
||||
|
||||
| # | Decision |
|
||||
|---|---|
|
||||
| **D106** | **The news switch is a card on Admin → Rust visibility.** D104 put the switch "on the server's admin page", but this module has no per-server admin page: servers are configured only through `PUT /admin/rust/servers/:id`. The visibility page is the one page that lists every server with a setting of its own, and it answers the same kind of question: what a server shows. The switch is one checkbox per server, saved with the rest of the page by the same `PUT`, as `news: { <serverId>: true \| false }`. Rejected: a new servers page, and an API-only switch. |
|
||||
| **D107** | **`tally.open` is behind `EventsEnabled`**, like the world verbs (D94). Reading and closing a tally never are. One consent covers everything an event does on a server unattended, and with events off no tally opens, so nobody can earn a reward from one. Rejected: leaving it ungated because a tally only observes. |
|
||||
| **D108** | **A tally is forgotten seven days after it opened, whatever its minutes.** The plan had it close at `minutes` without saying how long it stays readable after that, and collect and the reward steps come after it closes. The org lead chose one ceiling for everything over my recommendation (kept 24 hours past its deadline). So a tally with the full seven days of `minutes` leaves nothing for its collect: an author should give a tally fewer minutes than the time its reward steps need. Rejected: 24 hours past the deadline, and until teardown only. |
|
||||
|
||||
### 29.7 As built, 2026-09-24
|
||||
|
||||
[Rust-Plugins][rp], [Rust-Link][rl] and [Module-Rust][mr] on `feat/phase-13b-rewards`, into `edge`, as
|
||||
protocol 10 ([`PROTOCOL.md`](../../rust-link/PROTOCOL.md) §16). **Core is unchanged and `MODULE_API`
|
||||
did not move.**
|
||||
|
||||
| Repo | What |
|
||||
|---|---|
|
||||
| Rust-Plugins | `tally.open` / `.snapshot` / `.close` with `tallies.json`, presence from ZoneManager's membership and kills from the two death hooks; `kits.list`; the `credits` field on `perm.sync` with `credits.json` and the three-step settle, `OnKitRedeemed`, and the reset at a Kits wipe; `chat.say`; `EventsMaxRecipients` / `EventsMaxTallies` / `EventsMaxChat`; `rg.rewards` |
|
||||
| Rust-Link | Five forwards and `PROTOCOL_VERSION` 10 |
|
||||
| Module-Rust | `rust.participation.open` / `.collect`, `rust.kit.entitle` and `rust.announce`; budgets `rust.grants` and `rust.announcements`; `rust.options.kits` and five fixed-choice sources; `rust_perm_run_grants` unioned into the push, with credits on the sync; the `rust.chat` leg and `rust_servers.announce_news` with its visibility card; `rust.kit.entitled` and the `rewards-v1` rule group |
|
||||
|
||||
**Where the build departed from §29.3, and why:**
|
||||
|
||||
- **Presence is asked of ZoneManager, not mirrored from its hooks.** §29.3 had a member set fed by
|
||||
`OnEnterZone`/`OnExitZone` and reseeded when a zone is re-created. ZoneManager already holds each
|
||||
zone's members, and `GetPlayersInZone` returns them. So the tick asks it, and `IsPlayerInZone`
|
||||
settles a kill. No zone hook is subscribed at all, which is R17's "subscribe selectively" taken as
|
||||
far as it goes, and there is no set that can go stale.
|
||||
- **`rust_perm_run_grants` also carries `idem_key`**, core's idempotency key for the step. Core
|
||||
hands `revert` only `{ runId, resources, idempotencyKey }`. When core lost the answer and holds no
|
||||
resource, the key is the only thing that can find the rows the lost answer wrote. Without it, the
|
||||
rows would be a grant nothing could ever withdraw.
|
||||
- **Core has no enum param type**, so `score`, `killsOf`, `recipients` and `rust.announce`'s server
|
||||
are strings with fixed-choice option sources: `rust.options.scoremodes`, `.killsof`,
|
||||
`.recipientmodes` and `.chatservers` (the last also offers `*`). `rust.options.runzones` is
|
||||
declared and answers nothing, so its field is a documented free-text box. Each is lowercase for a
|
||||
reason §29.9 gives.
|
||||
- **Kits' `WipeData` is read from `Kits.json`**, because it is not in Kits' API. The config directory
|
||||
comes from the framework (R19).
|
||||
- **The ranked modes (`everyone`, `top`, `topPercent`) count only a score above zero.** "The highest
|
||||
scores" of a tally where nobody scored is nobody. `random` draws from everyone who took part, which
|
||||
is what a raffle is, and `minScore` is exactly its bar, so `minScore 0` is everyone present.
|
||||
- **Two accounts one user holds are one reward.** The credit goes to the account that scored higher.
|
||||
D103's "one win is one use" read at the level of the person, as D28 reads grants.
|
||||
- **`*` is priced at the enabled servers the module last saw.** `cost()` is synchronous and cannot
|
||||
ask the database. The count is refreshed by every poll, and it is one until the first poll.
|
||||
|
||||
**Walked against real core on `main`** (the `rustp12` database, this module staged from the branch).
|
||||
Event runs were authored through the admin API. The game's own view was read through `rg.rewards`,
|
||||
`oxide.show perm` and the sidecar, and a tally was seeded by writing `tallies.json` while the bridge
|
||||
was unloaded, which is the "tally seeded with fixed scores" of §29.4 step 5. Five throwaway website
|
||||
users and fake linked Steam ids stood in for players.
|
||||
|
||||
1. **Each mode (step 5).** A seeded tally of seven: one with a score of zero, one whose Steam id is
|
||||
linked to nobody, and one user holding two accounts. The results: `everyone` granted 4 and named
|
||||
the unlinked player as missed; `top 2` granted 3, keeping the tie at 400; `minScore 400` granted 3;
|
||||
`random 2` granted 2; `topPercent 50` granted 3. The random draw, replayed offline from the step's
|
||||
own idempotency key, gave the same two winners in any input order. Collect filed all seven, with
|
||||
the website user where linked. The budget consumed 304 (100 + 2 + 100 + 2 + 100): every mode that
|
||||
is not a count is priced at the bound, as §29.2 says.
|
||||
2. **The push and the credits.** The sync granted `kits.rgreward` to all five linked accounts (both
|
||||
of the two-account user's) with `notLanded` empty, so D85 holds for a permission Kits owns. It
|
||||
held one credit per reward on the account that played (4, 5, 5, 1), and **applied none**, because
|
||||
none of them had used the kit and a credit is spent last. When a Kits usage record showing the
|
||||
kit used up was seeded for one of them, the next sync reported **`creditsApplied: 1`**, read back
|
||||
through Kits.
|
||||
3. **The revert, and an admin grant that survives it (steps 3 and 4).** An admin grant of the same
|
||||
kit was given to one winner, then the run was cancelled. Teardown reverted all six resources and
|
||||
deleted every row. The next sync revoked four accounts and **kept the admin-granted one**, and
|
||||
reported **`creditsWithdrawn: 1`**: the applied, unredeemed credit was put back into Kits. The
|
||||
plugin then held no credits, and the tally was closed.
|
||||
4. **A zone tally across a restart (step 6).** A run opened a zone named *Walk6 arena*, a tally in
|
||||
it by that name (`both`, NPC kills, weight 5), and a scientist. The name resolved to the run's
|
||||
zone. One seeded person, 12 minutes and one kill, read back with a score of **17** (D99). After a
|
||||
server restart, under a new boot id: the tally still held the person, counting resumed, the zone
|
||||
was re-created, and the reconcile the watch asked for kept the tally and zone in force and
|
||||
orphaned the NPC (§15.3).
|
||||
5. **A credit across a wipe (step 6).** With a credit applied to Kits and Kits' `Wipe player data`
|
||||
switched on, the save was moved aside so the server booted into a new wipe. The plugin logged
|
||||
*"Kits wipes use counts at a new save, so 1 kit credits start over"*: `earned` stayed 1 and
|
||||
`applied` returned to 0, so the credit cannot be double-spent. Both tallies were kept, and the
|
||||
zone tally's zone went with the old map (`zoneStanding: false`). The first sync on the new boot
|
||||
id met a world still loading and got a 504. It retried after the two-minute backoff and found
|
||||
both grants `alreadyCorrect`. The save was then put back.
|
||||
6. **Chat (step 7).** `rust.announce` to one server said the line there. To `*`, with the bridge
|
||||
unloaded on the Carbon rig, the step succeeded, naming Oxide as `said` and Carbon as `down`. The
|
||||
same key sent twice was said once (`repeat: true`). A published news post was said on the server
|
||||
whose switch was on (its 29-character title, D104) and not on the one whose switch was off. With
|
||||
every switch off, the leg read `done` and nothing was said.
|
||||
7. **Carbon (step 8).** The same `.cs`, with no conditional compilation, loaded on Carbon with Kits
|
||||
4.4.9 installed there for the first time. On Carbon, `kits.list` answered from Carbon's Kits, a
|
||||
seeded tally scored by kills was collected and rewarded (`top 1`), and a chat line was said.
|
||||
|
||||
### 29.8 What the walk found that the plan did not say
|
||||
|
||||
- **Real core refused the whole module over one option-source id.** `rust.options.runZones` fails
|
||||
core's `EVENT_ID` grammar, which allows only lowercase dotted segments. The first boot logged
|
||||
*module "rust" failed to load*, while 310 green tests had passed, because the fake `api` validates
|
||||
no ids. The ids are lowercased, and `entry.test.js` now holds every action, budget, lease and
|
||||
source id against a copy of the grammar. CI's frozen-manifest job would have caught it too, one
|
||||
push later.
|
||||
- **Carbon will not grant a permission to a Steam id it has never seen.** `GrantUserPermission`
|
||||
answered *"user not found"* for the walk's fake ids, where Oxide creates the user record. The
|
||||
D85 read-back reported the grant `notLanded`, and the site did not record it as pushed, which is
|
||||
exactly what the read-back is for. **It does not reach a real reward**: a recipient comes from a
|
||||
tally, and a tally holds only people who were on that server, so Carbon already has their record.
|
||||
It does reach an admin grant (phase 7) to somebody who has never joined a Carbon server. That grant
|
||||
lands at the first audit sync after they join, as a group membership does (§12.2 rule 4).
|
||||
- **A directory the panel's `files/write` creates is not writable by the game.** Kits failed to
|
||||
initialise with *access denied* on `player_data.json`, in the `oxide/data/Kits/` directory the
|
||||
test-kit upload had created. Making the directory writable fixed it. Phase 18's egg and anybody
|
||||
seeding a plugin's data by hand should create the directory from the game, or set its mode.
|
||||
|
||||
### 29.9 What is not proven here
|
||||
|
||||
- **A real player.** Presence accruing in a zone, a kill landing on the tally, and a redemption in
|
||||
the Kits menu all need somebody in the game. The walk proved everything around them through the
|
||||
real plugin, the real sidecar, real Kits and real core, with seeded tallies and a seeded Kits usage
|
||||
record. Not proven: §29.4 steps 1–3 as written (a person walks into the zone and kills a placed
|
||||
NPC; logs in to a reward granted while offline, redeems it, and redeems it again on the credit; and
|
||||
a revert after a redemption), and step 8's redemption on Carbon. These are in
|
||||
[`PLAYER_WALK.md`](../../rust-link/PLAYER_WALK.md) as the rewards walk.
|
||||
- **`OnKitRedeemed` itself.** The settle it calls was walked from the sync side, including the
|
||||
"used up, so spend" branch. The hook firing after a real redemption was not, since that also needs
|
||||
a person.
|
||||
- **A chat line seen by a player.** The plugin answered `said: true` with `players: 0`.
|
||||
|
||||
---
|
||||
|
||||
[aa]: https://gitea.whitlocktech.com/RunicGateway/Android-app
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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