From ba636c8939524728cfd050fcef10d63420475202 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Thu, 24 Sep 2026 02:16:03 -0500 Subject: [PATCH] fix(rust): hold the reconcile until the world is loaded, and never read a refusal as a revert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01E14m6SuuY6i1vASFeGDBeY --- server/boot.js | 4 +++- server/eventWorld.js | 22 ++++++++++++++++++++-- server/test/world.test.js | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/server/boot.js b/server/boot.js index af4770c..193fc25 100644 --- a/server/boot.js +++ b/server/boot.js @@ -185,7 +185,9 @@ async function refreshOne(server) { // A restart or a wipe under a running event is the moment core must be told // to ask what the world still holds (§11.1). Only a CONNECTED plugin's hello // counts: a board the game left behind says nothing about now. - if (connected) eventWorld.observeServer(server.id, { bootId: frame.bootId, wipeId: frame.wipeId }) + if (connected) { + eventWorld.observeServer(server.id, { bootId: frame.bootId, wipeId: frame.wipeId, worldReady: frame.worldReady }) + } } catch (err) { // A failure here is one server's, and it must not reach `Promise.allSettled` // as a rejection that hides which one. Log with the id and carry on. diff --git a/server/eventWorld.js b/server/eventWorld.js index 16d97f1..e869074 100644 --- a/server/eventWorld.js +++ b/server/eventWorld.js @@ -256,6 +256,7 @@ async function revert({ runId, resources, idempotencyKey }) { for (const server of await servers.listForPolling()) { const result = await client.worldRevert(server, { runId: String(runId), key: idempotencyKey }) if (!result.ok) errors.push(transportError(server, result, 'revert')) + else if (!result.data || result.data.kind !== 'world.ok') errors.push(pluginError(result.data, `${server.name || server.id} refused the revert`)) } return errors.length ? { ok: false, error: errors.join('; ') } : { ok: true } } @@ -279,6 +280,16 @@ async function revert({ runId, resources, idempotencyKey }) { continue } + // **A 200 is not a success on this bridge** — a refusal comes back as one, + // carrying `world.error` (`not-ready` while the world is still loading). + // Read as success it would mark every row reverted while the game still + // held every crate. + if (!result.data || result.data.kind !== 'world.ok') { + failed.push(...group.map((r) => r.ref)) + errors.push(pluginError(result.data, `${found.server.name || found.server.id} refused the revert`)) + continue + } + // `gone` is not reported: a crate a player looted is the point of having // placed it. `refused` IS — the plugin found something there that this run // did not make, and nothing will ever remove it through this path. @@ -593,12 +604,19 @@ const lastSeen = new Map() /** * Note a server's identity as the refresh saw it, and ask core to reconcile when - * it moved. The first sighting after this module boots is a baseline, not a + * it moved — once its world is loaded. The first sighting after this module boots is a baseline, not a * change: core's own boot reconcile already covered it. */ -function observeServer(serverId, { bootId, wipeId } = {}) { +function observeServer(serverId, { bootId, wipeId, worldReady } = {}) { if (!serverId || (!bootId && !wipeId)) return false + // **Not until the world is loaded** (§28.6). The plugin connects before the + // save loads, so the new boot id arrives while every crate still looks gone; + // asked then, reconcile would orphan the lot. The plugin says when it is + // ready, and the change is noticed on that hello instead. An older plugin + // that never says is taken as ready, as it always was. + if (worldReady === false) return false + const previous = lastSeen.get(serverId) lastSeen.set(serverId, { bootId: bootId || null, wipeId: wipeId || null }) if (!previous) return false diff --git a/server/test/world.test.js b/server/test/world.test.js index 9b16339..a66c8dd 100644 --- a/server/test/world.test.js +++ b/server/test/world.test.js @@ -329,3 +329,39 @@ test('only a restart or a wipe asks core to reconcile — never a first sighting 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) +})