docs(rust): phase 13a as built — protocol 9, D96–D97, the walk and its findings

- PLAN.md §28.5–28.8: two more org-lead decisions (D96 required zone minutes
  held by the game; D97 one placing verb per kind, because core infers cap
  boxes from examples), the seven-step walk on both rigs, the defect a
  mid-run restart found (the reconcile asked a world that had not loaded and
  the plugin pruned live crates) and its fix, and what is not proven.
- PROTOCOL.md §15: protocol 9 — the five world commands, the registry keyed
  by the website's key, what a restart and a wipe do, `worldReady`.

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 03:14:49 -05:00
parent 0de9d7f90d
commit 9c46b06172
2 changed files with 264 additions and 2 deletions

View File

@@ -999,7 +999,7 @@ 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 |
| 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 |
| 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 |
@@ -4454,6 +4454,11 @@ sidecar reply at 10 s and this module's client at 12 s, so every 13a verb declar
### 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).
@@ -4568,6 +4573,126 @@ event runs authored in the admin UI:
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

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).