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:
2026-09-24 07:49:00 -05:00
parent 5777e0757c
commit 86dc0ac804
3 changed files with 362 additions and 3 deletions

View File

@@ -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