fix(events): the atlas import, and a teardown that was a no-op (Phase 16a)
All checks were successful
PR Checks / client-build (pull_request) Successful in 24s
PR Checks / frozen-manifest (pull_request) Successful in 58s
PR Checks / server-tests (pull_request) Successful in 8m27s

Two defects the acceptance walk found in shipped code, both invisible to the
suites that were green on either side of them.

**The spawn atlas cannot import on a stock ServUO tree.** `spawnAtlasSource.js`
dedupes decoration types with a case-SENSITIVE `Map`, but `shard_decor_types.type`
is a PRIMARY KEY under MariaDB's default `..._ai_ci` collation, which folds case.
Stock 57.4's own `Data/Decoration/` names four types under two spellings each
(CheckerBoard/Checkerboard, ChessBoard/Chessboard, MetalChest/Metalchest,
SpinningWheelEastAddon/SpinningwheelEastAddon), and in every pair exactly one is a
real class. The second row raised `1062 Duplicate entry` and took the WHOLE import
transaction down. The blast radius is not decoration: with no atlas, EVERY option
source answers empty and no Phase 12 world verb can be authored at all.

The shard end already knew — `BridgeWorld.cs` resolves a decor type with
`FindTypeByName(name, ignoreCase: true)` and its comment says the atlas and the
decoration files disagree about casing. Folding here is the two ends agreeing.

**Teardown of every world verb was a no-op that reported success.** `revertOwned`
forwarded core's `idempotencyKey` as the despawn's OWN key — and core's key is the
step's, the one `placeOwned` spawned under. `BridgeIdempotency` keys on the key
alone, so the despawn was taken for a repeat and answered with the SPAWN's stored
reply; `OnDespawn` never ran. Core read `ok` with no `refused` and marked every
row `reverted` while the shard still held every object.

Measured on the rig: ledger `world | reverted | 21`, shard `world.owned` 21 alive
with `pruned: 0`, and the identical despawn re-sent with a fresh key removed all
21. It affected all five world verbs, so an invasion's creatures, boss, oracle,
gate and decoration stayed in the world for ever while the console reported a
clean teardown.

`MODULE_API.md` says what that key is for and it is not this: it identifies a
dispatch core never learned the outcome of, so the module can ask about it. No key
is needed on a despawn — a repeat answers `gone`, which both ends already treat as
success — and dropping it also makes the documented empty-`resources` case work,
since no serials means "everything this run owns". The parameter is removed from
`despawnWorld`'s signature rather than left optional.

Both fixes are verified end to end against a real ServUO + sidecar + website rig:
the import now yields 309 decor types (was failing at 313 with 4 collisions),
6,455 spawn points, 800 creatures, 558 landmarks; and a full four-phase run's
teardown left the shard owning 0 objects.

Each new test was confirmed to FAIL without its fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
2026-09-09 08:27:50 -05:00
parent c289586a3d
commit 8def6e19f4
5 changed files with 130 additions and 6 deletions

View File

@@ -766,6 +766,51 @@ test('teardown reports a refused serial as failed, and a killed creature as done
assert.equal((await actions.revertOwned({ runId: 7, resources })).ok, false)
})
test('the despawn carries NO idempotency key, whatever core hands revert()', async () => {
// The Phase 16 acceptance walk's critical finding, as the test that would have
// caught it. `revertOwned` used to forward core's `idempotencyKey` onto the
// despawn — and core's key is the STEP's, the one `placeOwned` spawned under.
// The shard's at-most-once store is keyed on the key ALONE
// (`BridgeIdempotency.Intercept` does `_byKey.TryGetValue(key, …)`, with no
// reference to which command carried it), so the despawn was taken for a repeat
// and answered with the SPAWN's stored reply. `OnDespawn` never ran. Core read
// `ok` with no `refused` and marked every row `reverted` while the shard still
// held every object — teardown of all five world verbs was a no-op that
// reported success.
//
// Every other stub in this file ignores the body, which is why the suite was
// green throughout. This one asserts on the body, and it asserts ABSENCE — the
// property that matters — rather than pinning the rest of the shape.
let sent = null
uoLinkClient.despawnWorld = async (body) => {
sent = body
return { ok: true, status: 200, data: { removed: ['0x40000000'], gone: [], refused: [] } }
}
await actions.revertOwned({
runId: 7,
resources: [{ kind: 'world', ref: '0x40000000', payload: {} }],
// Core passes this on every call (MODULE_API.md), and it must not reach the wire.
idempotencyKey: 'the-step-key-the-spawn-went-out-under',
})
assert.ok(sent, 'despawnWorld was not called')
assert.equal(
Object.prototype.hasOwnProperty.call(sent, 'idempotencyKey'),
false,
'the despawn must not carry an idempotency key — the shard would replay the spawn',
)
// MODULE_API.md: revert is sometimes called with the key and an EMPTY list,
// meaning "a command went out under this key and core never learned what it
// did". No serials is the shard's own idiom for "everything this run owns",
// which is the correct sweep for exactly that case.
sent = null
await actions.revertOwned({ runId: 7, resources: [], idempotencyKey: 'lost-dispatch' })
assert.deepEqual(sent.serials, [])
assert.equal(Object.prototype.hasOwnProperty.call(sent, 'idempotencyKey'), false)
})
test('reconcile ASKS the shard, because these resources survive a restart', async () => {
// The one property that separates this from every other resource in the file.
// A crier line lives in shard memory, so a changed `bootId` IS proof it is