From d4d59899267d28a3491dec27304a01740847f5a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 02:00:43 -0500 Subject: [PATCH] feat(atlas): the spawn atlas reads the shard, not the shard's filesystem (Phase 7) `spawnAtlasSource.js` gains a second backend behind its existing interface (docs/link/v8.md 10). Where a shard is linked and enabled the tree arrives over the sidecar; where there is none, a local ServUO tree is read exactly as before. An explicit --servuo path is an instruction and overrules both. The parsers do not move. spawnAtlasParse.js is still pure, still fs-free and still CI-covered without a ServUO tree anywhere near it; `buildFromFiles` is now where the parse starts, and both readers feed it the same shape. treeBridge.js walks the manifest and then the chunks. Three of its checks are not decoration -- each is a way this ends in a tree that LOOKS imported, and XML is forgiving enough that a mis-assembled spawn file parses cleanly and simply has fewer spawns in it: - every chunk re-declares its address and carries the hash of its own uncompressed bytes, and chunks are placed by declared index rather than arrival order - the whole file is hashed after reassembly against its manifest row - the catalog must not move mid-walk, or the import is refused rather than stitched out of two trees Boot does not call the shard. The same answer 17.7 gave the cliloc table, and the same reasoning: a local tree hashes in ~120 ms and skips, while a round trip in the boot sequence would answer "no" on every restart that did not follow a map edit. Editing spawn files is an operator action, so importing is one -- Admin -> Spawn Atlas -> Import. What that costs is real and is said out loud in the panel, the CLI and the log: an install on the bridge has NO automatic refresh at all. Two things the live walk found that the unit tests could not: - PARSER_VERSION 4 -> 5. The parse is order-sensitive in one place -- the decoration index keeps the FIRST item id it sees for a type -- and the two readers agreed on a stock tree by coincidence, since the filesystem reader walks each directory with localeCompare while the shard sorts whole relative paths. buildFromFiles now sorts by label, ordinally, once, whatever order the files arrived in. Identical input, a different answer for a handful of types: exactly what the version number exists to push through the hash gate. The parity test asserted deepEqual, which ignores key order; it now asserts serialised equality too. - The source fingerprint is taken over RAW BYTES at both ends. Hashing decoded text hashes a UTF-8 re-encoding -- identical for valid UTF-8, different for a file that is not, because an undecodable byte becomes U+FFFD and never comes back. One Latin-1 character in a creature name would have made the drift gate report a change on every import, forever, with the tree untouched. A 200 from assets.sources also stopped meaning "the client files are on offer": a shard may now serve its configuration tree while declining to serve its UO client. Both client-file readers check `assetsEnabled` and say DISABLED, instead of reading an empty file list as "your client has no cliloc.enu" and sending an operator to their client install for a setting that lives on their shard. Measured end to end against a live shard and the real sidecar: 141 files, 11.9 MB, 158 chunks, 3 pages, 1.33 MB on the wire, 512 ms; every file byte-identical to disk; and the atlas built over the bridge identical to the one built off it -- 6,455 points, 800 creatures, 387 regions, 558 landmarks, 25 champions, 309 decoration types. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 --- client/src/routes/admin/SpawnAtlas.jsx | 56 ++- server/model/shardAtlas/shardAtlas.model.js | 143 +++++-- server/router/admin/shard.router.js | 8 +- server/scripts/importSpawnAtlas.js | 9 +- server/swagger/doc.js | 22 +- server/test/assetBridge.test.js | 2 +- server/test/atlasSourceSelection.test.js | 156 ++++++++ server/test/treeBridge.test.js | 413 +++++++++++++++++++ server/utils/assetBridge.js | 37 +- server/utils/clilocBridge.js | 12 + server/utils/spawnAtlasSource.js | 132 ++++++- server/utils/treeBridge.js | 418 ++++++++++++++++++++ swagger-fragment.json | 97 ++++- 13 files changed, 1432 insertions(+), 73 deletions(-) create mode 100644 server/test/atlasSourceSelection.test.js create mode 100644 server/test/treeBridge.test.js create mode 100644 server/utils/treeBridge.js diff --git a/client/src/routes/admin/SpawnAtlas.jsx b/client/src/routes/admin/SpawnAtlas.jsx index 10f084e..68e459b 100644 --- a/client/src/routes/admin/SpawnAtlas.jsx +++ b/client/src/routes/admin/SpawnAtlas.jsx @@ -159,11 +159,14 @@ export default function SpawnAtlas() { setStatus(fresh) setPath(fresh.path || '') setMsg( - fresh.path === '' - ? 'Path cleared. The atlas will be skipped on the next boot; what is loaded keeps serving.' - : fresh.treeReadable - ? 'Saved. The tree is readable — import when you are ready.' - : 'Saved, but the tree could not be read from here. Check the mount and permissions.', + fresh.source === 'bridge' + ? 'Saved, but not in use: this site reads the atlas from the linked shard. The path takes' + + ' over only if uo-link is disabled.' + : fresh.path === '' + ? 'Path cleared. The atlas will be skipped on the next boot; what is loaded keeps serving.' + : fresh.treeReadable + ? 'Saved. The tree is readable — import when you are ready.' + : 'Saved, but the tree could not be read from here. Check the mount and permissions.', ) } catch (err) { setError(err.message || 'Could not save the path.') @@ -185,9 +188,11 @@ export default function SpawnAtlas() {

