docs(rust): phase 13 plan, and 13a as built (PLAN.md §28, PROTOCOL.md §15) #268

Merged
whitlocktech merged 4 commits from docs/rust-phase-13 into main 2026-09-24 10:07:19 +00:00
Showing only changes of commit 0de9d7f90d - Show all commits

View File

@@ -999,7 +999,8 @@ Each phase ends with its findings written down, as every workstream here does.
| 10 | **Notifications and engagement** (R7). ✅ **Built and walked 2026-09-23 — plan, as built and findings in §25 (D59D68).** The criterion walked on the Oxide rig (two authorised owners alerted once each; bystander, raider and an unlinked owner told nothing), and the same plugin loaded on Carbon; a live walk found that every generic notice never said which server (§25.6). Streams, triggers with `ceiling` and `subjectKey`, audiences and engagement seeds — **the catalogue is §10, as corrected by §25.2**. The announce leg and the post hook are **not** in it (D62 amends R7), and protocol 7 widens the raid frame, which brings the two bridge repositories in | all 3 + docs | The offline raid alert reaches the player whose base it was, and nobody else |
| 11 | **Android leg C** (R10). ✅ **Built and walked 2026-09-23 — plan, as built and findings in §26 (D69D72).** 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.527.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 (D73D79)**: 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 |
| 13 | **Events: the actions** (§9, R3, R16). `rust.kit.entitle` first, then `rust.prefab.place` and `rust.announce`; `reversible: 'ledger'`; the kit option source flags kits with no permission gate, plus **`reconcile()` and the boot-id watch calling `ctx.events.reconcile()`** (§11.1) | 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; a wipe reconciles the ledger instead of stranding it |
| 13a | **Events: the world verbs** (§9, R17, **split by D80**). **Plan of record in §28 (D80D95).** The phase-7 owner fix (D85); `rust.zone.open` and `rust.prefab.place` (crates and NPCs, D88) at a monument or raw coordinates; 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 (D81D83), `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 |
@@ -1196,6 +1197,11 @@ its reasoning as the starting point rather than inventing a parallel set.
> became the group-wide `rust.group.permission` (D75). Every lease's target names its server (D73).
> And *"caps are per server"* holds only for a run an admin starts by hand: a scheduled run always
> has the empty scope. The tables below are the plan as first written.
>
> **Corrected again by phase 13 (§28, 2026-09-24).** `rust.zone.open` is scheduled (13a, D86), a
> 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).
### Budgets — what core counts and bounds
@@ -4356,6 +4362,212 @@ the pair stayed granted (`already true`), because the site had asked for it.
intended behaviour (`already true`), and the cause turned out to be the core cleanup finding above,
not the plugin.
**Three of the four findings are filed against core** (2026-09-24): [website#205][w205] (cleanup
restores an orphaned lease row), [website#206][w206] (`core.lease` drops `retry: false`) and
[website#207][w207] (`core.lease`'s two calls under the default budget). The fourth, the phase-7
mirror's owner bug, belongs to this module and is fixed in phase 13a (D85).
## 28. Phase 13 — the actions (plan of record, 2026-09-24)
The phase where an event makes something that did not exist. The row named three verbs, one option
source and the reconcile watch. The decisions below made it two world verbs with NPCs, a monument
source, a participation tally kept by the game, the entitlement, and the announce action with its
leg. So it is **split in two**, the way Events phase 16 was (D80):
- **13a, the world**: the owner-bug fix, `rust.zone.open`, `rust.prefab.place`, the monument source,
the plugin's ownership registry, and `reconcile()` with the boot and wipe watch. Protocol 9.
- **13b, the rewards**: the participation tally, `rust.kit.entitle` with the kit source, and
`rust.announce` with the announce leg. Protocol 10. It builds on 13a's zones. **Its plan of record
is §29, written before its code**, the same way this section is.
Three repositories each time: [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.**
### 28.0 The decisions this phase needed
All from the org lead, 2026-09-24. D80D91 cover both halves. D92D95 came from the rig probe in
§28.1 and apply to 13a.
| # | Decision |
|---|---|
| **D80** | **Phase 13 splits into 13a (the world) and 13b (the rewards)**, each with its own plan section, PRs and walk. 13a comes first because 13b's participation is counted inside 13a's zones. |
| **D81** | **The recipients of a reward are counted by the GAME, not the website.** `rust.participation.open` / `.collect` follow `module-uo`'s shape: the plugin keeps a per-run tally and `collect` reports it to core on the envelope. Rejected (my recommendation): the module derives the tally from the connect and disconnect events it already ingests. That needs no plugin change, but it cannot see presence in a zone or kills credited in one, and it is only as complete as the sidecar's raw window. |
| **D82** | **The tally's area is a zone when the step names one, and the whole server otherwise.** The zone is normally one the same run opened with `rust.zone.open`. It is fed by `OnEnterZone`/`OnExitZone` for zones in the tally only, so no other zone costs anything (R17's "subscribe selectively"). It is persisted, so a restart does not lose it. |
| **D83** | **The score is set per event: seconds present, kills, or both.** It is chosen on the `participation.open` step. (The org lead's own answer, replacing the three options offered.) |
| **D84** | **An earned entitlement is held by the site's permission mirror, in per-run rows.** It gets a table of its own keyed to the website user (D28) and unioned with admin grants into R2's desired set. A revert deletes only this run's rows, so the same kit granted by an admin or by another run stays. A wipe cannot lose it, because the site re-pushes. The cost is stated up front: **only a LINKED participant can receive one.** Unlinked participants are counted in the step's `detail` as missed. Rejected: the plugin grants the Steam id directly from a store of its own. That reaches unlinked players, but the site stops being the author of record. `rust_perm_grants` could not be reused as it stands: it is `UNIQUE (user_id, permission, scope)`, so an event's grant and an admin's grant of one kit are one row, and reverting the event's would delete the admin's. |
| **D85** | **The phase-7 owner bug is fixed inside this phase**, as its own commit in 13a's Rust-Plugins PR. `EnsureGrant` and `EnsureGroupPermission` pass a `null` owner and read the grant back, so a grant that did not land is reported and not counted (§27.6). This section says plainly what the site was told wrongly from phase 7 until now. |
| **D86** | **Both world verbs ship in 13a: `rust.prefab.place` and `rust.zone.open`.** No phase row had scheduled `zone.open`, although R17 moved it into the base catalogue. Rejected: deferring it to phase 14 with the map. |
| **D87** | **A location is a monument (plus an optional offset) or raw world coordinates**, one or the other on each step. |
| **D88** | **`prefab.place` may spawn crates and NPCs, and never vehicles.** Vehicles were rejected because a player can take one, and a revert would kill something a person may be sitting in. The allowlist lives in the plugin, and neither the sidecar nor the site can widen it. |
| **D89** | **NPCs are counted on their own budget dimension**, `rust.npcs`, apart from `rust.prefabs`, the way `module-uo` separates creatures from decor. An operator can then allow crates freely and cap fights. 13a declares `rust.prefabs`, `rust.npcs` and `rust.zone.minutes`; 13b declares `rust.grants` and `rust.announcements`. This is D79's "each is declared beside the verb that costs it". |
| **D90** | **`rust.announce` ships with the announce leg** (D62): one chat verb in the plugin serves both. |
| **D91** | **Phase 12's three core findings are filed**: website#205, #206 and #207. |
| **D92** | **Zones are re-created from the plugin's registry.** ZoneManager keeps temporary zones in memory only, and **erases every zone a plugin owns when that plugin unloads** (§28.1). The bridge keeps its zones in its own file and re-creates them in three cases: on its own load, on `OnPluginLoaded(ZoneManager)`, and after a restart. It does this within the same wipe only; a new wipe drops them all. A zone therefore lasts until the run reverts it. Rejected: reporting the zone lost and letting reconcile orphan it. |
| **D93** | **A monument is named by kind and instance, and resolved when the step runs.** The value is `<serverId>/<prefab short name>`, and `#n` picks the n-th of a kind that repeats (`srv-a/harbor_1#2`), numbered in a stable order. A step naming a unique monument therefore survives a wipe onto a new procedural map. A kind the new map does not have is refused `retry: false`, with the reason. The source lists what this wipe's map actually has. Rejected: any instance picked at random, and a pinned position (every saved event would break at the next wipe). |
| **D94** | **`EventsEnabled` gates the world verbs too.** It is one consent: *events may change this world unattended*. As with D76, revert, reconcile and listing always work, so switching events off never strands a crate, an NPC or a zone. |
| **D95** | **Per-step bounds: 25 crates, 20 NPCs.** A 50 m scatter, a monument offset of up to 150 m, a zone radius of 5 to 150 m, and raw coordinates that must lie on this map. They are **refused, never clamped** (link v6 §8.2's rule). They are plugin config that an operator may lower, and the site mirrors them so a dry run shows them. (The org lead chose these over the 10/10 I recommended.) |
### 28.1 Facts 13a rests on
**From the Oxide rig**, 2026-09-24, found with a probe plugin (`RgProbe13`, rig-only, never
shipped):
- **The map reports 85 monuments, and their names repeat.** There are two Harbors (`harbor_1`,
`harbor_2`), two Fishing Villages, two Abandoned Supermarkets, seven Train Tunnels, five Water
Wells and about thirty Substations. Each `MonumentInfo` carries a prefab path, a display phrase, a
tier, a type and bounds. A display name therefore cannot be an option value, which is why D93
exists. The prefab short name is unique per kind, and the instance number separates repeats.
- **The allowlist candidates exist, taken from `GameManifest.Current.entities` rather than from
memory**: 51 crate prefabs, 25 `ScientistNPC_*`, `NPC_Bandit_Guard`, the tunnel and underwater
dwellers, `codelockedhackablecrate.prefab` and `supply_drop.prefab`. Each one the plan names below
was spawned at the Power Plant and reported its type: crates `LootContainer`, the hackable crate
`HackableLockedCrate`, the supply drop `SupplyDrop`, the scientists `ScientistNPC`.
- **A restart is not proof that a placed object is gone, and that is the opposite of UO's town
crier.** Everything was spawned, then `server.save`, then a restart. The crates, the hackable
crate, the supply drop and the loot barrel were all **still there with the same net id**:
`enableSaving` is true on each. All three scientists were **gone**: `enableSaving` is false on
NPCs. So `reconcileByBootId`'s trick is wrong for a crate and right for an NPC. **Reconcile asks
the plugin by net id**, and the plugin answers what the world holds. Only a **wipe** proves that
everything is gone.
- **ZoneManager keeps temporary zones in memory, and its `OnPluginUnloaded` erases every zone the
unloading plugin owns** (`ZoneManager.cs` 3.1.14, line 92). An `oxide.reload RunicGateway`, which
every framework update and every hand reload causes, would silently delete every event zone in the
middle of a run. A reload of ZoneManager itself loses them too, because the whole dictionary goes.
That is D92.
- **`EraseTemporaryZone(owner, id)` erases an unowned zone and returns `true`** (§12.4, still so in
3.1.14). The bridge's own registry decides what it may erase, and never ZoneManager's answer.
**From the bridge** (`Rust-Plugins`, `edge`):
- **There is no at-most-once store.** UO's shard answers a repeated key with the original reply,
and this plugin has nothing like it. Without one, a `prefab.place` retried after a lost answer
places everything twice. **The ownership registry is therefore keyed by core's idempotency key**,
and a repeat of a key answers with the net ids the first call made. The same record answers a
`revert` that arrives with no resources and only the key: *everything placed under that key*.
- **The mirror's owner bug is live** (`RunicGateway.cs` 3144 and 3179, `this` in both).
**From core**: an action is handed `{ runId, stepId, idempotencyKey, scope, params, actor, verify }`
and never the run's participants, which is why D81 needs a tally at all. The timeouts are the
sidecar reply at 10 s and this module's client at 12 s, so every 13a verb declares
`budgetMs: 15000`.
### 28.2 The 13a catalogue
**Budgets** (D89): `rust.prefabs` (crates placed, count), `rust.npcs` (NPCs placed, count) and
`rust.zone.minutes` (zone time held, minutes).
**Option sources**:
| Source | Values | Filled from |
|---|---|---|
| `rust.options.monuments` | `<serverId>/<kind>` or `<serverId>/<kind>#<n>` | each configured server's live monument list, grouped by server. The label is the display phrase plus a grid square, so two Harbors can be told apart |
| `rust.options.prefabs` | an allowlist key (`crate.elite`, `npc.scientist.heavy`, …) | the plugin's allowlist, grouped **Crates** / **NPCs** |
**Actions** (all `risk: 'change'`, `reversible: 'ledger'`, `budgetMs: 15000`):
| Action | Params | Ledger | `cost()` |
|---|---|---|---|
| `rust.zone.open` | `server`, a location, `radius`, `name`, optional `minutes` | one resource: the zone | `{ 'rust.zone.minutes': minutes }` when given |
| `rust.prefab.place` | `server`, a location, `prefab`, `count`, optional `spread` | one resource **per net id** | `{ 'rust.prefabs': count }` or `{ 'rust.npcs': count }` by the prefab's kind |
A **location** is `monument` (from the source) with an optional `offsetX`/`offsetZ` in metres, **or**
`x`/`z` (and an optional `y`, otherwise the terrain height). A step that gives both kinds, or
neither, is refused `retry: false`.
- **A step names its server as a param, the way a lease names it in its target (D73).** A scheduled
run's scope is always `''` (§27.1), so the scope cannot carry it.
- **The allowlist as planned**, trimmed further if the walk cannot see any of it working:
- Crates: `crate.basic`, `crate.normal` (military), `crate.normal2`, `crate.elite`, `crate.tools`,
`crate.hackable`, `supply.drop`, `barrel.loot`.
- NPCs: `npc.scientist` (roam), `npc.scientist.heavy`, `npc.scientist.tethered`,
`npc.bandit.guard`.
- **One resource per net id**, as UO files one per serial. A group of which players looted half
then reconciles per crate, and not all or nothing.
### 28.3 What 13a builds
**Rust-Plugins**: protocol 9, and `overlay.toml`'s protocol advanced to match.
- **The owner fix (D85), first and as its own commit.** A `null` owner, and a read-back after every
grant so the report counts only what the store holds.
- **`world.zone`, `world.place`, `world.revert`, `world.owned` and `world.monuments`.**
- **Every write is gated by `EventsEnabled`** (D94) and bounded by config (D95). The refusal names
the switch or the bound.
- **A monument resolves when the step runs** (D93), from `TerrainMeta.Path.Monuments` in a stable
order (prefab path, then position). The ground height comes from `TerrainMeta.HeightMap`.
- **Raw coordinates are refused off the map** (`World.Size`).
- **The ownership registry** is a file under the framework's data directory, beside the lease
holds. Each entry records the run id, the idempotency key, the kind (zone, crate, NPC), the net id
or zone id, the prefab key, where it was put, the `bootId` and the `wipeId`.
- **A repeated key answers with the original ids** and places nothing (§28.1).
- **`world.revert`** kills what the registry says this run owns: every net id or zone id asked
for, or, given only a key, everything under it. An id that is already gone is a success. **An
id the registry does not hold is `refused`**, never erased, whatever ZoneManager would allow
(§12.4).
- **`world.owned`** answers what the world still holds, checked live:
`BaseNetworkable.serverEntities.Find(net id)` for an entity, the registry and `CheckZoneID` for
a zone. Any entry whose thing is gone is pruned as it walks, as UO's does.
- **Load**: on the same boot, nothing changes. After a restart, NPC entries are gone, found by
looking (the game does not save them, §28.1), and crates are still there. After a new wipe, the
whole registry is dropped and everything reads gone.
- **Zones (D92)**: each zone is created with the bridge as `owner` and an id of its own naming
(`rg-<run>-<n>`). It is re-created from the registry on the bridge's load, on
`OnPluginLoaded(ZoneManager)` and after a restart within the same wipe. ZoneManager being absent
is refused with the reason, as every base-plugin dependency is.
- **Carbon**: nothing conditional is expected. `GameManager`, `BaseNetworkable` and `TerrainMeta`
belong to the game, and ZoneManager is the same `.cs` file on both frameworks. The walk proves it.
**Rust-Link**: protocol 9. `POST /world/zone`, `POST /world/place`, `POST /world/revert`,
`GET /world/owned?runId=` and `GET /world/monuments`, stamped and forwarded as the lease routes are.
The sidecar knows nothing about prefabs, bounds or monuments.
**Module-Rust**:
- `registerEventBudgets` (the three above), `registerEventActions` (the two above) and two more
`registerEventOptionSources`.
- `perform` validates everything a dry run can check without the game: the server is configured,
exactly one kind of location, the bounds (mirrored), and the prefab is in the allowlist. It
answers `verify` there, then sends the key through.
- `revert` sends **no key of its own** (MODULE_API §2.4's `module-uo` lesson: the undo must not
carry the key the do went out under), and treats `refused` as `failed`. `reconcile` asks
`world.owned` and answers `inForce`. Anything unanswerable leaves the group alone.
- **The watch**: the server refresh already writes each server's `bootId` and `wipeId`. A change in
either calls `ctx.events.reconcile()`, and the log line says which. The first sighting after the
module boots is not a change, because core's own boot reconcile covers it.
- The mirror's report (`permSync`) gains the plugin's read-back count, so the admin page shows
*"not landed"* where it used to show *applied*.
- `ci/bundle.json` gains the new file, and the frozen manifest is regenerated if it moves. No new
route, and no swagger change is expected.
**docs**: this section as built, the phase rows, §9's catalogue corrected, and
`rust-link/PROTOCOL.md` §15 for protocol 9.
### 28.4 13a is done when
The row's second half, with D86 added: **a wipe reconciles the ledger instead of stranding it, and
each world verb's teardown is observed in the game.** Walked on the Oxide rig against real core, with
event runs authored in the admin UI:
1. **Each verb, observed.** A zone opened at a monument with an offset: `GetZoneIDs` lists it, and
`IsPlayerInZone` is true for a player moved into it. Crates and NPCs placed at a monument and at
raw coordinates are found by net id where they were put. At teardown every one is gone and every
row reads `reverted`.
2. **The lost answer.** A dispatch whose reply is dropped, then retried with the same key, places
nothing new. A `revert` with no resources and only that key removes what was placed.
3. **Reload and restart.** An `oxide.reload` of the bridge mid-run, and one of ZoneManager: the zone
comes back. A server restart mid-run: the watch calls reconcile, crates stay `confirmed`, NPCs go
`orphaned`, and the zone comes back.
4. **The wipe.** A wipe mid-run (a new save on the rig): the watch calls reconcile, every row goes
`orphaned`, teardown does nothing harmful, and nothing is placed on the new map.
5. **The switch and the bounds.** With `EventsEnabled` off, a place is refused with the switch
named, and reverting an existing run still works. 26 crates is refused on the authoring form and
by the plugin.
6. **The owner fix.** A site grant of a permission another plugin registered lands in Oxide's
store, and the report counts it. Before the fix it read *applied* and held nothing.
7. **Carbon.** At least one of each verb, with reload and restart, on the Carbon rig. If that cannot
be done, this section says so.
---
[aa]: https://gitea.whitlocktech.com/RunicGateway/Android-app
@@ -4365,5 +4577,8 @@ the pair stayed granted (`already true`), because the site had asked for it.
[rl]: https://gitea.whitlocktech.com/RunicGateway/Rust-Link
[rp]: https://gitea.whitlocktech.com/RunicGateway/Rust-Plugins
[mr]: https://gitea.whitlocktech.com/RunicGateway/Module-Rust
[w205]: https://gitea.whitlocktech.com/RunicGateway/website/issues/205
[w206]: https://gitea.whitlocktech.com/RunicGateway/website/issues/206
[w207]: https://gitea.whitlocktech.com/RunicGateway/website/issues/207
[kit]: https://gitea.whitlocktech.com/RunicGateway/Integration-kit