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

@@ -142,10 +142,41 @@ test('nothing is registered that has nothing behind it yet', () => {
// NOT register (D62) moved into the assertions below. Phase 12 deleted the
// leases and option sources, and kept budgets here on purpose (D79): a lease
// spends none, and a dimension nothing spends is a dial that does nothing.
// Phase 13a deleted the budgets and actions lines, and registered each budget
// beside the verb that spends it (below). The announce leg is 13b's.
assert.deepStrictEqual(api.record.legs, [])
assert.strictEqual(api.record.hooks.post, undefined)
assert.strictEqual(api.record.eventBudgets, null)
assert.strictEqual(api.record.eventActions, null)
})
test('the world verbs are registered, and every budget has a verb that spends it (phase 13a)', () => {
const { api } = register()
const actions = api.record.eventActions
const budgets = api.record.eventBudgets
assert.deepStrictEqual(actions.map((a) => a.id).sort(), ['rust.prefab.place', 'rust.zone.open'])
assert.deepStrictEqual(budgets.map((b) => b.id).sort(), ['rust.npcs', 'rust.prefabs', 'rust.zone.minutes'])
// D79/D89: no dimension without a verb that spends it. A crate step and an
// NPC step are priced on different dials, and a zone on its minutes.
const spent = new Set()
const place = actions.find((a) => a.id === 'rust.prefab.place')
for (const cost of [
place.cost({ prefab: 'crate.elite', count: 3 }),
place.cost({ prefab: 'npc.scientist', count: 2 }),
actions.find((a) => a.id === 'rust.zone.open').cost({ minutes: 90 }),
]) {
for (const id of Object.keys(cost)) spent.add(id)
}
assert.deepStrictEqual([...spent].sort(), budgets.map((b) => b.id).sort())
for (const a of actions) {
assert.strictEqual(a.reversible, 'ledger')
assert.strictEqual(typeof a.revert, 'function')
assert.strictEqual(typeof a.reconcile, 'function')
// Every param source is one this module registers.
const sources = new Set(api.record.eventOptionSources.map((s) => s.id))
for (const p of a.params) if (p.source) assert.ok(sources.has(p.source), `${a.id}.${p.name} names ${p.source}`)
}
})
test('the leases and their option sources are registered, every source a lease reads (phase 12)', () => {
@@ -158,8 +189,10 @@ test('the leases and their option sources are registered, every source a lease r
['rust.decay.scale', 'rust.group.permission', 'rust.population', 'rust.spawn.scalar'],
)
// D78: exactly the sources the leases' targets name — none without a reader.
// D78: exactly the sources the leases' targets name, plus those the world
// verbs' params name (phase 13a) — none without a reader.
const read = new Set(leases.map((l) => l.target.source))
for (const a of api.record.eventActions) for (const p of a.params) if (p.source) read.add(p.source)
assert.deepStrictEqual([...read].sort(), sources.map((s) => s.id).sort())
for (const l of leases) {