The bestiary and spawn map on the public site, parsed from the shard’s own ServUO files. - It refreshes itself on every server start; everything here is for the times you don’t want - to wait for one. Nothing on this page touches the sidecar — the atlas is shard content, not - shard state, and stays complete while the shard is down. + Where those files come from depends on whether a shard is linked: with uo-link configured + the shard serves them over the bridge and importing is something you do here, when a map + changes. Without one, the site reads a local tree and re-imports itself on every server + start. Either way the atlas is shard content rather than shard state, so what is + loaded keeps serving in full while the shard is down.

@@ -218,10 +223,23 @@ export default function SpawnAtlas() { {counts.champions?.toLocaleString() ?? '—'} )} - - {!status?.configured ? 'No path set' : status.treeReadable ? 'Yes' : 'No'} + + {status?.source === 'bridge' + ? 'The shard, over uo-link' + : status?.configured + ? status.path + : 'None — no shard linked and no path set'} - + + {!status?.configured + ? 'No source' + : status.treeReadable + ? 'Yes' + : status.source === 'bridge' + ? 'No — the shard did not answer, or Bridge.TreeEnabled is off' + : 'No'} + + {status?.drift == null ? '—' : status.drift ? 'Yes — an import would pick it up' : 'No'} @@ -231,9 +249,13 @@ export default function SpawnAtlas() { ServUO tree

- Where the website reads the shard’s spawn files from — the same host, a bind mount or a + A local ServUO tree the website can read directly — the same host, a bind mount or a shared volume. This setting wins over the SERVUO_PATH deploy default, so the - mount can move without a redeploy. Leave it blank to turn the atlas off. + mount can move without a redeploy. + {status?.source === 'bridge' + ? ' It is not in use right now: this site has a shard linked, and the shard serves its' + + ' own files over the bridge. Unlink or disable uo-link to fall back to a path.' + : ' Leave it blank to turn the atlas off.'}

- Applies a map change without restarting. An unchanged tree costs nothing — the source files - are hashed first and skipped when they match. A refresh that would remove a facet still - comes back here for approval rather than being applied. + Applies a map change without restarting — and on a linked shard it is the only thing that + does, because boot deliberately never calls the shard for this. An unchanged source costs + almost nothing: the file list and its hashes are read first (about 32 KB over the bridge) + and no file is transferred when they match. A refresh that would remove a facet still comes + back here for approval rather than being applied.