refactor(atlas): derive the atlas from the shard's tree on every boot
Replaces the committed-artifact design from the first commit. Two problems with it, both raised in review: **Facets are not a fixed list.** The first pass carried a hardcoded table of the six stock UO facets to reconcile the spelling drift between sources. That is wrong: a shard may add facets, replace them outright, or rename them when its maps are updated, and a built-in list quietly mishandles all three. Nothing in the atlas names a facet any more. The facet set is discovered from the tree — spawn records and region definitions are the authority — and the loose spellings in Data/Locations are matched against it by key and prefix. Custom facets get identical treatment; the tests use `Sosaria` and `Underdark` precisely so a stock-facet assumption cannot creep back in. **A snapshot goes stale.** Maps change over a server's life, so a build-once artifact silently drifts from the world players actually see. The tree is now the single source of truth and the atlas is re-derived on every boot. ## What that changed - **The committed artifact is gone** — 1.41 MB of generated JSON removed, along with `scripts/buildSpawnAtlas.js` and the whole encode/decode seam it needed (`encodePoint`/`readPoint`, the tuple encoding, the omitted-defaults scheme and their round-trip tests). Nothing to keep in sync, nothing to go stale. - **NEW `src/utils/spawnAtlasSource.js`** — the only thing that touches a ServUO tree; shared by the boot path and the CLI. Parsers stay pure and fs-free. - **NEW `src/model/shardAtlas/`** — `.db.js` (the one-transaction replace) and `.model.js` (the refresh decision). - **`scripts/importSpawnAtlas.js`** is now a thin CLI over the model: `--servuo`, `--force`, `--approve`, `--reject`, `--status`. `atlas:build` is gone; `atlas:import` remains. - Path comes from the `spawn_atlas_servuo_path` admin setting, falling back to `SERVUO_PATH`. The setting wins, matching how the rest of the shard integration is admin-managed rather than env-configured. ## Two contracts on the boot path **It never blocks startup.** No path, an unreadable mount, a malformed file, a database error — every one is caught and logged, and the site comes up serving whatever atlas it already had. Verified by booting the real server with no path, a broken path, and a good path. **A facet disappearing is never applied automatically.** Losing a facet is the signature of a half-copied or mid-update tree as much as of a real map change, and boot cannot tell them apart. The refresh is staged in `shard_atlas_pending` for an admin to approve or reject, and startup continues regardless. Additions and every other change apply immediately, since none of them can destroy something an operator would miss. Only the decision is stored, not the parsed world: a few KB of source hashes and the facet diff. Approving re-parses, so what gets applied matches the tree at approval time rather than at boot. A rejection is remembered against those exact hashes, so a declined refresh does not re-prompt on every restart — changing the tree changes the hashes and asks again. Hash-gated, so the common case (restart, maps unchanged) reads and hashes the tree (~120 ms) and writes nothing. A real change costs a ~400 ms parse. The admin approve/reject UI is part of the second PR, with the rest of the routes and pages. Until then the CLI covers it. ## Verification - **564 server tests pass**, 28 new in `spawnAtlas.source.test.js` covering the custom-facet build, the spelling reconciliation, hash gating, and every branch of the refresh decision — including that `refreshOnBoot` survives a database that throws on every call. - End-to-end against the local MariaDB and the real ServUO tree: 6,455 points, 800 creatures, 23,927 point/type rows, 387 regions, 558 landmarks, 25 altars, 83.2% of points resolved to a place name. - The facet gate exercised against a real tree copy with `malas.xml` removed: staged rather than applied, atlas untouched with all 293 Malas points intact, reject then stays quiet on re-run, approve applies and drops the facet. - Booted the real server under all three source conditions; none blocked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U7CBg11prhLimL9iHSX1bP
This commit is contained in:
@@ -10,7 +10,9 @@ const {
|
||||
parseChampions,
|
||||
buildPlacementIndex,
|
||||
resolveRegion,
|
||||
normalizeFacet,
|
||||
facetKey,
|
||||
buildFacetIndex,
|
||||
resolveFacetName,
|
||||
slugify,
|
||||
decodeEntities,
|
||||
} = require('../src/utils/spawnAtlasParse')
|
||||
@@ -274,31 +276,90 @@ test('parseLocations: flattens to points carrying their group', () => {
|
||||
|
||||
// ── Facet canonicalisation ─────────────────────────────────────────────────
|
||||
|
||||
test('normalizeFacet: reconciles the Locations spellings with <Map>', () => {
|
||||
// Left unreconciled, every unregioned Ter Mur and Tokuno spawn silently
|
||||
// resolves to "Wilderness" because the landmark bucket is keyed differently.
|
||||
assert.equal(normalizeFacet('Ter Mur'), 'TerMur')
|
||||
assert.equal(normalizeFacet('TerMur'), 'TerMur')
|
||||
assert.equal(normalizeFacet('Tokuno Islands'), 'Tokuno')
|
||||
assert.equal(normalizeFacet('Tokuno'), 'Tokuno')
|
||||
assert.equal(normalizeFacet('felucca'), 'Felucca')
|
||||
// Facets are NOT a fixed list — a shard may add, replace or rename them when its
|
||||
// maps are updated, so nothing may hardcode the stock six. Reconciliation is by
|
||||
// matching against whatever the shard's own files declare.
|
||||
|
||||
test('facetKey: collapses spelling differences to one key', () => {
|
||||
assert.equal(facetKey('Ter Mur'), facetKey('TerMur'))
|
||||
assert.equal(facetKey('ter-mur'), facetKey('TerMur'))
|
||||
assert.equal(facetKey('Felucca'), 'felucca')
|
||||
assert.equal(facetKey(''), '')
|
||||
assert.equal(facetKey(null), '')
|
||||
})
|
||||
|
||||
test('normalizeFacet: an unknown facet passes through instead of vanishing', () => {
|
||||
assert.equal(normalizeFacet('CustomShardFacet'), 'CustomShardFacet')
|
||||
assert.equal(normalizeFacet(''), '')
|
||||
assert.equal(normalizeFacet(null), '')
|
||||
test('facetKey: distinct facets keep distinct keys', () => {
|
||||
assert.notEqual(facetKey('Felucca'), facetKey('Trammel'))
|
||||
})
|
||||
|
||||
test('parseLocations and parsePoints agree on facet after normalisation', () => {
|
||||
const landmarks = parseLocations(
|
||||
'<places><parent name="Ter Mur"><parent name="Holy City">' +
|
||||
'<child name="Bank" x="1000" y="1000" z="0" /></parent></parent></places>',
|
||||
)
|
||||
test('resolveFacetName: matches a loose spelling to the discovered canonical', () => {
|
||||
// The canonical set comes from the shard's own spawn/region data, not a table.
|
||||
const index = buildFacetIndex(['TerMur', 'Tokuno', 'Felucca'])
|
||||
assert.equal(resolveFacetName('Ter Mur', index), 'TerMur')
|
||||
assert.equal(resolveFacetName('Tokuno Islands', index), 'Tokuno')
|
||||
assert.equal(resolveFacetName('felucca', index), 'Felucca')
|
||||
})
|
||||
|
||||
test('resolveFacetName: works for facets that do not exist in stock UO', () => {
|
||||
// The whole point: a shard running its own maps gets the same treatment as
|
||||
// the stock ones, with no entry anywhere naming them.
|
||||
const index = buildFacetIndex(['Sosaria', 'The Underdark'])
|
||||
assert.equal(resolveFacetName('sosaria', index), 'Sosaria')
|
||||
assert.equal(resolveFacetName('The Underdark', index), 'The Underdark')
|
||||
assert.equal(resolveFacetName('the-underdark', index), 'The Underdark')
|
||||
// Same shape as the real `Tokuno Islands` → `Tokuno` case.
|
||||
assert.equal(resolveFacetName('Sosaria Isles', index), 'Sosaria')
|
||||
})
|
||||
|
||||
test('resolveFacetName: a merely similar name is NOT forced to match', () => {
|
||||
// "Underdark Isles" is not a prefix of "The Underdark" in either direction.
|
||||
// Keeping its own name is right — a wrong match would silently file a real
|
||||
// custom facet's landmarks under the wrong facet.
|
||||
const index = buildFacetIndex(['The Underdark'])
|
||||
assert.equal(resolveFacetName('Underdark Isles', index), 'Underdark Isles')
|
||||
})
|
||||
|
||||
test('resolveFacetName: prefers the longer match when several could prefix', () => {
|
||||
const index = buildFacetIndex(['Tokuno', 'TokunoDeep'])
|
||||
assert.equal(resolveFacetName('TokunoDeep Reaches', index), 'TokunoDeep')
|
||||
})
|
||||
|
||||
test('resolveFacetName: an unmatched facet keeps its own name', () => {
|
||||
// Inventing a match would be worse than leaving a real custom facet alone.
|
||||
const index = buildFacetIndex(['Felucca'])
|
||||
assert.equal(resolveFacetName('Ilshenar', index), 'Ilshenar')
|
||||
assert.equal(resolveFacetName('', index), '')
|
||||
assert.equal(resolveFacetName(null, index), '')
|
||||
})
|
||||
|
||||
test('buildFacetIndex: first spelling wins and is stable', () => {
|
||||
const index = buildFacetIndex(['TerMur', 'Ter Mur', 'ter-mur'])
|
||||
assert.equal(index.size, 1)
|
||||
assert.equal(resolveFacetName('Ter Mur', index), 'TerMur')
|
||||
})
|
||||
|
||||
test('parsePoints and parseRegions report facet names verbatim', () => {
|
||||
// <Map> and <Facet name> are the authority; they are never rewritten.
|
||||
const points = parsePoints(
|
||||
'<Spawns><Points><Name>a</Name><Map>TerMur</Map><X>1000</X><Y>1000</Y></Points></Spawns>',
|
||||
'<Spawns><Points><Name>a</Name><Map>Sosaria</Map><X>1</X><Y>2</Y></Points></Spawns>',
|
||||
)
|
||||
assert.equal(landmarks[0].facet, points[0].facet)
|
||||
assert.equal(points[0].facet, 'Sosaria')
|
||||
const regions = parseRegions(
|
||||
'<ServerRegions><Facet name="Sosaria"><region name="Town" priority="1">' +
|
||||
'<rect x="0" y="0" width="10" height="10"/></region></Facet></ServerRegions>',
|
||||
)
|
||||
assert.equal(regions[0].facet, 'Sosaria')
|
||||
})
|
||||
|
||||
test('placement index buckets two spellings of one facet together', () => {
|
||||
// This is the bug the key exists to prevent: unreconciled, the landmark bucket
|
||||
// is keyed apart from the points looking it up, the fallback never fires, and
|
||||
// every unregioned spawn on that facet silently reads "Wilderness".
|
||||
const index = buildPlacementIndex(
|
||||
[],
|
||||
[{ facet: 'Ter Mur', name: 'Bank', group: 'Holy City', path: [], x: 1000, y: 1000, z: 0 }],
|
||||
)
|
||||
assert.equal(resolveRegion(1000, 1000, 'TerMur', index).landmark, 'Holy City')
|
||||
})
|
||||
|
||||
// ── parseChampions ─────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user