Files
Module-Rust/server/test/world.test.js
wtclaude ba636c8939
All checks were successful
PR Checks / client-build (pull_request) Successful in 20s
PR Checks / server-tests (pull_request) Successful in 25s
PR Checks / frozen-manifest (pull_request) Successful in -1m10s
fix(rust): hold the reconcile until the world is loaded, and never read a refusal as a revert
Two defects the phase 13a walk found by restarting the rig mid-run:

- The watch asked core to reconcile the moment a new boot id appeared, which
  is before the game has loaded its save — every crate looked gone and was
  orphaned. It now waits for the plugin's hello to say `worldReady`; an older
  plugin that never says is taken as ready.
- revert() read any 200 as success. On this bridge a refusal is a 200
  carrying world.error (`not-ready` while loading), so every row would have
  been marked reverted with the game still holding every crate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY
2026-09-24 02:16:03 -05:00

368 lines
16 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.crate.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 crates = action('rust.crate.place')
const npcs = action('rust.npc.place')
const zone = action('rust.zone.open')
const run = (a, params) => a.perform({ runId: 7, idempotencyKey: 'k', params })
for (const result of [
await run(crates, { monument: 'main/a', prefab: 'minicopter', count: 1 }),
await run(crates, { monument: 'main/a', prefab: 'npc.scientist', count: 1 }), // D97: the other verb's
await run(npcs, { monument: 'main/a', prefab: 'crate.elite', count: 1 }),
await run(crates, { monument: 'main/a', prefab: 'crate.elite', count: 26 }),
await run(npcs, { monument: 'main/a', prefab: 'npc.scientist', count: 21 }),
await run(crates, { 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.npc.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.crate.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.crate.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 crate and NPC sources split the allowlist, and answer with every server off (D97)', async (t) => {
const calls = stub(t)
const crates = await source('rust.options.crates').resolve()
const npcs = await source('rust.options.npcs').resolve()
assert.strictEqual(crates.length + npcs.length, world.PLACEABLE.length)
assert.ok(crates.every((r) => !r.value.startsWith('npc.')))
assert.ok(npcs.every((r) => r.value.startsWith('npc.')))
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)
})
test('a refusal that arrives as a 200 is not a revert (§28.6)', async (t) => {
// The plugin answers `world.error` with a 200 — `not-ready` while the world is
// still loading. Read as success, every row would be marked reverted while the
// game still held every crate.
stub(t, {
revert: async () => ({ ok: true, data: { kind: 'world.error', reason: 'not-ready', message: 'still loading' } }),
})
const result = await world.revert({ runId: 8, resources: [{ kind: 'world', ref: 'main:101' }] })
assert.deepStrictEqual(result, { ok: false, error: 'still loading' })
const lost = await world.revert({ runId: 8, resources: [], idempotencyKey: 'k' })
assert.strictEqual(lost.ok, false)
})
test('a boot id seen before the world has loaded is not a restart yet (§28.6)', (t) => {
world.resetWatch()
let asked = 0
const saved = core.reconcileEvents
core.reconcileEvents = () => {
asked += 1
return Promise.resolve({})
}
t.after(() => {
core.reconcileEvents = saved
world.resetWatch()
})
world.observeServer('main', { bootId: 'b1', wipeId: 'w1', worldReady: true })
// The plugin reconnects on the new boot BEFORE the save loads.
assert.strictEqual(world.observeServer('main', { bootId: 'b2', wipeId: 'w1', worldReady: false }), false)
assert.strictEqual(asked, 0)
// Its next hello says the world is there, and that is when the change counts.
assert.strictEqual(world.observeServer('main', { bootId: 'b2', wipeId: 'w1', worldReady: true }), true)
assert.strictEqual(asked, 1)
})