fix(rust): hold the reconcile until the world is loaded, and never read a refusal as a revert
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

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
This commit is contained in:
2026-09-24 02:16:03 -05:00
parent fab31f23e8
commit ba636c8939
3 changed files with 59 additions and 3 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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)
})