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:
325
server/test/world.test.js
Normal file
325
server/test/world.test.js
Normal file
@@ -0,0 +1,325 @@
|
||||
// ── 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)
|
||||
})
|
||||
Reference in New Issue
Block a user