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
128 lines
4.6 KiB
JavaScript
128 lines
4.6 KiB
JavaScript
#!/usr/bin/env node
|
|
//
|
|
// Refresh the spawn atlas from a ServUO tree, from the command line.
|
|
//
|
|
// npm run atlas:import # use the configured path
|
|
// npm run atlas:import -- --servuo <path> # override it for this run
|
|
// npm run atlas:import -- --force # reimport even if unchanged
|
|
// npm run atlas:import -- --approve # apply a staged refresh
|
|
// npm run atlas:import -- --status # report without changing anything
|
|
//
|
|
// The server does this itself on every boot (see `shardAtlas.refreshOnBoot`), so
|
|
// this is for operators who want to apply a map change without a restart, and
|
|
// for approving a refresh that was staged because it would remove a facet.
|
|
//
|
|
// All the logic lives in `src/model/shardAtlas/shardAtlas.model.js`; this file
|
|
// is argument parsing and output formatting.
|
|
|
|
const db = () => require('../src/utils/db')
|
|
|
|
function parseArgs(argv) {
|
|
const args = {}
|
|
for (let i = 0; i < argv.length; i += 1) {
|
|
const flag = argv[i]
|
|
if (flag === '--servuo') args.servuo = argv[++i]
|
|
else if (flag === '--force') args.force = true
|
|
else if (flag === '--approve') args.approve = true
|
|
else if (flag === '--reject') args.reject = true
|
|
else if (flag === '--status') args.status = true
|
|
else if (flag === '--help' || flag === '-h') args.help = true
|
|
}
|
|
return args
|
|
}
|
|
|
|
const USAGE = `
|
|
Refresh the spawn atlas from a ServUO tree.
|
|
|
|
node scripts/importSpawnAtlas.js [options]
|
|
|
|
--servuo <path> Use this tree for this run instead of the configured path.
|
|
--force Reimport even when the source files are unchanged.
|
|
--approve Apply a refresh that was staged for removing a facet.
|
|
--reject Keep the current atlas and dismiss the staged refresh.
|
|
--status Report atlas and source state; change nothing.
|
|
|
|
With no options this imports only if the tree differs from what is loaded.
|
|
`
|
|
|
|
function describe(result) {
|
|
switch (result.status) {
|
|
case 'skipped':
|
|
return (
|
|
'No ServUO path configured — nothing to import.\n' +
|
|
'Set one with SERVUO_PATH, the admin panel, or --servuo <path>.\n'
|
|
)
|
|
case 'unavailable':
|
|
return `ServUO tree unavailable: ${result.reason}\n`
|
|
case 'unchanged':
|
|
return `Atlas is already up to date${result.reason ? ` (${result.reason})` : ''}.\n`
|
|
case 'needsReview': {
|
|
return (
|
|
'Refresh NOT applied — it would remove ' +
|
|
`${result.removedFacets.length} facet(s): ${result.removedFacets.join(', ')}.\n` +
|
|
'This is what a half-copied or mid-update tree looks like, so it has been\n' +
|
|
'staged for review. The current atlas is unchanged.\n' +
|
|
'Apply it with --approve, or dismiss it with --reject.\n'
|
|
)
|
|
}
|
|
case 'imported': {
|
|
const c = result.counts
|
|
const added = result.addedFacets?.length ? ` Added facets: ${result.addedFacets.join(', ')}.` : ''
|
|
const removed = result.removedFacets?.length
|
|
? ` Removed facets: ${result.removedFacets.join(', ')}.`
|
|
: ''
|
|
return (
|
|
`Atlas imported: ${c.points} points, ${c.creatures} creatures, ` +
|
|
`${c.pointTypes} point/type rows, ${c.regions} regions, ` +
|
|
`${c.landmarks} landmarks, ${c.champions} champion altars.${added}${removed}\n`
|
|
)
|
|
}
|
|
case 'failed':
|
|
return `Atlas refresh failed: ${result.reason}\n`
|
|
default:
|
|
return `${JSON.stringify(result, null, 2)}\n`
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
const args = parseArgs(process.argv.slice(2))
|
|
if (args.help) {
|
|
process.stdout.write(USAGE)
|
|
return
|
|
}
|
|
|
|
const shardAtlas = require('../src/model/shardAtlas/shardAtlas.model')
|
|
|
|
// `--servuo` is a per-run override and deliberately does NOT persist to the
|
|
// configured path; changing where the atlas permanently reads from is an
|
|
// admin action, not a side effect of a one-off import.
|
|
const override = { path: args.servuo ?? '' }
|
|
|
|
if (args.status) {
|
|
process.stdout.write(`${JSON.stringify(await shardAtlas.status(override), null, 2)}\n`)
|
|
return
|
|
}
|
|
if (args.reject) {
|
|
process.stdout.write(`${JSON.stringify(await shardAtlas.rejectPending(), null, 2)}\n`)
|
|
return
|
|
}
|
|
|
|
const result = args.approve
|
|
? await shardAtlas.approvePending(override)
|
|
: await shardAtlas.refresh({ ...override, force: Boolean(args.force) })
|
|
|
|
process.stdout.write(describe(result))
|
|
if (result.status === 'failed') process.exitCode = 1
|
|
}
|
|
|
|
if (require.main === module) {
|
|
main()
|
|
.catch((err) => {
|
|
process.stderr.write(`atlas:import failed: ${err.message}\n`)
|
|
process.exitCode = 1
|
|
})
|
|
.finally(() => db().close())
|
|
}
|
|
|
|
module.exports = { describe, parseArgs }
|