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
368 lines
16 KiB
JavaScript
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)
|
|
})
|