feat(rust): the world verbs, their budgets and the reconcile watch (phase 13a, protocol 9)

- registerEventActions: rust.zone.open and rust.prefab.place, both
  reversible 'ledger' with revert() and reconcile(), budgetMs 15000 above the
  client's 12 s. A location is a monument (kind + instance, carrying its
  server) or raw coordinates, exactly one (D87, D93); bounds mirrored from the
  plugin so a bad step is refused on the form (D95); zone minutes required and
  held by the game (D96).
- registerEventBudgets: rust.prefabs, rust.npcs and rust.zone.minutes, each
  beside the verb that spends it (D79, D89).
- Option sources rust.options.monuments (live, searchable) and
  rust.options.prefabs (mirrored, answers with every server off), registered in
  the one batch core accepts alongside the lease sources.
- Refs are <serverId>:<id>, since revert and reconcile get no params. The undo
  sends no idempotency key; a lost answer is reverted by key on every server.
  reconcile asks the plugin, and a server that cannot be asked keeps its rows.
- The refresh's bootId/wipeId watch calls ctx.events.reconcile() on a restart
  or a wipe, never on a first sighting or a reconnect (§11.1).
- The permission mirror keeps the plugin's new notLanded grants out of what it
  records as pushed, and the admin page says so (D85).

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 01:26:50 -05:00
parent 36a5cb975a
commit a3bcec9cde
11 changed files with 1101 additions and 13 deletions

View File

@@ -60,7 +60,10 @@ const TIMEOUT_MS = 12000
* walls and the cupboard and names who is authorised there, which is what the
* raid alert is sent to (PLAN.md §25); **8** adds the leases — `GET /lease`,
* `POST /lease` and `POST /lease/release` — which is what lets an event borrow
* a value on a server and give it back (PLAN.md §27). The bump lands here in the same change as the emitters,
* a value on a server and give it back (PLAN.md §27); **9** adds the world
* verbs — `/world/monuments`, `/world/owned`, `/world/zone`, `/world/place` and
* `/world/revert` — what an event places in the world and gives back (PLAN.md
* §28). The bump lands here in the same change as the emitters,
* because the sidecar refuses a client declaring a different version with a
* `409`: a module left on 2 would stop being able to read the server board it
* has been reading all along. A constant that lags the deployment is not a safe
@@ -70,7 +73,7 @@ const TIMEOUT_MS = 12000
* deployment into a `409` naming both numbers instead of a parse failure three
* layers further in.
*/
const PROTOCOL_VERSION = 8
const PROTOCOL_VERSION = 9
/** What a caller gets back. Shaped once so every call site reads the same. */
function reply(ok, status, data = null) {
@@ -331,6 +334,34 @@ const leaseApply = (server, body) =>
const leaseRelease = (server, body) =>
request(server, '/lease/release', { method: 'POST', body, timeoutMs: LEASE_TIMEOUT_MS })
/**
* This wipe's monuments, the plugin's placeable allowlist and its bounds
* (protocol 9). Live, because a map changes at every wipe.
*/
const worldMonuments = (server) => request(server, '/world/monuments')
/**
* What the world still holds of what events made, looked for by net id on the
* game (a restart is not proof a crate is gone, §28.1). One run, or all.
*/
const worldOwned = (server, { runId } = {}) =>
request(server, `/world/owned${runId ? `?runId=${encodeURIComponent(runId)}` : ''}`)
/**
* Open a zone, or place crates or NPCs, for a run. `data.kind` is `world.ok`
* (with `placed`) or `world.error` (with `reason`); a repeated idempotency key
* is answered with the first call's ids and `repeat: true`.
*/
const worldZone = (server, body) => request(server, '/world/zone', { method: 'POST', body })
const worldPlace = (server, body) => request(server, '/world/place', { method: 'POST', body })
/**
* Give back what a run owns: named ids, else everything under a key, else the
* whole run. `data` lists `removed`, `gone` (already not there — a success)
* and `refused` (there, and not this run's to erase).
*/
const worldRevert = (server, body) => request(server, '/world/revert', { method: 'POST', body })
module.exports = {
TIMEOUT_MS,
LEASE_TIMEOUT_MS,
@@ -352,5 +383,10 @@ module.exports = {
leaseList,
leaseApply,
leaseRelease,
worldMonuments,
worldOwned,
worldZone,
worldPlace,
worldRevert,
joinUrl,
}