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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
@@ -6,6 +6,15 @@
|
||||
// - the server, which refreshes the atlas on boot (`shardAtlas.model.js`)
|
||||
// - the CLI (`scripts/importSpawnAtlas.js`)
|
||||
//
|
||||
// **As of protocol 8 phase 7 it is no longer the only way in** (docs/link/v8.md
|
||||
// §10). `treeBridge.js` reads the same five labelled groups off the SHARD, over
|
||||
// the sidecar, and hands back files in exactly the shape `readSources` produces
|
||||
// here — which is why `buildFromFiles` below is where the parse actually starts
|
||||
// and both readers feed it. That closes the one place the platform's rule (only
|
||||
// the sidecar bridges the shard) was broken, and broken by the component that
|
||||
// faces the internet: this file's `SERVUO_PATH` required the WEBSITE to be able
|
||||
// to read the shard's directories.
|
||||
//
|
||||
// The shard's own files are the single source of truth. Nothing is precomputed
|
||||
// and committed, because a shard's maps change over its lifetime — facets get
|
||||
// added, replaced or renamed — and a snapshot in the repo would silently go
|
||||
@@ -48,8 +57,20 @@ class AtlasSourceError extends Error {
|
||||
|
||||
// ── Reading ────────────────────────────────────────────────────────────────
|
||||
|
||||
function sha256(text) {
|
||||
return crypto.createHash('sha256').update(text, 'utf8').digest('hex')
|
||||
/**
|
||||
* The fingerprint of one source file, over its RAW BYTES.
|
||||
*
|
||||
* Bytes rather than the decoded string, so that this reader and the bridge
|
||||
* reader cannot disagree. The shard hashes what it sends; a hash taken here over
|
||||
* `text` would be a hash of a UTF-8 RE-ENCODING of what was read — identical for
|
||||
* every valid UTF-8 file, and different for one that is not, because Node's utf8
|
||||
* decode replaces each undecodable byte with U+FFFD and the re-encode never gets
|
||||
* them back. A spawn file with one Latin-1 character in a creature name would
|
||||
* then fingerprint differently depending on which end read it, and the drift gate
|
||||
* would report a change on every single import, forever, with the tree untouched.
|
||||
*/
|
||||
function sha256(bytes) {
|
||||
return crypto.createHash('sha256').update(bytes).digest('hex')
|
||||
}
|
||||
|
||||
function listXml(dir) {
|
||||
@@ -93,7 +114,7 @@ function listCfgTree(dir, prefix = '') {
|
||||
|
||||
function readIfPresent(file) {
|
||||
try {
|
||||
return fs.readFileSync(file, 'utf8')
|
||||
return fs.readFileSync(file)
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return null
|
||||
throw err
|
||||
@@ -118,9 +139,14 @@ function readSources(root) {
|
||||
|
||||
const files = []
|
||||
const push = (label, file) => {
|
||||
const text = readIfPresent(file)
|
||||
if (text === null) return false
|
||||
files.push({ label, text, sha256: sha256(text), bytes: Buffer.byteLength(text, 'utf8') })
|
||||
const bytes = readIfPresent(file)
|
||||
if (bytes === null) return false
|
||||
files.push({
|
||||
label,
|
||||
text: bytes.toString('utf8'),
|
||||
sha256: sha256(bytes),
|
||||
bytes: bytes.length,
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -180,8 +206,14 @@ function hashSources(root) {
|
||||
* targets (Phase 12b). The bump is what re-reads a tree the boot path
|
||||
* would otherwise skip on an unchanged hash — the source files have not
|
||||
* changed, only what is kept from them.
|
||||
* 5 — source files are parsed in one canonical label order (protocol 8 phase
|
||||
* 7). The decoration index keeps the first item id it sees for a type, so
|
||||
* the read order decided a preview graphic; it now cannot differ between a
|
||||
* tree read off a disk and the same tree read over the bridge. Identical
|
||||
* sources, and for a handful of types a different answer, which is exactly
|
||||
* what this number exists to make reach an install.
|
||||
*/
|
||||
const PARSER_VERSION = 4
|
||||
const PARSER_VERSION = 5
|
||||
|
||||
/** True when two source fingerprints describe the same tree. */
|
||||
function sameSources(a, b) {
|
||||
@@ -251,8 +283,45 @@ function aggregateCreatures(points) {
|
||||
* here writes. `shardAtlas.model.js` decides what to do with the result.
|
||||
*/
|
||||
function buildAtlas(root, options = {}) {
|
||||
const { files } = readSources(root)
|
||||
return buildFromFiles(readSources(root).files, options)
|
||||
}
|
||||
|
||||
/**
|
||||
* The parse itself, over files that have already been read.
|
||||
*
|
||||
* Split out in phase 7 so that a tree which arrived over the sidecar and a tree
|
||||
* read off a local disk go through the SAME code from here on. The alternative —
|
||||
* a second build for the bridge — would have been a second place for the facet
|
||||
* reconciliation, the decoration case-folding and the disabled-spawner filter to
|
||||
* be subtly different, and the difference would only ever show up as one install
|
||||
* having a slightly wrong atlas.
|
||||
*/
|
||||
function buildFromFiles(files, options = {}) {
|
||||
// **Sorted here, once, whatever order the reader handed them over in.**
|
||||
//
|
||||
// Order is not cosmetic in this parse: the decoration index keeps the FIRST
|
||||
// item id it sees for a type and the first spelling of it, and `meta.source` is
|
||||
// written in iteration order. Both readers happen to agree on a stock tree, and
|
||||
// "happen to" is the problem — the filesystem reader walks each decoration
|
||||
// directory with `localeCompare` while the shard sorts whole relative paths,
|
||||
// and those two disagree the moment a directory mixes cases. A tree read over
|
||||
// the bridge would then produce a subtly different atlas from the same tree read
|
||||
// off a disk, in a way nothing reports and only a side-by-side diff would find.
|
||||
//
|
||||
// A plain ordinal comparison rather than `localeCompare`, because the answer
|
||||
// must not depend on the host's ICU data either.
|
||||
files = [...files].sort((a, b) => (a.label < b.label ? -1 : a.label > b.label ? 1 : 0))
|
||||
|
||||
const byLabel = new Map(files.map((file) => [file.label, file]))
|
||||
|
||||
if (!byLabel.has('Data/Regions.xml')) {
|
||||
throw new AtlasSourceError('Missing required file: Data/Regions.xml', 'NO_REGIONS')
|
||||
}
|
||||
|
||||
if (!files.some((file) => file.label.startsWith('Spawns/'))) {
|
||||
throw new AtlasSourceError('No spawn files found in Spawns', 'NO_SPAWNS')
|
||||
}
|
||||
|
||||
const source = {}
|
||||
for (const file of files) source[file.label] = { bytes: file.bytes, sha256: file.sha256 }
|
||||
|
||||
@@ -403,6 +472,49 @@ function buildAtlas(root, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Backends ───────────────────────────────────────────────────────────────
|
||||
//
|
||||
// One descriptor, two readers (§10, phase 7). A source is `{ kind: 'fs', root }`
|
||||
// or `{ kind: 'bridge' }`, and everything above this line belongs to the first.
|
||||
//
|
||||
// `treeBridge` is required lazily and INSIDE the functions rather than at the top
|
||||
// of the file, because this module is also loaded by `scripts/importSpawnAtlas.js`
|
||||
// and by tests that have no sidecar, no core logger and no intention of touching
|
||||
// either. A top-level require would drag the whole client stack into both.
|
||||
|
||||
/** `{ files }` from whichever end this source names. */
|
||||
async function readFrom(source) {
|
||||
if (source?.kind === 'bridge') {
|
||||
const { files } = await require('./treeBridge').readSources()
|
||||
return { files }
|
||||
}
|
||||
|
||||
return readSources(source?.root)
|
||||
}
|
||||
|
||||
/**
|
||||
* The `{ label: sha256 }` fingerprint, from whichever end.
|
||||
*
|
||||
* The bridge answers this from the MANIFEST alone — no file bytes cross the wire
|
||||
* to answer "has anything changed", which is the whole reason the manifest is a
|
||||
* separate call. A stock tree is one page and about 32 KB.
|
||||
*/
|
||||
async function hashFrom(source) {
|
||||
if (source?.kind === 'bridge') {
|
||||
const treeBridge = require('./treeBridge')
|
||||
const listing = await treeBridge.manifest()
|
||||
return treeBridge.fingerprintOf(listing.files)
|
||||
}
|
||||
|
||||
return hashSources(source?.root)
|
||||
}
|
||||
|
||||
/** The full atlas, from whichever end. */
|
||||
async function buildFrom(source, options = {}) {
|
||||
const { files } = await readFrom(source)
|
||||
return buildFromFiles(files, options)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
AtlasSourceError,
|
||||
PARSER_VERSION,
|
||||
@@ -410,6 +522,10 @@ module.exports = {
|
||||
hashSources,
|
||||
sameSources,
|
||||
buildAtlas,
|
||||
buildFromFiles,
|
||||
readFrom,
|
||||
hashFrom,
|
||||
buildFrom,
|
||||
aggregateCreatures,
|
||||
displayName,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user