Files
Module-Rust/server/test/world.test.js
wtclaude a3bcec9cde 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
2026-09-24 01:26:57 -05:00

326 lines
14 KiB
JavaScript

// ── The world verbs (PLAN.md §28, protocol 9) ─────────────────────────────
//
// What an event makes and gives back. Every test here is one of the ways the
// contract's half can look right and be wrong:
//
// the budget must outlive the client, or `retry: false` is unreachable
// a location is a monument OR coordinates, and a monument names its server
// a dry run checks everything it can and sends nothing
// the ref carries the server, because revert and reconcile get no params
// the undo carries NO idempotency key (module-uo's teardown-was-a-no-op bug)
// a lost answer is reverted by key on every server, since the server is unknown
// `gone` is a success and `refused` is not
// "cannot ask" is never "it is gone"
// only a restart or a wipe provokes a reconcile, never a reconnect
const test = require('node:test')
const assert = require('node:assert')
const { fakeCtx } = require('./_fakes')
require('../core')._reset()
require('../core').init(fakeCtx())
const core = require('../core')
const client = require('../sidecarClient')
const serversDb = require('../model/servers/servers.db')
const servers = require('../model/servers/servers.model')
const world = require('../eventWorld')
const action = (id) => world.ACTIONS.find((a) => a.id === id)
const source = (id) => world.OPTION_SOURCES.find((s) => s.id === id)
const ROWS = {
main: { id: 'main', name: 'Main', sidecarBaseUrl: 'http://main:1', sidecarTokenEnc: null, enabled: 1 },
alt: { id: 'alt', name: 'Alt', sidecarBaseUrl: 'http://alt:1', sidecarTokenEnc: null, enabled: 1 },
off: { id: 'off', name: 'Off', sidecarBaseUrl: 'http://off:1', sidecarTokenEnc: null, enabled: 0 },
}
/** Replace the module's collaborators for one test, and put them back after. */
function stub(t, { zone, place, revert, owned, monuments, polling } = {}) {
const calls = { zone: [], place: [], revert: [], owned: [], monuments: [] }
const saved = {
getServer: serversDb.getServer,
listForPolling: servers.listForPolling,
worldZone: client.worldZone,
worldPlace: client.worldPlace,
worldRevert: client.worldRevert,
worldOwned: client.worldOwned,
worldMonuments: client.worldMonuments,
}
serversDb.getServer = async (id) => ROWS[id] || null
servers.listForPolling = async () =>
(polling || ['main']).map((id) => ({ id, name: ROWS[id] ? ROWS[id].name : id, baseUrl: `http://${id}:1`, token: 't' }))
const ok = (data) => ({ ok: true, status: 'ok', data })
client.worldZone = async (server, body) => {
calls.zone.push({ server: server.id, body })
return zone ? zone(server, body) : ok({ kind: 'world.ok', placed: [{ id: 'rg-7-1-1', kind: 'zone', name: 'Z' }] })
}
client.worldPlace = async (server, body) => {
calls.place.push({ server: server.id, body })
return place
? place(server, body)
: ok({ kind: 'world.ok', placed: [{ id: '101', kind: 'crate', prefab: body.prefab }, { id: '102', kind: 'crate', prefab: body.prefab }] })
}
client.worldRevert = async (server, body) => {
calls.revert.push({ server: server.id, body })
return revert ? revert(server, body) : ok({ kind: 'world.ok', removed: body.ids || [], gone: [], refused: [] })
}
client.worldOwned = async (server, q) => {
calls.owned.push({ server: server.id, ...q })
return owned ? owned(server, q) : { ok: false, status: 'http-503' }
}
client.worldMonuments = async (server) => {
calls.monuments.push(server.id)
return monuments ? monuments(server) : { ok: false, status: 'http-503' }
}
t.after(() => Object.assign(client, {
worldZone: saved.worldZone,
worldPlace: saved.worldPlace,
worldRevert: saved.worldRevert,
worldOwned: saved.worldOwned,
worldMonuments: saved.worldMonuments,
}))
t.after(() => {
serversDb.getServer = saved.getServer
servers.listForPolling = saved.listForPolling
})
return calls
}
test('every world verb outlives the client, which outlives the sidecar', () => {
// `sidecar RPC (10s) < TIMEOUT_MS < budgetMs` — or the dispatcher gives up
// first, classifies retry, and every `retry: false` below is dead code.
assert.ok(10000 < client.TIMEOUT_MS)
for (const a of world.ACTIONS) assert.ok(client.TIMEOUT_MS < a.budgetMs, `${a.id} budgetMs`)
})
test('the mirrored bounds are the plugin\'s own (D95, D96)', () => {
assert.strictEqual(world.MAX_CRATES, 25)
assert.strictEqual(world.MAX_NPCS, 20)
assert.strictEqual(world.ZONE_MAX_MINUTES, 7 * 24 * 60)
// Crates and NPCs only, never a vehicle (D88).
assert.deepStrictEqual([...new Set(world.PLACEABLE.map((p) => p.kind))].sort(), ['crate', 'npc'])
})
test('a location is a monument or coordinates, exactly one, and a monument names its server', () => {
assert.strictEqual(world.location({}).ok, false)
assert.strictEqual(world.location({ monument: 'main/airfield_1', x: 1, z: 2 }).ok, false)
const byMonument = world.location({ monument: 'main/harbor_1#2', offsetX: 10 })
assert.deepStrictEqual(byMonument, { ok: true, serverId: 'main', wire: { monument: 'harbor_1#2', offsetX: 10, offsetZ: 0 } })
// Two ways to name a server must agree.
assert.match(world.location({ monument: 'main/harbor_1', server: 'alt' }).error, /on main, not alt/)
// An offset past the bound is refused on the form, not mid-run.
assert.match(world.location({ monument: 'main/harbor_1', offsetX: 120, offsetZ: 120 }).error, /at most 150/)
// Coordinates name nothing, so they need the server.
assert.match(world.location({ x: 1, z: 2 }).error, /which server/)
assert.deepStrictEqual(world.location({ x: 1, z: 2, server: 'alt' }), { ok: true, serverId: 'alt', wire: { x: 1, z: 2 } })
assert.match(world.location({ x: 1, server: 'alt' }).error, /both x and z/)
})
test('a dry run checks everything it can and sends nothing', async (t) => {
const calls = stub(t)
const zone = await action('rust.zone.open').perform({
runId: 7, idempotencyKey: 'k1', verify: true, params: { monument: 'main/airfield_1', radius: 40, minutes: 60 },
})
const place = await action('rust.prefab.place').perform({
runId: 7, idempotencyKey: 'k2', verify: true, params: { monument: 'main/airfield_1', prefab: 'crate.elite', count: 3 },
})
assert.deepStrictEqual([zone, place], [{ ok: true }, { ok: true }])
assert.deepStrictEqual([calls.zone.length, calls.place.length], [0, 0])
})
test('every authoring mistake is refused for good, before anything is sent', async (t) => {
const calls = stub(t)
const place = action('rust.prefab.place')
const zone = action('rust.zone.open')
const run = (a, params) => a.perform({ runId: 7, idempotencyKey: 'k', params })
for (const result of [
await run(place, { monument: 'main/a', prefab: 'minicopter', count: 1 }),
await run(place, { monument: 'main/a', prefab: 'crate.elite', count: 26 }),
await run(place, { monument: 'main/a', prefab: 'npc.scientist', count: 21 }),
await run(place, { monument: 'main/a', prefab: 'crate.elite', count: 1, spread: 51 }),
await run(zone, { monument: 'main/a', radius: 4, minutes: 10 }),
await run(zone, { monument: 'main/a', radius: 40 }), // D96: minutes are required
await run(zone, { monument: 'main/a', radius: 40, minutes: 7 * 24 * 60 + 1 }),
await run(zone, { monument: 'nowhere/a', radius: 40, minutes: 10 }),
await run(zone, { monument: 'off/a', radius: 40, minutes: 10 }),
]) {
assert.strictEqual(result.ok, false)
assert.strictEqual(result.retry, false, result.error)
}
assert.deepStrictEqual([calls.zone.length, calls.place.length], [0, 0])
})
test('a zone crosses with its key, its duration, and the monument without the server', async (t) => {
const calls = stub(t)
const result = await action('rust.zone.open').perform({
runId: 7, idempotencyKey: 'k1', params: { monument: 'main/airfield_1', offsetZ: -20, radius: 40, minutes: 90, name: 'Brawl' },
})
assert.deepStrictEqual(calls.zone[0], {
server: 'main',
body: { runId: '7', key: 'k1', monument: 'airfield_1', offsetX: 0, offsetZ: -20, radius: 40, holdMs: 5400000, name: 'Brawl' },
})
// The ref carries the server: revert and reconcile are handed no params.
assert.deepStrictEqual(result.resources, [
{ kind: 'world', ref: 'main:rg-7-1-1', payload: { serverId: 'main', what: 'zone', name: 'Z' } },
])
})
test('one resource per thing placed, and a repeated key is said to be one', async (t) => {
stub(t, {
place: async () => ({
ok: true,
data: { kind: 'world.ok', repeat: true, placed: [{ id: '101', kind: 'npc', prefab: 'npc.scientist' }] },
}),
})
const result = await action('rust.prefab.place').perform({
runId: 7, idempotencyKey: 'k', params: { x: -604, z: -342, server: 'main', prefab: 'npc.scientist', count: 1 },
})
assert.strictEqual(result.ok, true)
assert.deepStrictEqual(result.resources.map((r) => r.ref), ['main:101'])
assert.strictEqual(result.detail.repeat, true)
})
test('the switch being off is a refusal with the switch named, for good (D94)', async (t) => {
stub(t, {
place: async () => ({ ok: true, data: { kind: 'world.error', reason: 'events-disabled', message: 'set EventsEnabled' } }),
})
const result = await action('rust.prefab.place').perform({
runId: 7, idempotencyKey: 'k', params: { monument: 'main/a', prefab: 'crate.elite', count: 1 },
})
assert.deepStrictEqual(result, { ok: false, retry: false, error: 'set EventsEnabled' })
})
test('a game that is down or slow is left to core to retry', async (t) => {
stub(t, { place: async () => ({ ok: false, status: 'http-503' }) })
const result = await action('rust.prefab.place').perform({
runId: 7, idempotencyKey: 'k', params: { monument: 'main/a', prefab: 'crate.elite', count: 1 },
})
assert.strictEqual(result.ok, false)
assert.strictEqual(result.retry, undefined)
assert.match(result.error, /Main has no game connected/)
})
test('revert sends ids per server and NO idempotency key', async (t) => {
const calls = stub(t)
const result = await world.revert({
runId: 7,
idempotencyKey: 'k-of-the-do',
resources: [
{ kind: 'world', ref: 'main:101', payload: { serverId: 'main' } },
{ kind: 'world', ref: 'alt:rg-7-1-1', payload: { serverId: 'alt' } },
{ kind: 'world', ref: 'main:102' },
],
})
assert.deepStrictEqual(result, { ok: true })
assert.deepStrictEqual(calls.revert, [
{ server: 'main', body: { runId: '7', ids: ['101', '102'] } },
{ server: 'alt', body: { runId: '7', ids: ['rg-7-1-1'] } },
])
})
test('gone is a success, and refused is a failure named by ref', async (t) => {
stub(t, { revert: async () => ({ ok: true, data: { kind: 'world.ok', removed: ['101'], gone: ['102'], refused: ['103'] } }) })
const result = await world.revert({
runId: 7,
resources: ['101', '102', '103'].map((id) => ({ kind: 'world', ref: `main:${id}` })),
})
assert.deepStrictEqual(result, { ok: true, failed: ['main:103'] })
})
test('a lost answer is reverted by its key on every server, and an unreachable one keeps the row', async (t) => {
const calls = stub(t, {
polling: ['main', 'alt'],
revert: async (server) => (server.id === 'alt' ? { ok: false, status: 'http-503' } : { ok: true, data: { kind: 'world.ok' } }),
})
const result = await world.revert({ runId: 7, resources: [], idempotencyKey: 'k-lost' })
assert.deepStrictEqual(calls.revert.map((c) => c.body), [
{ runId: '7', key: 'k-lost' },
{ runId: '7', key: 'k-lost' },
])
assert.strictEqual(result.ok, false)
assert.match(result.error, /Alt has no game connected/)
})
test('reconcile asks each server what it holds, and "cannot ask" is not "gone"', async (t) => {
stub(t, {
owned: async (server) =>
server.id === 'main'
? { ok: true, data: { kind: 'world.owned', owned: [{ id: '101' }] } }
: { ok: false, status: 'http-503' },
})
const result = await world.reconcile({
runId: 7,
resources: [
{ kind: 'world', ref: 'main:101' },
{ kind: 'world', ref: 'main:102' }, // looted, or an NPC a restart took
{ kind: 'world', ref: 'alt:rg-7-1-1', payload: { serverId: 'alt' } },
],
})
assert.deepStrictEqual(result, { ok: true, inForce: ['main:101', 'alt:rg-7-1-1'] })
})
test('the monument source lists each server\'s map as whole values, numbered where a kind repeats', async (t) => {
stub(t, {
polling: ['main', 'alt'],
monuments: async (server) =>
server.id === 'alt'
? { ok: false, status: 'http-503' }
: {
ok: true,
data: {
monuments: [
{ value: 'harbor_1#1', label: 'Harbor', instance: 1, of: 2, grid: 'M7' },
{ value: 'harbor_1#2', label: 'Harbor', instance: 2, of: 2, grid: 'C12' },
{ value: 'powerplant_1', label: 'Power Plant', instance: 1, of: 1, grid: 'F14' },
],
},
},
})
const rows = await source('rust.options.monuments').resolve()
assert.deepStrictEqual(rows, [
{ value: 'main/harbor_1#1', label: 'Harbor #1 · M7', group: 'Main' },
{ value: 'main/harbor_1#2', label: 'Harbor #2 · C12', group: 'Main' },
{ value: 'main/powerplant_1', label: 'Power Plant · F14', group: 'Main' },
])
const narrowed = await source('rust.options.monuments').resolve({ q: 'power' })
assert.deepStrictEqual(narrowed.map((r) => r.value), ['main/powerplant_1'])
})
test('the prefab source answers with every server off', async (t) => {
const calls = stub(t)
const rows = await source('rust.options.prefabs').resolve()
assert.strictEqual(rows.length, world.PLACEABLE.length)
assert.deepStrictEqual(calls.monuments, [])
})
test('only a restart or a wipe asks core to reconcile — never a first sighting or a reconnect', (t) => {
world.resetWatch()
let asked = 0
const saved = core.reconcileEvents
core.reconcileEvents = () => {
asked += 1
return Promise.resolve({})
}
t.after(() => {
core.reconcileEvents = saved
world.resetWatch()
})
assert.strictEqual(world.observeServer('main', { bootId: 'b1', wipeId: 'w1' }), false) // baseline
assert.strictEqual(world.observeServer('main', { bootId: 'b1', wipeId: 'w1' }), false) // a reconnect
assert.strictEqual(world.observeServer('main', { bootId: 'b2', wipeId: 'w1' }), true) // a restart
assert.strictEqual(world.observeServer('main', { bootId: 'b3', wipeId: 'w2' }), true) // a wipe
assert.strictEqual(world.observeServer('alt', { bootId: 'x', wipeId: 'y' }), false) // another server's baseline
assert.strictEqual(asked, 2)
})