// ── 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) })