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

@@ -267,6 +267,11 @@ async function syncOne(server, { authored, sync, state, force }) {
async function applyReport(server, { desired, retire, report, bootId, wipeId }) {
const unresolved = new Set((report.unresolved || []).map(model.normaliseName))
const pending = new Set(report.pending || [])
// Grants the plugin made and then did not find in the store when it read it
// back (D85). Before protocol 9 there was no such read-back, and on Oxide every
// grant of another plugin's permission landed nowhere while this site recorded
// it as pushed (PLAN.md §27.6).
const notLanded = new Set((report.notLanded || []).map((entry) => String(entry).toLowerCase()))
// A grant naming a permission this server has not registered did NOT land —
// `GrantUserPermission` no-ops silently for an unregistered name, which is
@@ -276,7 +281,9 @@ async function applyReport(server, { desired, retire, report, bootId, wipeId })
// The same for a member the store could not place: the membership is waiting
// on their first connection, and it is not in the game yet.
const landed = desired.rows.filter((row) => {
if (row.kind === 'grant' || row.kind === 'group-permission') return !unresolved.has(row.object)
if (row.kind === 'grant' || row.kind === 'group-permission') {
return !unresolved.has(row.object) && !notLanded.has(`${row.subject}:${row.object}`.toLowerCase())
}
if (row.kind === 'member') return !pending.has(`${row.subject}:${row.object}`)
return true
})
@@ -325,6 +332,7 @@ async function applyReport(server, { desired, retire, report, bootId, wipeId })
unresolved: (report.unresolved || []).length,
foreign: (report.foreign || []).length,
pending: (report.pending || []).length,
notLanded: (report.notLanded || []).length,
})
}