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:
2026-07-28 16:41:33 -05:00
parent 353cce9f26
commit 2801ec8f4d
22 changed files with 1528 additions and 1018 deletions

View File

@@ -0,0 +1,375 @@
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'
const fs = require('fs')
const os = require('os')
const path = require('path')
const { test, after, beforeEach } = require('node:test')
const assert = require('node:assert/strict')
const {
AtlasSourceError,
aggregateCreatures,
displayName,
sameSources,
hashSources,
buildAtlas,
} = require('../src/utils/spawnAtlasSource')
const shardAtlas = require('../src/model/shardAtlas/shardAtlas.model')
const atlasDb = require('../src/model/shardAtlas/shardAtlas.db')
const settings = require('../src/model/settings/settings.model')
const db = require('../src/utils/db')
after(() => db.close())
// ── A tiny synthetic ServUO tree ───────────────────────────────────────────
//
// Deliberately uses facets that do NOT exist in stock UO. The atlas must not
// contain a built-in facet list anywhere: a shard may add facets, replace them
// outright, or rename them when its maps are updated, and everything has to keep
// working with no code change.
function writeTree(root, { facets = ['Sosaria'], includeChampions = true } = {}) {
fs.mkdirSync(path.join(root, 'Spawns'), { recursive: true })
fs.mkdirSync(path.join(root, 'Data', 'Locations'), { recursive: true })
fs.mkdirSync(path.join(root, 'Config'), { recursive: true })
for (const facet of facets) {
fs.writeFileSync(
path.join(root, 'Spawns', `${facet}.xml`),
`<Spawns>
<Points><Name>${facet}A</Name><Map>${facet}</Map><X>1100</X><Y>1100</Y>
<MaxCount>3</MaxCount><IsRunning>True</IsRunning>
<Objects2>Lizardman:MX=3:SB=0:OBJ=Orc:MX=1:SB=0</Objects2></Points>
<Points><Name>${facet}B</Name><Map>${facet}</Map><X>9000</X><Y>9000</Y>
<MaxCount>1</MaxCount><IsRunning>True</IsRunning>
<Objects2>lizardman:MX=2:SB=0</Objects2></Points>
<Points><Name>${facet}Off</Name><Map>${facet}</Map><X>1</X><Y>1</Y>
<MaxCount>1</MaxCount><IsRunning>False</IsRunning>
<Objects2>Ghost:MX=1</Objects2></Points>
</Spawns>`,
'utf8',
)
// The location file names its facet differently from <Map>, the real
// `Ter Mur` / `Tokuno Islands` drift.
fs.writeFileSync(
path.join(root, 'Data', 'Locations', `${facet.toLowerCase()}.xml`),
`<places><parent name="${facet} Isles"><parent name="Deep Cave">
<child name="Level 1" x="9010" y="9010" z="0" /></parent></parent></places>`,
'utf8',
)
}
fs.writeFileSync(
path.join(root, 'Data', 'Regions.xml'),
`<ServerRegions>${facets
.map(
(facet) => `<Facet name="${facet}">
<region type="TownRegion" priority="10" name="${facet} City">
<rect x="1000" y="1000" width="500" height="500" />
</region></Facet>`,
)
.join('')}</ServerRegions>`,
'utf8',
)
if (includeChampions) {
fs.writeFileSync(
path.join(root, 'Config', 'ChampionSpawns.xml'),
`<championSystem><spawn name="Deep" group="G" type="Terror">
<location x="1100" y="1100" z="0" map="${facets[0]}" radius="40" />
</spawn></championSystem>`,
'utf8',
)
}
}
function tempTree(options) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'atlas-test-'))
writeTree(root, options)
return root
}
// ── buildAtlas against a custom-facet tree ─────────────────────────────────
test('buildAtlas: works entirely on facets that do not exist in stock UO', () => {
const root = tempTree({ facets: ['Sosaria', 'Underdark'] })
const atlas = buildAtlas(root)
assert.deepEqual(atlas.facets, ['Sosaria', 'Underdark'])
assert.equal(atlas.meta.counts.facets, 2)
})
test('buildAtlas: reconciles a location file that spells the facet differently', () => {
// "Sosaria Isles" inside the file vs <Map>Sosaria</Map> — the same drift that
// silently emptied the Ter Mur / Tokuno landmark buckets.
const root = tempTree({ facets: ['Sosaria'] })
const atlas = buildAtlas(root)
assert.deepEqual([...new Set(atlas.landmarks.map((l) => l.facet))], ['Sosaria'])
// And the fallback actually fires, rather than the point reading Wilderness.
const far = atlas.points.find((p) => p.name === 'SosariaB')
assert.equal(far.landmark, 'Deep Cave')
assert.equal(far.label, 'Deep Cave')
})
test('buildAtlas: resolves a contained point to its region', () => {
const atlas = buildAtlas(tempTree({ facets: ['Sosaria'] }))
const inCity = atlas.points.find((p) => p.name === 'SosariaA')
assert.equal(inCity.region, 'Sosaria City')
assert.equal(inCity.label, 'Sosaria City')
})
test('buildAtlas: drops spawners that are switched off in-world', () => {
const atlas = buildAtlas(tempTree({ facets: ['Sosaria'] }))
assert.equal(atlas.points.some((p) => p.name === 'SosariaOff'), false)
assert.equal(atlas.meta.counts.pointsDisabled, 1)
})
test('buildAtlas: a champion altar resolves through the same placement index', () => {
const atlas = buildAtlas(tempTree({ facets: ['Sosaria'] }))
assert.equal(atlas.champions[0].label, 'Sosaria City')
assert.equal(atlas.champions[0].facet, 'Sosaria')
})
test('buildAtlas: a tree with no champion file still builds', () => {
const atlas = buildAtlas(tempTree({ facets: ['Sosaria'], includeChampions: false }))
assert.deepEqual(atlas.champions, [])
})
test('buildAtlas: missing path and empty path raise typed errors', () => {
assert.throws(() => buildAtlas(''), (err) => err instanceof AtlasSourceError && err.code === 'NO_PATH')
assert.throws(
() => buildAtlas(path.join(os.tmpdir(), 'definitely-not-a-servuo-tree-xyz')),
(err) => err instanceof AtlasSourceError && err.code === 'NOT_FOUND',
)
})
test('buildAtlas: a directory with no spawn files raises rather than building empty', () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'atlas-empty-'))
fs.mkdirSync(path.join(root, 'Data'), { recursive: true })
fs.writeFileSync(path.join(root, 'Data', 'Regions.xml'), '<ServerRegions/>', 'utf8')
assert.throws(() => buildAtlas(root), (err) => err.code === 'NO_SPAWNS')
})
// ── Hashing ────────────────────────────────────────────────────────────────
test('hashSources: stable across reads, changes when a file changes', () => {
const root = tempTree({ facets: ['Sosaria'] })
const first = hashSources(root)
assert.ok(sameSources(first, hashSources(root)))
fs.appendFileSync(path.join(root, 'Spawns', 'Sosaria.xml'), '<!-- edit -->', 'utf8')
assert.equal(sameSources(first, hashSources(root)), false)
})
test('sameSources: a missing or extra file is a difference', () => {
assert.equal(sameSources({ a: '1' }, { a: '1', b: '2' }), false)
assert.equal(sameSources({ a: '1' }, { a: '2' }), false)
assert.equal(sameSources({ a: '1' }, { a: '1' }), true)
assert.equal(sameSources(null, { a: '1' }), false)
assert.equal(sameSources({ a: '1' }, null), false)
})
// ── Aggregation ────────────────────────────────────────────────────────────
const POINTS = [
{ facet: 'Sosaria', types: [{ type: 'Lizardman', max: 3 }, { type: 'Orc', max: 1 }] },
{ facet: 'Sosaria', types: [{ type: 'Lizardman', max: 2 }] },
{ facet: 'Underdark', types: [{ type: 'lizardman', max: 5 }] },
]
test('aggregateCreatures: sums each types own max and counts per facet', () => {
const lizardman = aggregateCreatures(POINTS).find((c) => c.slug === 'lizardman')
assert.equal(lizardman.total, 10)
assert.equal(lizardman.points, 3)
assert.deepEqual(lizardman.facets, { Sosaria: 2, Underdark: 1 })
})
test('aggregateCreatures: differing case collapses to one creature', () => {
const creatures = aggregateCreatures(POINTS)
assert.equal(creatures.filter((c) => c.slug === 'lizardman').length, 1)
assert.deepEqual(creatures.map((c) => c.slug), ['lizardman', 'orc'])
assert.equal(Object.hasOwn(creatures[0], 'spellings'), false)
})
test('displayName: most common wins, ties break to the capitalised form', () => {
assert.equal(displayName(new Map([['lizardman', 9], ['Lizardman', 2]])), 'lizardman')
assert.equal(displayName(new Map([['lizardman', 5], ['Lizardman', 5]])), 'Lizardman')
// Deterministic regardless of insertion order — a committed artifact is gone,
// but a spurious diff in the DB on every restart would be just as wrong.
assert.equal(
displayName(new Map([['abc', 1], ['abd', 1]])),
displayName(new Map([['abd', 1], ['abc', 1]])),
)
})
test('pointTypeRows: collapses a repeated type to the larger max', () => {
// The primary key is (point_id, slug), so a duplicate would otherwise fail the
// insert and take the whole transaction with it.
const rows = shardAtlas.pointTypeRows([
{ types: [{ type: 'Orc', max: 1 }, { type: 'orc', max: 4 }, { type: 'Rat', max: 2 }] },
])
assert.deepEqual(rows.sort(), [[1, 'orc', 4], [1, 'rat', 2]].sort())
})
test('pointTypeRows: point ids are 1-based and line up with insert order', () => {
const rows = shardAtlas.pointTypeRows([
{ types: [{ type: 'A', max: 1 }] },
{ types: [{ type: 'B', max: 1 }] },
])
assert.deepEqual(rows, [[1, 'a', 1], [2, 'b', 1]])
})
// ── The refresh decision ───────────────────────────────────────────────────
//
// The boot path's two contracts: it never blocks startup, and it never applies a
// facet removal on its own.
let applied
let pendingRow
let facetsInDb
let metaRow
beforeEach(() => {
applied = null
pendingRow = null
facetsInDb = []
metaRow = null
atlasDb.replaceAtlas = async (atlas) => {
applied = atlas
return { points: atlas.points.length, creatures: atlas.creatures.length }
}
atlasDb.getMeta = async () => metaRow
atlasDb.getFacets = async () => facetsInDb
atlasDb.getPending = async () => pendingRow
atlasDb.setPending = async (payload, status) => {
pendingRow = { ...payload, status }
}
atlasDb.clearPending = async () => {
pendingRow = null
}
settings.get = async () => ''
process.env.SERVUO_PATH = ''
})
test('refresh: no configured path is skipped, not an error', async () => {
const result = await shardAtlas.refresh()
assert.equal(result.status, 'skipped')
})
test('refresh: an unreadable tree reports unavailable rather than throwing', async () => {
const result = await shardAtlas.refresh({ path: path.join(os.tmpdir(), 'no-such-tree-abc') })
assert.equal(result.status, 'unavailable')
assert.equal(result.code, 'NOT_FOUND')
})
test('refresh: a fresh database imports', async () => {
const root = tempTree({ facets: ['Sosaria'] })
const result = await shardAtlas.refresh({ path: root })
assert.equal(result.status, 'imported')
assert.ok(applied)
assert.deepEqual(result.addedFacets, ['Sosaria'])
})
test('refresh: an unchanged tree parses nothing and writes nothing', async () => {
const root = tempTree({ facets: ['Sosaria'] })
metaRow = { source: buildAtlas(root).meta.source }
const result = await shardAtlas.refresh({ path: root })
assert.equal(result.status, 'unchanged')
assert.equal(applied, null)
})
test('refresh: --force reimports an unchanged tree', async () => {
const root = tempTree({ facets: ['Sosaria'] })
metaRow = { source: buildAtlas(root).meta.source }
const result = await shardAtlas.refresh({ path: root, force: true })
assert.equal(result.status, 'imported')
assert.ok(applied)
})
test('refresh: a NEW facet applies straight away', async () => {
// Additions cannot destroy anything an operator would miss.
const root = tempTree({ facets: ['Sosaria', 'Underdark'] })
facetsInDb = ['Sosaria']
const result = await shardAtlas.refresh({ path: root })
assert.equal(result.status, 'imported')
assert.deepEqual(result.addedFacets, ['Underdark'])
})
test('refresh: a REMOVED facet is staged, not applied', async () => {
const root = tempTree({ facets: ['Sosaria'] })
facetsInDb = ['Sosaria', 'Underdark']
const result = await shardAtlas.refresh({ path: root })
assert.equal(result.status, 'needsReview')
assert.deepEqual(result.removedFacets, ['Underdark'])
// The critical part: the existing atlas was left alone.
assert.equal(applied, null)
assert.equal(pendingRow.status, 'pending')
})
test('refresh: approving applies the removal', async () => {
const root = tempTree({ facets: ['Sosaria'] })
facetsInDb = ['Sosaria', 'Underdark']
await shardAtlas.refresh({ path: root })
assert.equal(applied, null)
const result = await shardAtlas.approvePending({ path: root })
assert.equal(result.status, 'imported')
assert.ok(applied)
assert.deepEqual(result.removedFacets, ['Underdark'])
})
test('refresh: a rejected refresh does not re-prompt while the tree is unchanged', async () => {
const root = tempTree({ facets: ['Sosaria'] })
facetsInDb = ['Sosaria', 'Underdark']
await shardAtlas.refresh({ path: root })
await shardAtlas.rejectPending()
assert.equal(pendingRow.status, 'rejected')
const again = await shardAtlas.refresh({ path: root })
assert.equal(again.status, 'unchanged')
assert.equal(applied, null)
})
test('refresh: changing the tree asks again after a rejection', async () => {
const root = tempTree({ facets: ['Sosaria'] })
facetsInDb = ['Sosaria', 'Underdark']
await shardAtlas.refresh({ path: root })
await shardAtlas.rejectPending()
fs.appendFileSync(path.join(root, 'Spawns', 'Sosaria.xml'), '<!-- changed -->', 'utf8')
const again = await shardAtlas.refresh({ path: root })
assert.equal(again.status, 'needsReview')
})
test('refreshOnBoot: never throws, whatever goes wrong', async () => {
atlasDb.getMeta = async () => {
throw new Error('database is on fire')
}
atlasDb.getFacets = async () => {
throw new Error('still on fire')
}
atlasDb.replaceAtlas = async () => {
throw new Error('and the import too')
}
process.env.SERVUO_PATH = tempTree({ facets: ['Sosaria'] })
const result = await shardAtlas.refreshOnBoot()
assert.equal(result.status, 'failed')
})
test('refreshOnBoot: a missing tree is survivable, not fatal', async () => {
process.env.SERVUO_PATH = path.join(os.tmpdir(), 'nope-not-here-xyz')
const result = await shardAtlas.refreshOnBoot()
assert.equal(result.status, 'unavailable')
})
test('refresh: an explicit path overrides the configured one', async () => {
const configured = tempTree({ facets: ['Configured'] })
const override = tempTree({ facets: ['Override'] })
settings.get = async () => configured
const result = await shardAtlas.refresh({ path: override })
assert.equal(result.status, 'imported')
assert.deepEqual(result.addedFacets, ['Override'])
})