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

@@ -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` (5–150 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 (0–50, 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).