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
3 changed files with 530 additions and 2 deletions

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**). ✅ **Built and walked 2026-09-24 on both rigs — plan in §28 (D80D95), as built and findings in §28.528.8 (D96D97).** 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 (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,337 @@ 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
> **Corrected in the build (§28.5).** `rust.prefab.place` became two verbs, `rust.crate.place` and
> `rust.npc.place`, with sources `rust.options.crates` and `rust.options.npcs` (D97). A zone's `minutes`
> is **required**, and the game erases the zone when it runs out (D96). The tables below are the plan as
> first written.
**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.
### 28.5 Two more decisions the build needed
Both from the org lead on 2026-09-24, and both asked mid-build because the tree answered
differently from §28.3.
| # | Decision |
|---|---|
| **D96** | **A zone's `minutes` are required, and the game erases the zone when they run out.** §28.2 had them optional, with the zone lasting until teardown. But `rust.zone.minutes` can only bound what a step declares, so a zone opened without minutes would have cost nothing against the operator's cap. Now `minutes` runs from 1 to 7 days (the lease ceiling), the plugin checks every second and emits `world.expired`, and the cost is exact. The zone never outlives its event, even with the site gone. D92's re-creation applies until the deadline. Rejected: counting zones instead of minutes, and keeping §28.2 as written. |
| **D97** | **One placing verb per kind: `rust.crate.place` and `rust.npc.place`**, in place of §28.2's single `rust.prefab.place`. Core works out which cap boxes an action accepts by **pricing its declared example params once**, and drops a dimension priced at zero. A verb whose cost moved between `rust.prefabs` and `rust.npcs` by its `prefab` param could only ever show the crates cap, so D89's separate dial for fights was unreachable. Each verb now prices exactly one dimension, and the prefab source is split to match (`rust.options.crates`, `rust.options.npcs`). The switchboard can also allow crates and leave fights off. The plugin's `world.place` is unchanged. Rejected: a core change to how dimensions are discovered, which would be a `MODULE_API` change and out of this phase's scope. |
### 28.6 As built, 2026-09-24
[Rust-Plugins][rp], [Rust-Link][rl] and [Module-Rust][mr] on `feat/phase-13a-world`, into `edge`, as
protocol 9 ([`PROTOCOL.md`](../../rust-link/PROTOCOL.md) §15). **Core is unchanged and `MODULE_API`
did not move.**
| Repo | What |
|---|---|
| Rust-Plugins | The owner fix, with a read-back and a `notLanded` list (D85). `world.monuments` / `.zone` / `.place` / `.revert` / `.owned`, the `world.json` registry keyed by the website's key, the zone deadline and its `world.expired` event, zone re-creation, `worldReady`, and `EventsMaxCrates` / `EventsMaxNpcs` |
| Rust-Link | Five forwards and `PROTOCOL_VERSION` 9 |
| Module-Rust | `rust.zone.open`, `rust.crate.place` and `rust.npc.place` (`reversible: 'ledger'`, `budgetMs` 15 000), the three budgets, `rust.options.monuments` / `.crates` / `.npcs`, the boot-id and wipe-id watch, and the mirror keeping `notLanded` grants out of what it records as pushed |
**Walked against real core on `main`** (the `rustp12` database, this module staged from the
branch), with event runs authored through the admin API and every effect checked in the game with a
rig-only probe plugin:
1. **Each verb, observed.** A zone 20 m east of the Power Plant (ZoneManager listed it with the
authored name and radius), three elite crates at the monument with an offset, and two tethered
scientists at raw coordinates. Core's dry run priced all three dials separately, and the run
consumed exactly what it declared (2 of 6 NPCs, 3 of 30 crates, 20 of 600 zone-minutes). All
twelve allowlist entries were spawned through the sidecar and each reported its kind. Teardown
left nothing in the world, and every row read `reverted`.
2. **The lost answer, through core.** A proxy between the module and the sidecar let the first
placement reach the game and then swallowed the reply. Core retried a minute later under the
same key, and the plugin answered with **the same four net ids**. Four crates in the world, not
eight. In a second run the reply was dropped and the run was cancelled before any retry. Core's
placeholder was reverted by key alone, and the plugin removed all four.
3. **Reload and restart.** An `oxide.reload` of the bridge, and one of ZoneManager, each logged the
zone re-created. A server restart in the middle of a run (after the §28.7 fix) gave: about
95 seconds of `worldReady: false`, then the watch asked core to reconcile, the three crates and
the zone stayed `confirmed`, and the two NPCs went `orphaned`. The next teardown reverted all
six.
4. **The wipe.** The save was moved aside (and put back afterwards) so the server booted into a new
one mid-run. The plugin logged *"a new wipe dropped 6 things from the old map"*. The watch saw
both the restart and the new wipe id, core orphaned all six rows, no zone was re-created on the
new map, and teardown resolved everything harmlessly.
5. **The switch and the bounds.** A 26-crate step failed the dry run with the bound named. With
`EventsEnabled` switched off from the site's own config editor, a placement was refused on its
first attempt (`retry: false` honoured) and the run paused with the switch named. A run placed
earlier was still torn down cleanly while the switch was off.
6. **The owner fix, before and after.** The phase-12 plugin from `edge` pushed a site group
carrying `zonemanager.zone`. The report said `groupPermissions: 1` and the site showed the server
in sync, while `oxide.show group` answered **"No permissions currently granted"**: phase 7's
defect, reproduced. The protocol 9 plugin, synced against the same group, granted it, read it
back, counted it, and `notLanded` was empty.
7. **Carbon.** The same plugin file, ZoneManager 3.1.14 installed on the Carbon rig (it had never
been), and a protocol 9 sidecar in its container. One run held all three verbs. A `c.reload` of
the bridge showed the same ZoneManager behaviour as Oxide (the zone erased on unload, re-created
on load). A restart mid-run gave the same answer as Oxide: **the crates kept their net ids**,
4 in force and 2 NPCs orphaned. Teardown left the world empty.
### 28.7 What the walk found that the plan did not say
**Two defects, both fixed before merge, and both found by restarting the rig in the middle of a
run:**
- **The reconcile asked about a world that had not loaded yet, and the plugin believed the empty
answer.** The link starts in `Init`, before the save loads. So the new boot id reached the site at
once, the watch asked core to reconcile, and `world.owned` looked for three live crates in an
empty world. It pruned them from the registry, and core orphaned them. They stayed in the world,
owned by nothing, and teardown's revert then **refused** them (correctly: the plugin will not erase
what it cannot prove it made), which left three rows unresolved. This is the §11.1 rule, *"I do not
know" is never "it is gone"*, broken on the game side rather than the website side. The fix has
two halves. The plugin answers `not-ready` to every world command until `OnServerInitialized`, and
`server.hello` carries `worldReady`. The module's watch waits for `worldReady: true`. Re-walked: the
gap was about 95 seconds, and nothing was orphaned that still existed.
- **`revert()` read any 200 as success.** A refusal on this bridge is a 200 carrying `world.error`,
so a `not-ready` revert would have marked every row reverted with every crate still standing. It
now requires `world.ok`. This is the same trap §19.4 found in the other direction (core's client
reading `message` where this module answered `error`), and it will come up again for every verb
in 13b.
**Three things the contract did, found by building against it:**
- **Core infers an action's cap boxes from its examples** (D97). This is documented in
`authorize.js` (*"a `cost()` that returns different dimension KEYS for different params
under-reports here"*) and nowhere a module author would read it. It is worth a line in
`MODULE_API.md` §2.4 and in the kit's chapter 5. **Raised, not changed here.**
- **A key-only revert cannot know its server.** Core hands `revert` no params, so a step whose
answer was lost can only be undone by asking every enabled server. One server that cannot be
reached therefore holds the row open: in walk 2 the Carbon rig was stopped, and the placeholder
stayed unresolved until Carbon came up and the manual cleanup route was used. That is the honest
outcome (MODULE_API: *"answers `{ ok: false }` and the row stays visible"*), and it is written down
here because an operator with a server switched off for a season will meet it.
- **`registerEventOptionSources` is once per module**, so the world sources join the lease sources
in one batch. This is obvious in hindsight, and a second call throws at boot.
**Smaller things worth keeping:**
- **ZoneManager was not installed on the Carbon rig**, although R17 made it a base plugin. It is now
installed (3.1.14, the same file as the Oxide rig). Phase 18's egg must install the base set on
both frameworks.
- The rig's `latest.log` holds only the current boot. `oxide/logs/oxide_<date>.txt` is the log with
timestamps, and it is what showed the connect happening two minutes before the world was ready.
- Two rig-tooling traps from this walk: Git Bash's `MSYS_NO_PATHCONV=1`, which the `/oxide/...`
paths need, also stops `git -C /c/...` and `taskkill //PID` from being converted. The first
produced an empty plugin file on the rig. It was restored within a minute, and Oxide kept the
loaded version meanwhile. Also, a walk core restart can fail silently when the old process keeps
the port, so check for a new `listening on` line.
### 28.8 What is not proven here
- **A real player in a zone.** The rig has no second client. The zone was observed through
ZoneManager's own `GetZoneIDs` / `GetZoneLocation` / `GetZoneRadius`. `IsPlayerInZone` against a
player moved into it was §28.4's wording and was not walked. 13b's tally is where presence has to
be proven, with a person.
- **A crash, as opposed to a restart**, for the registry file. The same load path runs either way.
- **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.
---
[aa]: https://gitea.whitlocktech.com/RunicGateway/Android-app
@@ -4365,5 +4702,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

View File

@@ -175,6 +175,15 @@ this phase has two specific things to confirm there rather than assume:
the website and nothing else; a hand edit is reported rather than undone; and a wipe costs the
operator nothing.
> **Run this walk on protocol 9 or later — before that, steps 13 could not pass on Oxide.** From
> phase 7 until phase 13a the plugin granted with itself as the *owner*, and Oxide silently ignores a
> grant whose owner did not register the permission — which is every permission this walk uses. The
> site showed the server **in sync** and `oxide.show` showed **nothing**; the phase 13a walk
> reproduced exactly that before fixing it ([PLAN.md §28.6](../modules/rust/PLAN.md), step 6). Since
> protocol 9 the plugin reads every grant back, and one that did not land is listed on the
> permissions screen as *sent and not found in the game's permission store* instead of being counted.
> If you see that warning on this walk, it is a finding.
## The configuration walk (protocol 5, phase 7b)
Added 2026-09-22. The website half was walked end to end against a real sidecar and a stand-in
@@ -240,3 +249,45 @@ same three minutes as the identity walk above, done on the phone instead of in a
**What counts as a pass:** a player links an account from the phone without touching a browser, and
the screen never claims an entitlement is in the game when the site has not confirmed it there.
## The events walk (phases 12 and 13a)
Added 2026-09-24. Everything an event does to a server — the leases (protocol 8) and the world verbs
(protocol 9) — was walked through real core on both rigs, and every effect was checked in the game
with a console probe ([PLAN.md](../modules/rust/PLAN.md) §27.5 and §28.6). What a probe cannot do is
**be a player**: stand in a zone, open a crate, get shot at, or see a kit unlock in the menu. That is
this walk. It is §27.7's and §28.8's "not proven" lists, done by a person.
**Before you start:**
1. **Kits installed on the rig** (`umod.org/plugins/Kits.cs`, into `oxide/plugins/` or
`carbon/plugins/`) with at least one kit whose `RequiredPermission` is set — step 1 needs it, and
neither rig has had Kits yet.
2. **`EventsEnabled` on** — Admin → Rust mod config → `RunicGateway.json`. It takes effect at once.
3. **The four event verbs switched on** in Admin → Events → Actions: `core.lease`, `rust.zone.open`,
`rust.crate.place`, `rust.npc.place`.
4. **A non-admin Steam account for step 6** is preferable but not required. Kits applies its gate to an
admin too, so step 1 works on the owner's account, and nothing in steps 26 depends on a permission.
Each step is an event you author in Admin → Events, publish, and start by hand. A monument is picked
from the dropdown (it lists this map's monuments by name and grid square); stand near it before you
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 |
| 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 |
| 6 | **A zone for 3 minutes.** Stand inside it (`rgp.inzone` names you) and wait out the three minutes | The zone disappears around you **without anybody on the website doing anything**, because the game keeps the deadline (D96). `zone_list` no longer lists it, and the sidecar's `/events?kind=world.expired` shows the frame. Best done on the non-admin account, which is the view a real player has |
| 7 | **If players can be arranged, a `core.lease` on `spawn.max_rate`** — these two scalars are left out of the catalogue today because they only matter with people online (§27.5) | With several players on, the population system's spawn rate moves (`spawn.report`) while the lease holds and returns after. If it does, the two `spawn.max_*` keys can be added to the allowlist; if nothing moves even with players, they stay out |
**Run steps 15 on both frameworks.** One thing is specific to Carbon: ZoneManager was only installed
on the Carbon rig on 2026-09-24, for the 13a walk — step 2 is the first time a *player* has stood in
a zone there.
**What counts as a pass:** a player sees what the event did and sees it stop — the kit locks again,
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.

View File

@@ -50,7 +50,7 @@ it is listening without one.
## 2. Versioning
The wire version is a single integer — **8** as of the leases (§14) — declared in
The wire version is a single integer — **9** as of the world verbs (§15) — declared in
**four** places that must agree:
| Where | Repo |
@@ -206,6 +206,7 @@ Every response carries `X-RustLink-Version`, including `/health` and including e
| `GET /status` | the plugin (RPC) | A live round trip. `503` with no plugin, `504` on no reply |
| `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) |
### 4.1 The split between store-backed and live is deliberate
@@ -1399,3 +1400,139 @@ That is below the sidecar's 10 s reply timeout, so the module can give up on an
still going to take. It follows a timed-out apply with a release of the same value down the same
link. The plugin handles the two in order: if the apply landed, the hold's own baseline goes back,
and if it never did, the compare finds nothing to do.
## 15. Protocol 9 — the world verbs
Added in phase 13a ([`PLAN.md`](../modules/rust/PLAN.md) §28). An event **makes** something that was
not there, a zone or crates or NPCs, and gives it back at teardown. **Five commands, one event, two
plugin config keys and one `server.hello` field.** As in §14, the plugin holds everything that
decides what is allowed: the allowlist, the bounds, the monument vocabulary and the registry of what
each run owns. The website holds the ledger (core's `event_run_resources`), and the sidecar forwards
five routes.
### 15.1 The commands
```json
{"cmd":"world.place","reqId":"r-9","runId":"13","key":"6803fe68…","prefab":"crate.tools",
"count":4,"spread":5,"monument":"powerplant_1","offsetX":-30,"offsetZ":0}
```
| Command | Answers | |
|---|---|---|
| `world.monuments` | `world.monuments` | This map's monuments in one stable order (grouped by prefab short name, then by position). Each has `value` (`kind`, or `kind#n` when the kind repeats), `kind`, `instance`, `of`, `label` (the game's display phrase), `x`, `z` and `grid`. Also `worldSize`, the placeable `prefabs` (`key`, `kind`, `label`), `eventsEnabled`, `maxCrates`, `maxNpcs` and `zoneManager` |
| `world.zone` | `world.ok` or `world.error` | Opens a ZoneManager temporary zone owned by the bridge. Needs `runId`, `key`, a location, `radius` (5150 m) and `holdMs` (1 minute to 7 days); `name` is optional |
| `world.place` | `world.ok` or `world.error` | Places `count` of one allowlisted `prefab` at a location, scattered within `spread` m (050, 10 by default for a group). All or nothing: if the game refuses one, the ones already made are killed |
| `world.revert` | `world.ok` | Gives back what a run owns: the named `ids`, or else everything under `key`, or else everything the run owns. The answer lists `removed`, `gone` and `refused` |
| `world.owned` | `world.owned` | What the world still holds of what events made, **looked for** by net id or zone id, narrowed by `runId`. Anything gone is pruned from the registry as the walk passes it |
**A location is a monument or coordinates, exactly one.** A monument location is `monument` (a `value`
from `world.monuments`) plus an optional `offsetX`/`offsetZ` of up to 150 m in total. A bare kind
that repeats means its first instance. A coordinate location is `x` and `z`, which must lie on the
map, and an optional `y`. When no height is given, the ground height is used.
`world.ok` for a zone or a placement carries `placed`, one row per thing (`id`, `kind`, `runId`, `x`,
`y`, `z`, and `prefab`, or for a zone `radius`, `name` and `remainingMs`), and **`repeat: true` when
the `key` had already been used** (§15.2).
**`world.error` reasons**:
| `reason` | Means | Worth retrying |
|---|---|---|
| `events-disabled` | `EventsEnabled` is off (§15.4) | no |
| `malformed` | both kinds of location or neither, a missing field, or no `runId` | no |
| `unknown-prefab` | not in the plugin's allowlist | no |
| `out-of-range` | a count, radius, spread, offset or duration outside the plugin's bounds | no |
| `no-monument` | this map has no such monument, or not that many of it | no |
| `off-map` | coordinates outside the map | no |
| `zonemanager-missing` | ZoneManager is not loaded | no |
| `not-ready` | **the world has not finished loading** (§15.5) | yes |
| `refused` | the game or ZoneManager did not create what was asked | yes |
**The allowlist** (PLAN.md D88) is crates and NPCs only, never vehicles. The crates are
`crate.basic`, `crate.normal` (military), `crate.normal2`, `crate.elite`, `crate.tools`,
`crate.hackable`, `supply.drop` and `barrel.loot`. The NPCs are `npc.scientist`,
`npc.scientist.heavy`, `npc.scientist.tethered` and `npc.bandit.guard`. Each one was spawned on the rig
and reported its type before it was listed.
### 15.2 The registry, and why it is keyed by the website's key
`world.json`, under the framework's data directory beside `leases.json`, records everything events
have made on this map. Each entry holds the id, the kind, the prefab, the run, **the website's
idempotency key**, the position, a zone's radius, name and deadline, and the `bootId` and `wipeId` it
was made under.
**This bridge has no at-most-once store**, unlike UO's shard. The registry is keyed by the website's
key instead. A `world.zone` or `world.place` whose key the registry already holds is a retry whose
first answer was lost, so it is answered with **the first call's ids and `repeat: true`**, and nothing
is placed. The same record answers a `world.revert` that carries only a key: *everything placed under
it*.
**What may be erased is decided by the registry and never by the world.** `world.revert` of an id
the registry holds for another run is `refused`. An id the registry does not hold is `gone` when
nothing is there (a wipe or a prune already took it), and `refused` when something **is** there. The
bridge never erases a thing it cannot prove it made, and ZoneManager's `EraseTemporaryZone` returns
`true` for an operator's own hand-made zone (PLAN.md §12.4).
### 15.3 What a restart and a wipe do
Found on the rig, not taken from a document (PLAN.md §28.1):
| | A crate, the hackable crate, the supply drop, a barrel | An NPC | A zone |
|---|---|---|---|
| **Plugin reload** | there | there | **erased by ZoneManager** as the bridge unloads, and re-created from the registry on load |
| **ZoneManager reload** | there | there | erased, and re-created on `OnPluginLoaded(ZoneManager)` |
| **Server restart** | **there, same net id** (the game saves it) | gone (the game does not save NPCs) | re-created at `OnServerInitialized` |
| **Wipe** | gone | gone | dropped, not re-created |
So **a restart is not proof that a placed thing is gone**, which is the opposite of UO's town crier.
`world.owned` always looks. A new save (`OnNewSave`), or a load that finds entries recorded against
another wipe, drops those entries whole.
**A zone has a deadline, and the game keeps it** (PLAN.md D96). The plugin checks every second and
erases a zone whose `holdMs` has run out, whether or not the website is heard from again, and emits:
```json
{"kind":"world.expired","type":"event","id":"rg-13-35875416-1","runId":"13"}
```
The website maps it to nothing. Core learns about it through `reconcile` and `revert`, as with
`lease.expired`.
### 15.4 `EventsEnabled`, and the two bounds
`EventsEnabled` (§14.3) now gates **every world write** as well as `lease.apply` (PLAN.md D94).
`world.revert`, `world.owned` and `world.monuments` are never behind it, so switching events off never
strands anything.
Two new config keys, written into an existing config the first time protocol 9 loads:
**`EventsMaxCrates`** (25) and **`EventsMaxNpcs`** (20), per step. An operator may lower them. A value
above the ceiling is clamped down to it, because the website mirrors the ceiling. A step over the
bound is refused, never trimmed.
### 15.5 `worldReady`, and why the world is not there yet
**The link starts in `Init`, before the save loads**, so for the first minute or two of a real start
the plugin is connected, its `bootId` is new, and the world is empty. Asked `world.owned` then, the
phase 13a walk's plugin found no entity behind any net id and pruned three live crates from its own
registry.
So every world command answers **`not-ready`** until `OnServerInitialized`, and `server.hello` carries
**`worldReady`**. The field is `false` from connect until the world has loaded, then `true`. A hello is
also sent at that moment, rather than at the next board tick. A website that reconciles on a changed
`bootId` or `wipeId` waits for `worldReady: true` before it asks. On the rig the gap was about
95 seconds.
### 15.6 The sidecar
`PROTOCOL_VERSION` becomes 9. There are five routes, each a correlated round trip that fails when the
game is down:
| Route | Command | |
|---|---|---|
| `GET /world/monuments` | `world.monuments` | |
| `GET /world/owned?runId=` | `world.owned` | `runId` optional; forwarded as it is |
| `POST /world/zone` | `world.zone` | Opaque object; `cmd` and `reqId` written over the caller's |
| `POST /world/place` | `world.place` | The same |
| `POST /world/revert` | `world.revert` | The same |
`world.expired` is an `event`, filed and served like every other (§8.1).