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
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user