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:
9
.gitignore
vendored
9
.gitignore
vendored
@@ -21,11 +21,10 @@ uploads/
|
||||
server/logs/
|
||||
logs/
|
||||
|
||||
# Operator-supplied spawn atlas artwork. The atlas artifact itself
|
||||
# (db/data/spawnAtlas.*.json) IS committed, but creature art is not: sprites are
|
||||
# extracted from the operator's own UO client .mul/.uop files and are theirs, not
|
||||
# ours to redistribute. The art map is generated locally; the images it points at
|
||||
# live under server/uploads/atlas/, already ignored above.
|
||||
# Operator-supplied spawn atlas artwork. Creature art is never committed: sprites
|
||||
# are extracted from the operator's own UO client .mul/.uop files and are theirs,
|
||||
# not ours to redistribute. The images live under server/uploads/atlas/, already
|
||||
# ignored above; this is the slug -> file-name map pointing at them.
|
||||
# See docs/website/SPAWN_ATLAS.md and db/data/spawnAtlas.art.example.json.
|
||||
server/db/data/spawnAtlas.art.json
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
24
server/db/data/spawnAtlas.art.example.json
Normal file
24
server/db/data/spawnAtlas.art.example.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"_comment": [
|
||||
"OPTIONAL operator-supplied creature art for the spawn atlas. Copy this file to",
|
||||
"spawnAtlas.art.json (same directory) and edit it, then restart the server or run",
|
||||
"`npm run atlas:import` — the art map is read on every atlas refresh.",
|
||||
"",
|
||||
"This project ships NO creature artwork and never will. UO sprites live in your",
|
||||
"own client's .mul/.uop files and are yours to extract, not ours to redistribute.",
|
||||
"If you want art on the atlas pages, export it yourself (UOFiddler, ClassicUO's",
|
||||
"tooling, or any art extractor), drop the images under server/uploads/atlas/, and",
|
||||
"map each creature slug to its file name here.",
|
||||
"",
|
||||
"Both spawnAtlas.art.json and server/uploads/ are gitignored, so neither the map",
|
||||
"nor the images can be committed by accident.",
|
||||
"",
|
||||
"Keys are creature slugs, as reported by the atlas API and derived from the type",
|
||||
"names in your own shard's Spawns/*.xml. Values are file names relative to",
|
||||
"server/uploads/atlas/. Any creature with no entry here simply renders without",
|
||||
"art — that is the default and fully supported state, not a degraded one."
|
||||
],
|
||||
"lizardman": "lizardman.png",
|
||||
"orc": "orc.png",
|
||||
"dragon": "dragon.png"
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,100 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"generatedAt": "2026-07-28T21:05:07.987Z",
|
||||
"counts": {
|
||||
"facets": 6,
|
||||
"points": 6455,
|
||||
"pointsDisabled": 10,
|
||||
"creatures": 800,
|
||||
"regions": 387,
|
||||
"landmarks": 558,
|
||||
"champions": 25,
|
||||
"unresolvedPoints": 1086
|
||||
},
|
||||
"source": {
|
||||
"Data/Regions.xml": {
|
||||
"bytes": 129008,
|
||||
"sha256": "05c505b3806a3ee617ce503f6cb9ccf1d0ebadca0821d8d9346587d36a790ed2"
|
||||
},
|
||||
"Data/Locations/felucca.xml": {
|
||||
"bytes": 12481,
|
||||
"sha256": "cfd4c49a808c2bf1f904722740fe8dd3e10311ec9e832bd74fc17dcb171ad108"
|
||||
},
|
||||
"Data/Locations/ilshenar.xml": {
|
||||
"bytes": 4279,
|
||||
"sha256": "92a85e2ad894d4c428b0ba24c1d4547b585e47086aeb4b8119d24bc1a74f7fa0"
|
||||
},
|
||||
"Data/Locations/malas.xml": {
|
||||
"bytes": 3903,
|
||||
"sha256": "c7dafa6a958c8c7b7f0e7a448c679d90cb2f736a878c60b58057a4d1105b6d69"
|
||||
},
|
||||
"Data/Locations/termur.xml": {
|
||||
"bytes": 3568,
|
||||
"sha256": "065c8f4077eb77d6c65c089a8ef9d422a7f197cb6002859cb4296960c3619da5"
|
||||
},
|
||||
"Data/Locations/tokuno.xml": {
|
||||
"bytes": 1546,
|
||||
"sha256": "7c70950555fc6f983b6db6c85a569621ba02cbbdc42ab3f76bbaeff795cc1bb8"
|
||||
},
|
||||
"Data/Locations/trammel.xml": {
|
||||
"bytes": 11772,
|
||||
"sha256": "fd5cfc941bf6bf5dc685d6753d71fee869df3063688d060ded59cd4567531e57"
|
||||
},
|
||||
"Spawns/Eodon.xml": {
|
||||
"bytes": 100654,
|
||||
"sha256": "632053fe7ed1d2fbcb03c5b18d9bd13a700d1f66300574d12603202c56c8e4c3"
|
||||
},
|
||||
"Spawns/GravewaterLake.xml": {
|
||||
"bytes": 50657,
|
||||
"sha256": "4b9f48cc18c176382026e04eb8dbcf2a080afd1386fee47fb3fea469cad4fdac"
|
||||
},
|
||||
"Spawns/TheExodusEncounterQuest.xml": {
|
||||
"bytes": 25802,
|
||||
"sha256": "167498c8f5c8f3804a74046c0e1e6ebb7db709c5bd1cdac5623042abf9e694ff"
|
||||
},
|
||||
"Spawns/TreasuresOfKotl.xml": {
|
||||
"bytes": 8665,
|
||||
"sha256": "8e0ed17ef8942db7275719ce82989018d74caf8a382e86c4711c123ef6a913db"
|
||||
},
|
||||
"Spawns/felucca.xml": {
|
||||
"bytes": 3749158,
|
||||
"sha256": "eb2ed7de2623d3b95262eba9fffaddbc2dca24f53680e970e4e5f809def32891"
|
||||
},
|
||||
"Spawns/ilshenar.xml": {
|
||||
"bytes": 705359,
|
||||
"sha256": "e6b7cf796af2b72221ed2df7d718111f38055c170cdd463b222afb747048df5d"
|
||||
},
|
||||
"Spawns/malas.xml": {
|
||||
"bytes": 445464,
|
||||
"sha256": "2eb69d0006f507e027f8afc3e0e02b70a4caf32ca6e3ecdfef788c41a0907a3b"
|
||||
},
|
||||
"Spawns/solenhives.xml": {
|
||||
"bytes": 101524,
|
||||
"sha256": "0b73ae59c2058cd3354d5b11bec133d6f374eed43f9b818353640240af159ed9"
|
||||
},
|
||||
"Spawns/termur.xml": {
|
||||
"bytes": 187184,
|
||||
"sha256": "d6351e7879845f412d460433dad7438ae0795f1c38f371c78ec3dd4829b42eac"
|
||||
},
|
||||
"Spawns/tokuno.xml": {
|
||||
"bytes": 732784,
|
||||
"sha256": "f03fd70c301b97457b3e9732d6fef58c009eb6c498d15109ae0334eed133e860"
|
||||
},
|
||||
"Spawns/trammel.xml": {
|
||||
"bytes": 4226171,
|
||||
"sha256": "29d1e9d2fa5ce64ac0cc6396195ff3bdb464a140f6fb4ac61f1459174a2b9995"
|
||||
},
|
||||
"Spawns/twistedweald.xml": {
|
||||
"bytes": 35456,
|
||||
"sha256": "927e8f7177e1269231ea43a595fc156d9d94e4c3f2bfc1cc7574366d3aa495f8"
|
||||
},
|
||||
"Spawns/underworld.xml": {
|
||||
"bytes": 76729,
|
||||
"sha256": "e0c813331a52808b9bd93fd252cfcc8c967397ab8b0ead0e2c4e768db2f5baf6"
|
||||
},
|
||||
"Config/ChampionSpawns.xml": {
|
||||
"bytes": 4838,
|
||||
"sha256": "4e6798ae552d707e8f8a07ec611ebb7aca9046b8385017cad01ad0d397ca1171"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1148,6 +1148,30 @@ CREATE TABLE IF NOT EXISTS shard_atlas_meta (
|
||||
CONSTRAINT chk_shard_atlas_meta_singleton CHECK (id = 1)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Singleton (id = 1) holding an atlas refresh that was parsed but deliberately
|
||||
-- NOT applied, because it would remove a facet the site currently serves.
|
||||
--
|
||||
-- Losing a facet is the signature of a half-copied or mid-update ServUO tree as
|
||||
-- much as of a real map change, and boot cannot tell the two apart — so the
|
||||
-- refresh is staged here for a human instead of being applied. Startup is never
|
||||
-- blocked by it: the site comes up serving the atlas it already had.
|
||||
--
|
||||
-- Only the DECISION is stored, not the parsed world: `payload` holds the source
|
||||
-- hashes and the facet diff (a few KB), and approving re-parses the tree. That
|
||||
-- keeps a multi-megabyte blob out of the database and guarantees the applied
|
||||
-- atlas matches the tree as it is at approval time, not as it was at boot.
|
||||
--
|
||||
-- `rejected` is remembered against those exact source hashes so a declined
|
||||
-- refresh does not re-prompt on every restart; changing the tree changes the
|
||||
-- hashes and asks again.
|
||||
CREATE TABLE IF NOT EXISTS shard_atlas_pending (
|
||||
id TINYINT NOT NULL PRIMARY KEY DEFAULT 1,
|
||||
status ENUM('pending','rejected') NOT NULL DEFAULT 'pending',
|
||||
payload JSON NOT NULL, -- source hashes + facet diff
|
||||
detected_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT chk_shard_atlas_pending_singleton CHECK (id = 1)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Migrations for databases created before the wiki upgrade. Each statement uses
|
||||
-- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get
|
||||
-- these columns from the CREATE TABLE above; existing installs get them here.
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
"seed": "node db/seed.js",
|
||||
"swagger": "node swagger/swagger.js",
|
||||
"routes:manifest": "node scripts/routeManifest.js",
|
||||
"atlas:build": "node scripts/buildSpawnAtlas.js",
|
||||
"atlas:import": "node scripts/importSpawnAtlas.js",
|
||||
"test": "node --test"
|
||||
},
|
||||
|
||||
@@ -1,383 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
//
|
||||
// Build the committed spawn atlas artifact from a ServUO tree.
|
||||
//
|
||||
// npm run atlas:build -- --servuo C:\path\to\ServUO [--out db/data]
|
||||
//
|
||||
// This is the ONLY thing in the repo that reads a ServUO tree, and it is
|
||||
// CLI-only by design: the website container has no ServUO tree, and pushing
|
||||
// 10.5 MB of XML through a parser on every boot to produce data that changes
|
||||
// when an operator edits their spawns — that is, almost never — would be pure
|
||||
// waste. The build runs on a machine that has the tree, its output is committed,
|
||||
// and `atlas:import` (which needs no tree) loads it into the database.
|
||||
//
|
||||
// All parsing lives in `src/utils/spawnAtlasParse.js` as pure functions so it
|
||||
// is unit-tested in CI without a ServUO tree. This file is the fs/CLI shell
|
||||
// around it: read, transform, shard, write.
|
||||
//
|
||||
// It writes NO images. Creature art is deliberately not part of the artifact —
|
||||
// sprites live in the operator's own client files (`.mul`/`.uop`) and are theirs
|
||||
// to extract and supply. See `db/data/spawnAtlas.art.example.json` and
|
||||
// docs/website/SPAWN_ATLAS.md for that path; `art` stays NULL without it.
|
||||
|
||||
const crypto = require('crypto')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const {
|
||||
parsePoints,
|
||||
parseRegions,
|
||||
parseLocations,
|
||||
parseChampions,
|
||||
buildPlacementIndex,
|
||||
resolveRegion,
|
||||
normalizeFacet,
|
||||
slugify,
|
||||
} = require('../src/utils/spawnAtlasParse')
|
||||
|
||||
const ARTIFACT_VERSION = 1
|
||||
|
||||
// ── CLI ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = { servuo: '', out: path.join(__dirname, '..', 'db', 'data'), radius: undefined }
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const flag = argv[i]
|
||||
if (flag === '--servuo') args.servuo = argv[++i]
|
||||
else if (flag === '--out') args.out = argv[++i]
|
||||
else if (flag === '--landmark-radius') args.radius = Number(argv[++i])
|
||||
else if (flag === '--help' || flag === '-h') args.help = true
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
const USAGE = `
|
||||
Build the spawn atlas artifact from a ServUO tree.
|
||||
|
||||
node scripts/buildSpawnAtlas.js --servuo <path> [--out <dir>] [--landmark-radius <tiles>]
|
||||
|
||||
--servuo Path to the ServUO server root (the directory holding
|
||||
Spawns/, Data/ and Config/). Required.
|
||||
--out Output directory. Default: server/db/data
|
||||
--landmark-radius Max tile distance for the landmark fallback. Default: 200
|
||||
|
||||
Writes spawnAtlas.index.json plus one spawnAtlas.<facet>.json per facet.
|
||||
Commit the result; load it with "npm run atlas:import".
|
||||
`
|
||||
|
||||
// ── Source files ───────────────────────────────────────────────────────────
|
||||
|
||||
function readIfPresent(file) {
|
||||
try {
|
||||
return fs.readFileSync(file, 'utf8')
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT') return null
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
function sha256(text) {
|
||||
return crypto.createHash('sha256').update(text, 'utf8').digest('hex')
|
||||
}
|
||||
|
||||
function listXml(dir) {
|
||||
try {
|
||||
return fs
|
||||
.readdirSync(dir)
|
||||
.filter((name) => name.toLowerCase().endsWith('.xml'))
|
||||
.sort()
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT') return []
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// ── Transform ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Roll spawn points up into per-type creature rows.
|
||||
*
|
||||
* `total` is the sum of each type's own MX across every point that spawns it —
|
||||
* i.e. how many of this creature the world holds at once, which is the number
|
||||
* worth showing. `facets` is a per-facet point count, used both for the facet
|
||||
* filter and to answer "where does this live" without loading its points.
|
||||
*/
|
||||
function aggregateCreatures(points) {
|
||||
const creatures = new Map()
|
||||
for (const point of points) {
|
||||
for (const entry of point.types) {
|
||||
const slug = slugify(entry.type)
|
||||
if (slug === '') continue
|
||||
let creature = creatures.get(slug)
|
||||
if (!creature) {
|
||||
creature = { slug, name: '', total: 0, points: 0, facets: {}, spellings: new Map() }
|
||||
creatures.set(slug, creature)
|
||||
}
|
||||
creature.total += entry.max
|
||||
creature.points += 1
|
||||
creature.facets[point.facet] = (creature.facets[point.facet] || 0) + 1
|
||||
creature.spellings.set(entry.type, (creature.spellings.get(entry.type) || 0) + 1)
|
||||
}
|
||||
}
|
||||
|
||||
return [...creatures.values()]
|
||||
.map(({ spellings, ...creature }) => ({
|
||||
...creature,
|
||||
name: displayName(spellings),
|
||||
}))
|
||||
.sort((a, b) => a.slug.localeCompare(b.slug))
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose one display spelling for a creature.
|
||||
*
|
||||
* The shard's spawn files are not consistent about case — the same creature is
|
||||
* written `Lizardman` in one file and `lizardman` in another. Slugging collapses
|
||||
* them into one creature correctly, but the display name then depended on
|
||||
* whichever file happened to be read first, which is exactly the kind of thing
|
||||
* that produces a spurious diff in a committed artifact on an unrelated rebuild.
|
||||
*
|
||||
* So: most frequent spelling wins; ties break toward the one with more
|
||||
* capitals (`Lizardman` over `lizardman`), then alphabetically. Fully
|
||||
* deterministic, and independent of file read order.
|
||||
*/
|
||||
function displayName(spellings) {
|
||||
const capitals = (value) => (value.match(/[A-Z]/g) || []).length
|
||||
return [...spellings.entries()].sort((a, b) => {
|
||||
if (b[1] !== a[1]) return b[1] - a[1]
|
||||
const caps = capitals(b[0]) - capitals(a[0])
|
||||
if (caps !== 0) return caps
|
||||
return a[0].localeCompare(b[0])
|
||||
})[0][0]
|
||||
}
|
||||
|
||||
function build(args) {
|
||||
const root = args.servuo
|
||||
if (!fs.existsSync(root)) {
|
||||
throw new Error(`ServUO path does not exist: ${root}`)
|
||||
}
|
||||
|
||||
const source = {}
|
||||
const record = (label, file, text) => {
|
||||
source[label] = { bytes: Buffer.byteLength(text, 'utf8'), sha256: sha256(text) }
|
||||
return text
|
||||
}
|
||||
|
||||
// Regions + landmarks first — the placement index needs both before any
|
||||
// point can be resolved.
|
||||
const regionsPath = path.join(root, 'Data', 'Regions.xml')
|
||||
const regionsXml = readIfPresent(regionsPath)
|
||||
if (regionsXml === null) throw new Error(`Missing required file: ${regionsPath}`)
|
||||
const regions = parseRegions(record('Data/Regions.xml', regionsPath, regionsXml))
|
||||
|
||||
const locationsDir = path.join(root, 'Data', 'Locations')
|
||||
const landmarks = []
|
||||
for (const name of listXml(locationsDir)) {
|
||||
const file = path.join(locationsDir, name)
|
||||
const xml = record(`Data/Locations/${name}`, file, fs.readFileSync(file, 'utf8'))
|
||||
landmarks.push(...parseLocations(xml, path.basename(name, '.xml')))
|
||||
}
|
||||
|
||||
const index = buildPlacementIndex(regions, landmarks)
|
||||
const resolveOpts = args.radius ? { landmarkRadius: args.radius } : {}
|
||||
|
||||
// Spawn points. There are 13 files but only 6 facets — the facet comes from
|
||||
// each record's <Map>, so sharding is driven by the data, not the file names.
|
||||
const spawnsDir = path.join(root, 'Spawns')
|
||||
const spawnFiles = listXml(spawnsDir)
|
||||
if (spawnFiles.length === 0) throw new Error(`No spawn files found in ${spawnsDir}`)
|
||||
|
||||
const rawPoints = []
|
||||
for (const name of spawnFiles) {
|
||||
const file = path.join(spawnsDir, name)
|
||||
const xml = record(`Spawns/${name}`, file, fs.readFileSync(file, 'utf8'))
|
||||
rawPoints.push(...parsePoints(xml))
|
||||
}
|
||||
|
||||
// A spawner switched off in-world produces nothing; advertising it would be a
|
||||
// straight lie to a player planning a hunt.
|
||||
const disabled = rawPoints.filter((point) => !point.running).length
|
||||
const points = rawPoints
|
||||
.filter((point) => point.running)
|
||||
// A spawner with no types is a placeholder — it has nothing to show.
|
||||
.filter((point) => point.types.length > 0)
|
||||
.map((point) => {
|
||||
const placement = resolveRegion(point.x, point.y, point.facet, index, resolveOpts)
|
||||
// The full, honest shape. Compaction happens once, in encodePoint(), at
|
||||
// write time — so everything in between (aggregateCreatures, the facet
|
||||
// grouping, the counts) works on real fields rather than on holes.
|
||||
return {
|
||||
name: point.name,
|
||||
facet: point.facet,
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
width: point.width,
|
||||
height: point.height,
|
||||
range: point.range,
|
||||
maxCount: point.maxCount,
|
||||
minDelay: point.minDelay,
|
||||
maxDelay: point.maxDelay,
|
||||
todStart: point.todStart,
|
||||
todEnd: point.todEnd,
|
||||
todMode: point.todMode,
|
||||
region: placement.region,
|
||||
landmark: placement.landmark,
|
||||
types: point.types,
|
||||
}
|
||||
})
|
||||
|
||||
// Champions are configured altars, not the live champ.update feed. Resolving
|
||||
// them through the same index means "Deceit" reads consistently on both.
|
||||
const championsPath = path.join(root, 'Config', 'ChampionSpawns.xml')
|
||||
const championsXml = readIfPresent(championsPath)
|
||||
const champions = (
|
||||
championsXml === null
|
||||
? []
|
||||
: parseChampions(record('Config/ChampionSpawns.xml', championsPath, championsXml))
|
||||
).map((champ) => ({
|
||||
...champ,
|
||||
slug: slugify(`${champ.facet}-${champ.name}`),
|
||||
label: resolveRegion(champ.x, champ.y, champ.facet, index, resolveOpts).label,
|
||||
}))
|
||||
|
||||
const creatures = aggregateCreatures(points)
|
||||
const facets = [...new Set(points.map((point) => point.facet))].sort()
|
||||
|
||||
const unresolved = points.filter((point) => !point.region && !point.landmark).length
|
||||
const meta = {
|
||||
version: ARTIFACT_VERSION,
|
||||
generatedAt: new Date().toISOString(),
|
||||
landmarkRadius: args.radius ?? undefined,
|
||||
counts: {
|
||||
facets: facets.length,
|
||||
points: points.length,
|
||||
pointsDisabled: disabled,
|
||||
creatures: creatures.length,
|
||||
regions: regions.length,
|
||||
landmarks: landmarks.length,
|
||||
champions: champions.length,
|
||||
unresolvedPoints: unresolved,
|
||||
},
|
||||
source,
|
||||
}
|
||||
|
||||
return { meta, facets, creatures, regions, landmarks, champions, points }
|
||||
}
|
||||
|
||||
// ── Write ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Encode one point for the artifact. The whole compaction scheme lives here, so
|
||||
* this function and `readPoint()` in importSpawnAtlas.js are exact inverses;
|
||||
* test/spawnAtlas.build.test.js round-trips the pair. Change one, change both.
|
||||
*
|
||||
* Three space savings, worth ~3 MB across the world:
|
||||
*
|
||||
* - `facet` is dropped. The shard file names its facet once at the top rather
|
||||
* than repeating it on all ~2,500 of its records.
|
||||
* - Fields at their default are omitted rather than written as 0. Most
|
||||
* spawners are a single point with no time-of-day gating, so `width`,
|
||||
* `height`, `range` and the three `tod*` fields are zero on the large
|
||||
* majority of records.
|
||||
* - `types` becomes [name, max] tuples. There are ~24,000 type entries across
|
||||
* the world, and `{"type":"Orc","max":1}` spends 15 bytes apiece restating
|
||||
* two key names that never vary.
|
||||
*
|
||||
* `label` is not written at all: it is exactly `region || landmark ||
|
||||
* "Wilderness"`, and the importer recomputes it.
|
||||
*/
|
||||
function encodePoint(point) {
|
||||
const out = {
|
||||
name: point.name,
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
maxCount: point.maxCount,
|
||||
types: point.types.map((entry) => [entry.type, entry.max]),
|
||||
}
|
||||
if (point.width) out.width = point.width
|
||||
if (point.height) out.height = point.height
|
||||
if (point.range) out.range = point.range
|
||||
if (point.minDelay) out.minDelay = point.minDelay
|
||||
if (point.maxDelay) out.maxDelay = point.maxDelay
|
||||
// The tod_* trio is meaningless unless gating is on, so it travels together.
|
||||
if (point.todMode) {
|
||||
out.todMode = point.todMode
|
||||
out.todStart = point.todStart
|
||||
out.todEnd = point.todEnd
|
||||
}
|
||||
if (point.region) out.region = point.region
|
||||
else if (point.landmark) out.landmark = point.landmark
|
||||
return out
|
||||
}
|
||||
|
||||
// Compact, not pretty-printed. This is a generated artifact that is read by
|
||||
// `atlas:import` and never by a human — indenting it added ~2 MB of leading
|
||||
// whitespace to the committed repo for no benefit. Reviewers read the counts
|
||||
// printed by this script and the meta block, not 6,455 JSON records.
|
||||
function writeJson(file, value) {
|
||||
fs.writeFileSync(file, `${JSON.stringify(value)}\n`, 'utf8')
|
||||
return fs.statSync(file).size
|
||||
}
|
||||
|
||||
// `meta` alone stays indented. It is ~2.6 KB and it is the part a reviewer and
|
||||
// the drift check actually read, so a changed source hash shows up as a
|
||||
// one-line diff instead of being buried in a single-line 200 KB blob.
|
||||
function writeJsonPretty(file, value) {
|
||||
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
|
||||
return fs.statSync(file).size
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
if (args.help || !args.servuo) {
|
||||
process.stdout.write(USAGE)
|
||||
process.exit(args.help ? 0 : 1)
|
||||
}
|
||||
|
||||
const atlas = build(args)
|
||||
fs.mkdirSync(args.out, { recursive: true })
|
||||
|
||||
// Points shard per facet; everything else is small enough to share one index.
|
||||
let totalBytes = 0
|
||||
for (const facet of atlas.facets) {
|
||||
const file = path.join(args.out, `spawnAtlas.${normalizeFacet(facet)}.json`)
|
||||
const facetPoints = atlas.points
|
||||
.filter((point) => point.facet === facet)
|
||||
.map(encodePoint)
|
||||
totalBytes += writeJson(file, { facet, points: facetPoints })
|
||||
process.stdout.write(` ${path.basename(file)} ${facetPoints.length} points\n`)
|
||||
}
|
||||
|
||||
const metaFile = path.join(args.out, 'spawnAtlas.meta.json')
|
||||
totalBytes += writeJsonPretty(metaFile, atlas.meta)
|
||||
|
||||
const indexFile = path.join(args.out, 'spawnAtlas.index.json')
|
||||
totalBytes += writeJson(indexFile, {
|
||||
facets: atlas.facets,
|
||||
creatures: atlas.creatures,
|
||||
regions: atlas.regions,
|
||||
landmarks: atlas.landmarks,
|
||||
champions: atlas.champions,
|
||||
})
|
||||
|
||||
const c = atlas.meta.counts
|
||||
process.stdout.write(
|
||||
` ${path.basename(indexFile)} ${c.creatures} creatures, ${c.regions} regions, ` +
|
||||
`${c.landmarks} landmarks, ${c.champions} champions\n` +
|
||||
`\nAtlas built: ${c.points} points across ${c.facets} facets ` +
|
||||
`(${c.pointsDisabled} disabled spawners skipped, ` +
|
||||
`${c.unresolvedPoints} unplaced), ${(totalBytes / 1024 / 1024).toFixed(2)} MB total.\n` +
|
||||
'Commit db/data/spawnAtlas.*.json, then run: npm run atlas:import\n',
|
||||
)
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
try {
|
||||
main()
|
||||
} catch (err) {
|
||||
process.stderr.write(`atlas:build failed: ${err.message}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { build, aggregateCreatures, displayName, encodePoint, ARTIFACT_VERSION }
|
||||
@@ -1,309 +1,118 @@
|
||||
#!/usr/bin/env node
|
||||
//
|
||||
// Load the committed spawn atlas artifact into the database.
|
||||
// Refresh the spawn atlas from a ServUO tree, from the command line.
|
||||
//
|
||||
// npm run atlas:import [-- --dir db/data]
|
||||
// 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
|
||||
//
|
||||
// Deliberately separate from `atlas:build`: building needs a ServUO tree, which
|
||||
// the website container does not have, while importing needs only the committed
|
||||
// JSON and a database. That split is what lets the atlas ship in the image and
|
||||
// be loaded on any deployment.
|
||||
// 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.
|
||||
//
|
||||
// The atlas tables are import-owned. This TRUNCATEs and reloads all of them
|
||||
// inside ONE transaction, so a failed import leaves the previous atlas intact
|
||||
// rather than a half-loaded world — there is no partial state worth keeping,
|
||||
// since the artifact is the whole truth.
|
||||
// All the logic lives in `src/model/shardAtlas/shardAtlas.model.js`; this file
|
||||
// is argument parsing and output formatting.
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
// Slugging must match the build's exactly, so it comes from the same module.
|
||||
const { slugify: slugOf } = require('../src/utils/spawnAtlasParse')
|
||||
|
||||
// `src/utils/db` is required lazily, inside the functions that actually talk to
|
||||
// the database, rather than at module load. Requiring it opens a connection
|
||||
// pool as a side effect, and readPoint() — which the tests round-trip against
|
||||
// buildSpawnAtlas.encodePoint() — is pure. Loading it eagerly made the unit
|
||||
// tests hold a pool open against a dead port for ~11 seconds.
|
||||
const db = () => require('../src/utils/db')
|
||||
|
||||
const DEFAULT_DIR = path.join(__dirname, '..', 'db', 'data')
|
||||
const BATCH = 500
|
||||
|
||||
// ── Artifact reading ───────────────────────────────────────────────────────
|
||||
|
||||
function readJson(file) {
|
||||
return JSON.parse(fs.readFileSync(file, 'utf8'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode one point record back to the full shape.
|
||||
*
|
||||
* The exact mirror of the two space-saving encodings buildSpawnAtlas.js applies
|
||||
* on write — omitted-when-default fields, and `types` as [name, max] tuples.
|
||||
* If one side changes, this must change with it.
|
||||
*/
|
||||
function readPoint(raw, facet) {
|
||||
const region = raw.region ?? null
|
||||
const landmark = raw.landmark ?? null
|
||||
return {
|
||||
facet,
|
||||
name: raw.name ?? null,
|
||||
x: raw.x ?? 0,
|
||||
y: raw.y ?? 0,
|
||||
width: raw.width ?? 0,
|
||||
height: raw.height ?? 0,
|
||||
range: raw.range ?? 0,
|
||||
maxCount: raw.maxCount ?? 0,
|
||||
minDelay: raw.minDelay ?? 0,
|
||||
maxDelay: raw.maxDelay ?? 0,
|
||||
todStart: raw.todStart ?? 0,
|
||||
todEnd: raw.todEnd ?? 0,
|
||||
todMode: raw.todMode ?? 0,
|
||||
region,
|
||||
landmark,
|
||||
// Recomputed rather than stored — it is exactly this expression, and the
|
||||
// build omits it for that reason.
|
||||
label: region || landmark || 'Wilderness',
|
||||
types: (raw.types ?? []).map((entry) =>
|
||||
Array.isArray(entry) ? { type: entry[0], max: entry[1] } : entry,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function loadArtifact(dir) {
|
||||
const indexFile = path.join(dir, 'spawnAtlas.index.json')
|
||||
const metaFile = path.join(dir, 'spawnAtlas.meta.json')
|
||||
if (!fs.existsSync(indexFile)) {
|
||||
throw new Error(
|
||||
`No atlas artifact at ${indexFile}. Build one first:\n` +
|
||||
' npm run atlas:build -- --servuo <path to ServUO>',
|
||||
)
|
||||
}
|
||||
|
||||
const index = readJson(indexFile)
|
||||
const meta = fs.existsSync(metaFile) ? readJson(metaFile) : {}
|
||||
|
||||
const points = []
|
||||
for (const facet of index.facets ?? []) {
|
||||
const file = path.join(dir, `spawnAtlas.${facet}.json`)
|
||||
if (!fs.existsSync(file)) {
|
||||
throw new Error(`Artifact is incomplete: ${path.basename(file)} is missing`)
|
||||
}
|
||||
const shard = readJson(file)
|
||||
for (const raw of shard.points ?? []) points.push(readPoint(raw, shard.facet || facet))
|
||||
}
|
||||
|
||||
return { index, meta, points }
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional operator-supplied art map, `{ "<slug>": "<file under uploads/atlas/>" }`.
|
||||
*
|
||||
* Never committed and never shipped — creature sprites come out of the
|
||||
* operator's own client `.mul`/`.uop` files, which are theirs, not ours. Absent
|
||||
* (the normal case) every `art` stays NULL and the UI renders text-only.
|
||||
* See docs/website/SPAWN_ATLAS.md.
|
||||
*/
|
||||
function loadArtMap(dir) {
|
||||
const file = path.join(dir, 'spawnAtlas.art.json')
|
||||
if (!fs.existsSync(file)) return {}
|
||||
const map = readJson(file)
|
||||
return map && typeof map === 'object' ? map : {}
|
||||
}
|
||||
|
||||
// ── Insert helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
async function insertBatched(conn, sql, rows) {
|
||||
for (let i = 0; i < rows.length; i += BATCH) {
|
||||
await conn.batch(sql, rows.slice(i, i + BATCH))
|
||||
}
|
||||
return rows.length
|
||||
}
|
||||
|
||||
// ── Import ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function importAtlas(dir) {
|
||||
const { index, meta, points } = loadArtifact(dir)
|
||||
const art = loadArtMap(dir)
|
||||
const conn = await db().pool.getConnection()
|
||||
const counts = {}
|
||||
|
||||
try {
|
||||
await conn.beginTransaction()
|
||||
|
||||
// TRUNCATE is DDL in MariaDB and would commit the transaction implicitly,
|
||||
// defeating the all-or-nothing guarantee. DELETE is transactional, which is
|
||||
// what this needs; at ~7k rows the difference is not worth the atomicity.
|
||||
for (const table of [
|
||||
'shard_spawn_point_types',
|
||||
'shard_spawn_points',
|
||||
'shard_spawn_creatures',
|
||||
'shard_regions',
|
||||
'shard_landmarks',
|
||||
'shard_champion_spawns',
|
||||
]) {
|
||||
await conn.query(`DELETE FROM ${table}`)
|
||||
}
|
||||
await conn.query('ALTER TABLE shard_spawn_points AUTO_INCREMENT = 1')
|
||||
|
||||
counts.creatures = await insertBatched(
|
||||
conn,
|
||||
'INSERT INTO shard_spawn_creatures (slug, name, total, points, facets, art) VALUES (?,?,?,?,?,?)',
|
||||
(index.creatures ?? []).map((c) => [
|
||||
c.slug,
|
||||
c.name,
|
||||
c.total ?? 0,
|
||||
c.points ?? 0,
|
||||
JSON.stringify(c.facets ?? {}),
|
||||
art[c.slug] ?? null,
|
||||
]),
|
||||
)
|
||||
|
||||
counts.regions = await insertBatched(
|
||||
conn,
|
||||
'INSERT INTO shard_regions (facet, name, type, priority, parent, rects) VALUES (?,?,?,?,?,?)',
|
||||
(index.regions ?? []).map((r) => [
|
||||
r.facet,
|
||||
r.name,
|
||||
r.type || null,
|
||||
r.priority ?? 0,
|
||||
r.parent || null,
|
||||
JSON.stringify(r.rects ?? []),
|
||||
]),
|
||||
)
|
||||
|
||||
counts.landmarks = await insertBatched(
|
||||
conn,
|
||||
'INSERT INTO shard_landmarks (facet, name, grp, x, y, z) VALUES (?,?,?,?,?,?)',
|
||||
(index.landmarks ?? []).map((l) => [
|
||||
l.facet,
|
||||
l.name,
|
||||
l.group || null,
|
||||
l.x ?? 0,
|
||||
l.y ?? 0,
|
||||
l.z ?? 0,
|
||||
]),
|
||||
)
|
||||
|
||||
counts.champions = await insertBatched(
|
||||
conn,
|
||||
'INSERT INTO shard_champion_spawns ' +
|
||||
'(slug, name, grp, type, random_type, facet, x, y, z, radius, label) ' +
|
||||
'VALUES (?,?,?,?,?,?,?,?,?,?,?)',
|
||||
(index.champions ?? []).map((c) => [
|
||||
c.slug,
|
||||
c.name,
|
||||
c.group || null,
|
||||
c.type || null,
|
||||
c.randomType ? 1 : 0,
|
||||
c.facet,
|
||||
c.x ?? 0,
|
||||
c.y ?? 0,
|
||||
c.z ?? 0,
|
||||
c.radius ?? 0,
|
||||
c.label || null,
|
||||
]),
|
||||
)
|
||||
|
||||
// Point ids are assigned here rather than left to AUTO_INCREMENT, because
|
||||
// the join rows need to know them and `conn.batch()` reports no usable
|
||||
// insertId for a multi-row insert. Assigning them explicitly is safe — the
|
||||
// atlas tables are import-owned and this transaction just emptied them — and
|
||||
// it makes the ids a pure function of artifact order instead of something
|
||||
// derived from driver behaviour.
|
||||
const pointRows = points.map((p, i) => [
|
||||
i + 1,
|
||||
p.facet,
|
||||
p.name,
|
||||
p.x,
|
||||
p.y,
|
||||
p.width,
|
||||
p.height,
|
||||
p.range,
|
||||
p.maxCount,
|
||||
p.minDelay,
|
||||
p.maxDelay,
|
||||
p.todStart,
|
||||
p.todEnd,
|
||||
p.todMode,
|
||||
p.region,
|
||||
p.landmark,
|
||||
p.label,
|
||||
])
|
||||
counts.points = await insertBatched(
|
||||
conn,
|
||||
'INSERT INTO shard_spawn_points ' +
|
||||
'(id, facet, name, x, y, width, height, spawn_range, max_count, min_delay, max_delay, ' +
|
||||
'tod_start, tod_end, tod_mode, region, landmark, label) ' +
|
||||
'VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
|
||||
pointRows,
|
||||
)
|
||||
|
||||
const typeRows = []
|
||||
points.forEach((point, i) => {
|
||||
// A spawner may legitimately list the same type twice (six-type points
|
||||
// repeating a creature). The primary key is (point_id, slug), so collapse
|
||||
// duplicates to the larger max rather than letting the insert fail.
|
||||
const bySlug = new Map()
|
||||
for (const entry of point.types) {
|
||||
const slug = slugOf(entry.type)
|
||||
if (slug === '') continue
|
||||
bySlug.set(slug, Math.max(bySlug.get(slug) ?? 0, entry.max ?? 1))
|
||||
}
|
||||
for (const [slug, max] of bySlug) typeRows.push([i + 1, slug, max])
|
||||
})
|
||||
counts.pointTypes = await insertBatched(
|
||||
conn,
|
||||
'INSERT INTO shard_spawn_point_types (point_id, slug, max_count) VALUES (?,?,?)',
|
||||
typeRows,
|
||||
)
|
||||
|
||||
await conn.query(
|
||||
'INSERT INTO shard_atlas_meta (id, payload) VALUES (1, ?) ' +
|
||||
'ON DUPLICATE KEY UPDATE payload = VALUES(payload), imported_at = CURRENT_TIMESTAMP',
|
||||
[JSON.stringify({ ...meta, importedCounts: counts })],
|
||||
)
|
||||
|
||||
await conn.commit()
|
||||
return counts
|
||||
} catch (err) {
|
||||
await conn.rollback().catch(() => {})
|
||||
throw err
|
||||
} finally {
|
||||
conn.release()
|
||||
}
|
||||
}
|
||||
|
||||
// ── CLI ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = { dir: DEFAULT_DIR }
|
||||
const args = {}
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
if (argv[i] === '--dir') args.dir = argv[++i]
|
||||
else if (argv[i] === '--help' || argv[i] === '-h') args.help = true
|
||||
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(
|
||||
'\nLoad the committed spawn atlas artifact into the database.\n\n' +
|
||||
' node scripts/importSpawnAtlas.js [--dir <artifact dir>]\n\n' +
|
||||
` --dir Artifact directory. Default: ${DEFAULT_DIR}\n\n`,
|
||||
)
|
||||
process.stdout.write(USAGE)
|
||||
return
|
||||
}
|
||||
|
||||
const counts = await importAtlas(args.dir)
|
||||
require('../src/utils/logger')('atlas:import').info('spawn atlas imported', counts)
|
||||
process.stdout.write(
|
||||
`Atlas imported: ${counts.points} points, ${counts.creatures} creatures, ` +
|
||||
`${counts.pointTypes} point/type rows, ${counts.regions} regions, ` +
|
||||
`${counts.landmarks} landmarks, ${counts.champions} champion altars.\n`,
|
||||
)
|
||||
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) {
|
||||
@@ -315,4 +124,4 @@ if (require.main === module) {
|
||||
.finally(() => db().close())
|
||||
}
|
||||
|
||||
module.exports = { importAtlas, readPoint, loadArtifact }
|
||||
module.exports = { describe, parseArgs }
|
||||
|
||||
202
server/src/model/shardAtlas/shardAtlas.db.js
Normal file
202
server/src/model/shardAtlas/shardAtlas.db.js
Normal file
@@ -0,0 +1,202 @@
|
||||
const { pool, query } = require('../../utils/db')
|
||||
|
||||
// Raw SQL for the spawn atlas. Every table here is IMPORT-OWNED: `replaceAtlas`
|
||||
// empties and refills all six inside one transaction, and nothing else in the
|
||||
// codebase writes to them. There are no foreign keys, consistent with every
|
||||
// other shard_* table.
|
||||
|
||||
const BATCH = 500
|
||||
|
||||
const ATLAS_TABLES = [
|
||||
'shard_spawn_point_types',
|
||||
'shard_spawn_points',
|
||||
'shard_spawn_creatures',
|
||||
'shard_regions',
|
||||
'shard_landmarks',
|
||||
'shard_champion_spawns',
|
||||
]
|
||||
|
||||
async function insertBatched(conn, sql, rows) {
|
||||
for (let i = 0; i < rows.length; i += BATCH) {
|
||||
await conn.batch(sql, rows.slice(i, i + BATCH))
|
||||
}
|
||||
return rows.length
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the entire atlas in one transaction.
|
||||
*
|
||||
* All-or-nothing on purpose: a failed reload must leave the previous atlas
|
||||
* intact rather than a half-loaded world, since a partially-imported atlas is
|
||||
* indistinguishable from a real one to anyone reading it.
|
||||
*
|
||||
* `DELETE`, not `TRUNCATE` — `TRUNCATE` is DDL in MariaDB and implicitly
|
||||
* commits, which would defeat exactly that guarantee. At ~7k rows the cost of
|
||||
* `DELETE` is irrelevant.
|
||||
*/
|
||||
async function replaceAtlas(atlas, art = {}) {
|
||||
const conn = await pool.getConnection()
|
||||
const counts = {}
|
||||
try {
|
||||
await conn.beginTransaction()
|
||||
|
||||
for (const table of ATLAS_TABLES) await conn.query(`DELETE FROM ${table}`)
|
||||
|
||||
counts.creatures = await insertBatched(
|
||||
conn,
|
||||
'INSERT INTO shard_spawn_creatures (slug, name, total, points, facets, art) VALUES (?,?,?,?,?,?)',
|
||||
atlas.creatures.map((c) => [
|
||||
c.slug,
|
||||
c.name,
|
||||
c.total ?? 0,
|
||||
c.points ?? 0,
|
||||
JSON.stringify(c.facets ?? {}),
|
||||
art[c.slug] ?? null,
|
||||
]),
|
||||
)
|
||||
|
||||
counts.regions = await insertBatched(
|
||||
conn,
|
||||
'INSERT INTO shard_regions (facet, name, type, priority, parent, rects) VALUES (?,?,?,?,?,?)',
|
||||
atlas.regions.map((r) => [
|
||||
r.facet,
|
||||
r.name,
|
||||
r.type || null,
|
||||
r.priority ?? 0,
|
||||
r.parent || null,
|
||||
JSON.stringify(r.rects ?? []),
|
||||
]),
|
||||
)
|
||||
|
||||
counts.landmarks = await insertBatched(
|
||||
conn,
|
||||
'INSERT INTO shard_landmarks (facet, name, grp, x, y, z) VALUES (?,?,?,?,?,?)',
|
||||
atlas.landmarks.map((l) => [
|
||||
l.facet,
|
||||
l.name,
|
||||
l.group || null,
|
||||
l.x ?? 0,
|
||||
l.y ?? 0,
|
||||
l.z ?? 0,
|
||||
]),
|
||||
)
|
||||
|
||||
counts.champions = await insertBatched(
|
||||
conn,
|
||||
'INSERT INTO shard_champion_spawns ' +
|
||||
'(slug, name, grp, type, random_type, facet, x, y, z, radius, label) ' +
|
||||
'VALUES (?,?,?,?,?,?,?,?,?,?,?)',
|
||||
atlas.champions.map((c) => [
|
||||
c.slug,
|
||||
c.name,
|
||||
c.group || null,
|
||||
c.type || null,
|
||||
c.randomType ? 1 : 0,
|
||||
c.facet,
|
||||
c.x ?? 0,
|
||||
c.y ?? 0,
|
||||
c.z ?? 0,
|
||||
c.radius ?? 0,
|
||||
c.label || null,
|
||||
]),
|
||||
)
|
||||
|
||||
// Point ids are assigned explicitly rather than left to AUTO_INCREMENT: the
|
||||
// join rows need to know them and `conn.batch()` reports no usable insertId
|
||||
// for a multi-row insert. Safe because this transaction just emptied the
|
||||
// table and nothing else writes to it.
|
||||
counts.points = await insertBatched(
|
||||
conn,
|
||||
'INSERT INTO shard_spawn_points ' +
|
||||
'(id, facet, name, x, y, width, height, spawn_range, max_count, min_delay, max_delay, ' +
|
||||
'tod_start, tod_end, tod_mode, region, landmark, label) ' +
|
||||
'VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
|
||||
atlas.points.map((p, i) => [
|
||||
i + 1,
|
||||
p.facet,
|
||||
p.name,
|
||||
p.x,
|
||||
p.y,
|
||||
p.width ?? 0,
|
||||
p.height ?? 0,
|
||||
p.range ?? 0,
|
||||
p.maxCount ?? 0,
|
||||
p.minDelay ?? 0,
|
||||
p.maxDelay ?? 0,
|
||||
p.todStart ?? 0,
|
||||
p.todEnd ?? 0,
|
||||
p.todMode ?? 0,
|
||||
p.region,
|
||||
p.landmark,
|
||||
p.label || 'Wilderness',
|
||||
]),
|
||||
)
|
||||
|
||||
counts.pointTypes = await insertBatched(
|
||||
conn,
|
||||
'INSERT INTO shard_spawn_point_types (point_id, slug, max_count) VALUES (?,?,?)',
|
||||
atlas.pointTypes,
|
||||
)
|
||||
|
||||
await conn.query(
|
||||
'INSERT INTO shard_atlas_meta (id, payload) VALUES (1, ?) ' +
|
||||
'ON DUPLICATE KEY UPDATE payload = VALUES(payload), imported_at = CURRENT_TIMESTAMP',
|
||||
[JSON.stringify({ ...atlas.meta, importedCounts: counts })],
|
||||
)
|
||||
|
||||
// A completed import answers whatever was pending.
|
||||
await conn.query('DELETE FROM shard_atlas_pending')
|
||||
|
||||
await conn.commit()
|
||||
return counts
|
||||
} catch (err) {
|
||||
await conn.rollback().catch(() => {})
|
||||
throw err
|
||||
} finally {
|
||||
conn.release()
|
||||
}
|
||||
}
|
||||
|
||||
async function getMeta() {
|
||||
const rows = await query('SELECT payload, imported_at FROM shard_atlas_meta WHERE id = 1')
|
||||
if (rows.length === 0) return null
|
||||
const payload = typeof rows[0].payload === 'string' ? JSON.parse(rows[0].payload) : rows[0].payload
|
||||
return { ...payload, importedAt: rows[0].imported_at }
|
||||
}
|
||||
|
||||
/** Facet names currently loaded, used to detect a facet disappearing. */
|
||||
async function getFacets() {
|
||||
const rows = await query('SELECT DISTINCT facet FROM shard_spawn_points ORDER BY facet')
|
||||
return rows.map((row) => row.facet)
|
||||
}
|
||||
|
||||
// ── Pending review ─────────────────────────────────────────────────────────
|
||||
|
||||
async function getPending() {
|
||||
const rows = await query('SELECT payload, status, detected_at FROM shard_atlas_pending WHERE id = 1')
|
||||
if (rows.length === 0) return null
|
||||
const payload = typeof rows[0].payload === 'string' ? JSON.parse(rows[0].payload) : rows[0].payload
|
||||
return { ...payload, status: rows[0].status, detectedAt: rows[0].detected_at }
|
||||
}
|
||||
|
||||
async function setPending(payload, status = 'pending') {
|
||||
return query(
|
||||
'INSERT INTO shard_atlas_pending (id, status, payload) VALUES (1, ?, ?) ' +
|
||||
'ON DUPLICATE KEY UPDATE status = VALUES(status), payload = VALUES(payload), ' +
|
||||
'detected_at = CURRENT_TIMESTAMP',
|
||||
[status, JSON.stringify(payload)],
|
||||
)
|
||||
}
|
||||
|
||||
async function clearPending() {
|
||||
return query('DELETE FROM shard_atlas_pending')
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
replaceAtlas,
|
||||
getMeta,
|
||||
getFacets,
|
||||
getPending,
|
||||
setPending,
|
||||
clearPending,
|
||||
}
|
||||
296
server/src/model/shardAtlas/shardAtlas.model.js
Normal file
296
server/src/model/shardAtlas/shardAtlas.model.js
Normal file
@@ -0,0 +1,296 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const db = require('./shardAtlas.db')
|
||||
const settings = require('../settings/settings.model')
|
||||
const { slugify } = require('../../utils/spawnAtlasParse')
|
||||
const {
|
||||
AtlasSourceError,
|
||||
buildAtlas,
|
||||
hashSources,
|
||||
sameSources,
|
||||
} = require('../../utils/spawnAtlasSource')
|
||||
const log = require('../../utils/logger')('shardAtlas')
|
||||
|
||||
// The spawn atlas, refreshed from the shard's own ServUO tree.
|
||||
//
|
||||
// The tree is 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 go stale against the world
|
||||
// players actually see. So the atlas is re-derived on every boot.
|
||||
//
|
||||
// Two rules govern the boot path:
|
||||
//
|
||||
// 1. **It never blocks startup.** No configured path, an unreadable path, a
|
||||
// malformed file, a database error — all of it is caught and logged. The
|
||||
// site comes up either way, serving whatever atlas it already had.
|
||||
// 2. **A facet disappearing is not 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 the two are indistinguishable from here. The refresh is
|
||||
// staged for a human instead, and an admin approves or rejects it.
|
||||
//
|
||||
// Everything else — new facets, renamed regions, changed spawns — applies
|
||||
// straight away, because none of it can silently destroy data an operator would
|
||||
// miss.
|
||||
|
||||
const SETTING_KEY = 'spawn_atlas_servuo_path'
|
||||
|
||||
/**
|
||||
* Where the ServUO tree lives.
|
||||
*
|
||||
* The admin setting wins over the environment so an operator can point the
|
||||
* atlas at a different tree without a redeploy, matching how the rest of the
|
||||
* shard integration is admin-managed rather than env-configured. `SERVUO_PATH`
|
||||
* remains as the deploy-time default, since the path usually describes a mount
|
||||
* that the deployment sets up.
|
||||
*/
|
||||
async function getServuoPath() {
|
||||
try {
|
||||
const configured = await settings.get(SETTING_KEY)
|
||||
if (configured && String(configured).trim() !== '') return String(configured).trim()
|
||||
} catch {
|
||||
// Settings unavailable is not fatal — fall through to the env default.
|
||||
}
|
||||
const fromEnv = process.env.SERVUO_PATH
|
||||
return fromEnv && fromEnv.trim() !== '' ? fromEnv.trim() : ''
|
||||
}
|
||||
|
||||
async function setServuoPath(value, updatedBy = null) {
|
||||
return settings.set(SETTING_KEY, String(value ?? '').trim(), updatedBy)
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional operator-supplied art map, `{ "<slug>": "<file under uploads/atlas/>" }`.
|
||||
*
|
||||
* Never committed and never shipped — creature sprites come out of the
|
||||
* operator's own client `.mul`/`.uop` files, which are theirs, not ours to
|
||||
* redistribute. Absent (the normal case) every `art` stays NULL and the UI
|
||||
* renders text-only.
|
||||
*/
|
||||
function loadArtMap(dir = path.join(__dirname, '..', '..', '..', 'db', 'data')) {
|
||||
try {
|
||||
const file = path.join(dir, 'spawnAtlas.art.json')
|
||||
if (!fs.existsSync(file)) return {}
|
||||
const map = JSON.parse(fs.readFileSync(file, 'utf8'))
|
||||
return map && typeof map === 'object' ? map : {}
|
||||
} catch (err) {
|
||||
log.warn('spawn atlas art map could not be read', { error: err.message })
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten each point's types into `shard_spawn_point_types` rows.
|
||||
*
|
||||
* A spawner may legitimately list the same type twice, and the primary key is
|
||||
* (point_id, slug), so duplicates collapse to the larger max rather than
|
||||
* failing the insert.
|
||||
*/
|
||||
function pointTypeRows(points) {
|
||||
const rows = []
|
||||
points.forEach((point, i) => {
|
||||
const bySlug = new Map()
|
||||
for (const entry of point.types ?? []) {
|
||||
const slug = slugify(entry.type)
|
||||
if (slug === '') continue
|
||||
bySlug.set(slug, Math.max(bySlug.get(slug) ?? 0, entry.max ?? 1))
|
||||
}
|
||||
for (const [slug, max] of bySlug) rows.push([i + 1, slug, max])
|
||||
})
|
||||
return rows
|
||||
}
|
||||
|
||||
async function applyAtlas(atlas) {
|
||||
return db.replaceAtlas({ ...atlas, pointTypes: pointTypeRows(atlas.points) }, loadArtMap())
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the atlas from the configured ServUO tree.
|
||||
*
|
||||
* Returns a result describing what happened rather than throwing, so the caller
|
||||
* — including the boot path — can log it and move on:
|
||||
*
|
||||
* `skipped` no path configured
|
||||
* `unavailable` path configured but unreadable / missing required files
|
||||
* `unchanged` source hashes match the loaded atlas; nothing parsed
|
||||
* `imported` parsed and applied
|
||||
* `needsReview` parsed, but a facet would be lost; staged for an admin
|
||||
* `failed` parsed or applied and something went wrong
|
||||
*
|
||||
* `force` skips the hash check (an admin asking for a reimport) and `approve`
|
||||
* additionally accepts facet loss (an admin approving a staged refresh).
|
||||
*/
|
||||
async function refresh({ force = false, approve = false, path: pathOverride = '' } = {}) {
|
||||
// An explicit override wins outright — it is a one-off "use this tree", and it
|
||||
// must not be silently overruled by the configured path the way an env default
|
||||
// would be.
|
||||
const root = pathOverride.trim() !== '' ? pathOverride.trim() : await getServuoPath()
|
||||
if (root === '') return { status: 'skipped', reason: 'no ServUO path configured' }
|
||||
|
||||
let hashes
|
||||
try {
|
||||
hashes = hashSources(root)
|
||||
} catch (err) {
|
||||
if (err instanceof AtlasSourceError) {
|
||||
return { status: 'unavailable', reason: err.message, code: err.code, path: root }
|
||||
}
|
||||
return { status: 'failed', reason: err.message, path: root }
|
||||
}
|
||||
|
||||
const meta = await db.getMeta().catch(() => null)
|
||||
const loaded = meta?.source
|
||||
? Object.fromEntries(Object.entries(meta.source).map(([label, v]) => [label, v.sha256]))
|
||||
: null
|
||||
|
||||
if (!force && sameSources(hashes, loaded)) {
|
||||
return { status: 'unchanged', path: root }
|
||||
}
|
||||
|
||||
// A rejected refresh must not re-prompt on every boot. It stays rejected until
|
||||
// the tree changes again, at which point the hashes differ and it is a new
|
||||
// decision.
|
||||
const pending = await db.getPending().catch(() => null)
|
||||
if (!approve && !force && pending?.status === 'rejected' && sameSources(hashes, pending.hashes)) {
|
||||
return { status: 'unchanged', path: root, reason: 'refresh previously rejected' }
|
||||
}
|
||||
|
||||
let atlas
|
||||
try {
|
||||
atlas = buildAtlas(root)
|
||||
} catch (err) {
|
||||
return { status: 'failed', reason: err.message, path: root }
|
||||
}
|
||||
|
||||
const currentFacets = await db.getFacets().catch(() => [])
|
||||
const incomingFacets = atlas.facets
|
||||
const removedFacets = currentFacets.filter((facet) => !incomingFacets.includes(facet))
|
||||
const addedFacets = incomingFacets.filter((facet) => !currentFacets.includes(facet))
|
||||
|
||||
// Losing a facet is indistinguishable here from a half-copied tree, so it is
|
||||
// staged rather than applied — but startup is never blocked by it.
|
||||
if (removedFacets.length > 0 && !approve) {
|
||||
const summary = {
|
||||
hashes,
|
||||
path: root,
|
||||
currentFacets,
|
||||
incomingFacets,
|
||||
removedFacets,
|
||||
addedFacets,
|
||||
counts: atlas.meta.counts,
|
||||
}
|
||||
await db.setPending(summary, 'pending').catch((err) => {
|
||||
log.warn('could not stage spawn atlas refresh', { error: err.message })
|
||||
})
|
||||
return { status: 'needsReview', ...summary }
|
||||
}
|
||||
|
||||
try {
|
||||
const counts = await applyAtlas(atlas)
|
||||
return { status: 'imported', path: root, counts, addedFacets, removedFacets }
|
||||
} catch (err) {
|
||||
return { status: 'failed', reason: err.message, path: root }
|
||||
}
|
||||
}
|
||||
|
||||
/** Admin approved a staged refresh: apply it, facet loss and all. */
|
||||
async function approvePending(options = {}) {
|
||||
return refresh({ ...options, approve: true, force: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin rejected a staged refresh: keep the current atlas and remember the
|
||||
* decision against those exact source hashes, so it does not re-prompt every
|
||||
* boot. A further change to the tree produces different hashes and asks again.
|
||||
*/
|
||||
async function rejectPending() {
|
||||
const pending = await db.getPending()
|
||||
if (!pending) return { status: 'none' }
|
||||
await db.setPending({ ...pending, rejectedAt: new Date().toISOString() }, 'rejected')
|
||||
return { status: 'rejected' }
|
||||
}
|
||||
|
||||
/** Everything the admin panel needs to describe atlas state. */
|
||||
async function status({ path: pathOverride = '' } = {}) {
|
||||
const root = pathOverride.trim() !== '' ? pathOverride.trim() : await getServuoPath()
|
||||
const [meta, pending, facets] = await Promise.all([
|
||||
db.getMeta().catch(() => null),
|
||||
db.getPending().catch(() => null),
|
||||
db.getFacets().catch(() => []),
|
||||
])
|
||||
|
||||
let treeReadable = false
|
||||
let drift = null
|
||||
if (root !== '') {
|
||||
try {
|
||||
const hashes = hashSources(root)
|
||||
treeReadable = true
|
||||
const loaded = meta?.source
|
||||
? Object.fromEntries(Object.entries(meta.source).map(([l, v]) => [l, v.sha256]))
|
||||
: null
|
||||
drift = !sameSources(hashes, loaded)
|
||||
} catch {
|
||||
treeReadable = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
configured: root !== '',
|
||||
path: root,
|
||||
treeReadable,
|
||||
drift,
|
||||
facets,
|
||||
importedAt: meta?.importedAt ?? null,
|
||||
counts: meta?.counts ?? null,
|
||||
pending,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot hook. Best-effort by contract: it logs and returns, never throws, so a
|
||||
* missing tree or a bad file can never stop the site coming up.
|
||||
*/
|
||||
async function refreshOnBoot() {
|
||||
try {
|
||||
const result = await refresh()
|
||||
switch (result.status) {
|
||||
case 'imported':
|
||||
log.info('spawn atlas refreshed from ServUO tree', {
|
||||
...result.counts,
|
||||
added: result.addedFacets,
|
||||
})
|
||||
break
|
||||
case 'needsReview':
|
||||
log.warn(
|
||||
'spawn atlas refresh staged for admin review — a facet would be removed; ' +
|
||||
'the existing atlas is unchanged',
|
||||
{ removed: result.removedFacets, added: result.addedFacets },
|
||||
)
|
||||
break
|
||||
case 'unavailable':
|
||||
log.warn('spawn atlas source unavailable', { reason: result.reason, path: result.path })
|
||||
break
|
||||
case 'failed':
|
||||
log.warn('spawn atlas refresh failed', { reason: result.reason })
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
return result
|
||||
} catch (err) {
|
||||
log.warn('spawn atlas refresh errored', { error: err.message })
|
||||
return { status: 'failed', reason: err.message }
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
refresh,
|
||||
refreshOnBoot,
|
||||
approvePending,
|
||||
rejectPending,
|
||||
status,
|
||||
getServuoPath,
|
||||
setServuoPath,
|
||||
pointTypeRows,
|
||||
loadArtMap,
|
||||
SETTING_KEY,
|
||||
}
|
||||
@@ -14,6 +14,7 @@ const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed')
|
||||
const settings = require('./model/settings/settings.model')
|
||||
const revokedSessions = require('./model/revokedSessions/revokedSessions.model')
|
||||
const mobileAuthBridge = require('./model/mobileAuthBridge/mobileAuthBridge.model')
|
||||
const shardAtlas = require('./model/shardAtlas/shardAtlas.model')
|
||||
const createLogger = require('./utils/logger')
|
||||
const { evaluateBotInternalKey } = require('./utils/botInternalKey')
|
||||
const brand = require('./config/brand')
|
||||
@@ -77,6 +78,17 @@ async function start() {
|
||||
log.warn('mobile-auth-bridge prune failed', { error: err.message })
|
||||
}
|
||||
|
||||
// Re-derive the spawn atlas from the shard's own ServUO tree. The shard's maps
|
||||
// change over its lifetime — facets get added, replaced or renamed — so the
|
||||
// atlas is rebuilt on every boot rather than shipped as a snapshot that would
|
||||
// silently go stale. Hash-gated, so an unchanged tree costs one read pass and
|
||||
// no database write.
|
||||
//
|
||||
// Best-effort by contract: no configured path, an unreadable mount or a
|
||||
// malformed file must never stop the site coming up. A refresh that would
|
||||
// REMOVE a facet is staged for admin approval instead of being applied.
|
||||
await shardAtlas.refreshOnBoot()
|
||||
|
||||
const mode = await settings.get('site_mode')
|
||||
log.info(`site mode: ${String(mode || 'live').toUpperCase()}`)
|
||||
|
||||
|
||||
@@ -182,33 +182,75 @@ function childrenNamed(node, name) {
|
||||
}
|
||||
|
||||
// ── Facet names ────────────────────────────────────────────────────────────
|
||||
|
||||
// The three sources disagree about facet spelling and nothing in the files
|
||||
// reconciles them: `Spawns/*.xml` `<Map>` and `Regions.xml` `<Facet name>` both
|
||||
// say `TerMur`/`Tokuno`, while `Data/Locations/*.xml` spells the same facets
|
||||
// `Ter Mur` and `Tokuno Islands`. Left alone this is silent — the landmark
|
||||
// fallback simply never matches on those two facets and every unregioned spawn
|
||||
// in Ter Mur and Tokuno reads "Wilderness" — so every facet name entering the
|
||||
// atlas is canonicalised through here first.
|
||||
const FACET_CANONICAL = new Map([
|
||||
['felucca', 'Felucca'],
|
||||
['trammel', 'Trammel'],
|
||||
['ilshenar', 'Ilshenar'],
|
||||
['malas', 'Malas'],
|
||||
['tokuno', 'Tokuno'],
|
||||
['tokunoislands', 'Tokuno'],
|
||||
['termur', 'TerMur'],
|
||||
])
|
||||
//
|
||||
// Facets are NOT a fixed list. A shard may add facets, replace them wholesale,
|
||||
// or rename them when its maps are updated, so nothing here may name Felucca,
|
||||
// Trammel or any other stock facet. The facet set is whatever the shard's own
|
||||
// files say it is, discovered at parse time.
|
||||
//
|
||||
// The complication is that the sources disagree about spelling for the SAME
|
||||
// facet and nothing in the files reconciles them: `Spawns/*.xml` `<Map>` and
|
||||
// `Regions.xml` `<Facet name>` say `TerMur`, while `Data/Locations/*.xml` spells
|
||||
// it `Ter Mur` and calls Tokuno `Tokuno Islands`. Left unreconciled this fails
|
||||
// silently — the landmark bucket is keyed differently from the points looking it
|
||||
// up, so the fallback never fires and every unregioned spawn on those facets
|
||||
// reads "Wilderness".
|
||||
//
|
||||
// Reconciliation is therefore done by MATCHING, not by a lookup table:
|
||||
// `facetKey()` collapses spelling differences, and `resolveFacetName()` matches
|
||||
// a loosely-spelled name against the canonical set discovered from the shard's
|
||||
// own data. A facet nobody else mentions keeps its own name rather than being
|
||||
// dropped.
|
||||
|
||||
/**
|
||||
* Canonicalise a facet name to the `<Map>` spelling the atlas keys on.
|
||||
* Unknown facets pass through trimmed rather than being dropped, so a custom
|
||||
* shard facet still gets an atlas rather than vanishing.
|
||||
* Collapse a facet name to a comparison key: lowercase, alphanumerics only.
|
||||
* `TerMur`, `Ter Mur` and `ter-mur` all key alike.
|
||||
*/
|
||||
function normalizeFacet(value) {
|
||||
function facetKey(value) {
|
||||
return String(value ?? '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a key → canonical-spelling lookup from the authoritative facet names.
|
||||
*
|
||||
* The authority is what the spawn records and region definitions actually say,
|
||||
* since those are the names the atlas keys everything on. Later names do not
|
||||
* overwrite earlier ones, so the first source wins consistently.
|
||||
*/
|
||||
function buildFacetIndex(names) {
|
||||
const index = new Map()
|
||||
for (const name of names) {
|
||||
const key = facetKey(name)
|
||||
if (key !== '' && !index.has(key)) index.set(key, String(name).trim())
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a loosely-spelled facet name against the discovered canonical set.
|
||||
*
|
||||
* Tried in order: exact key match (`Ter Mur` → `TerMur`), then a prefix match in
|
||||
* either direction (`Tokuno Islands` → `Tokuno`), longest candidate first so a
|
||||
* more specific facet wins over a shorter one that merely prefixes it.
|
||||
*
|
||||
* A name matching nothing is returned trimmed rather than dropped — on a shard
|
||||
* with a custom facet that is a real facet the atlas simply has no spawns for
|
||||
* yet, and inventing a match would be worse than leaving it alone.
|
||||
*/
|
||||
function resolveFacetName(value, index) {
|
||||
const raw = String(value ?? '').trim()
|
||||
if (raw === '') return ''
|
||||
return FACET_CANONICAL.get(raw.toLowerCase().replace(/[\s_-]+/g, '')) ?? raw
|
||||
const key = facetKey(raw)
|
||||
if (key === '') return ''
|
||||
if (index.has(key)) return index.get(key)
|
||||
|
||||
let best = null
|
||||
for (const [candidateKey, canonical] of index) {
|
||||
if (!key.startsWith(candidateKey) && !candidateKey.startsWith(key)) continue
|
||||
if (best === null || candidateKey.length > facetKey(best).length) best = canonical
|
||||
}
|
||||
return best ?? raw
|
||||
}
|
||||
|
||||
// ── Small coercions ────────────────────────────────────────────────────────
|
||||
@@ -326,7 +368,9 @@ function parsePoints(source) {
|
||||
|
||||
while ((match = POINT_RE.exec(text)) !== null) {
|
||||
const block = match[1]
|
||||
const facet = normalizeFacet(tagValue(block, 'Map'))
|
||||
// Reported exactly as written. `<Map>` is the authority the rest of the
|
||||
// atlas keys on, so it is never rewritten.
|
||||
const facet = tagValue(block, 'Map')
|
||||
if (facet === '') continue
|
||||
|
||||
points.push({
|
||||
@@ -374,7 +418,7 @@ function parseRegions(source) {
|
||||
if (!root) return regions
|
||||
|
||||
for (const facetNode of childrenNamed(root, 'Facet')) {
|
||||
const facet = normalizeFacet(facetNode.attrs.name)
|
||||
const facet = (facetNode.attrs.name || '').trim()
|
||||
if (facet === '') continue
|
||||
walkRegions(facetNode, facet, null, 0, regions)
|
||||
}
|
||||
@@ -430,12 +474,25 @@ function parseLocations(source, facetHint = '') {
|
||||
if (!root) return landmarks
|
||||
|
||||
for (const top of childrenNamed(root, 'parent')) {
|
||||
const facet = normalizeFacet(top.attrs.name || facetHint)
|
||||
walkLocations(top, facet, [], landmarks)
|
||||
// The file name (`Data/Locations/termur.xml`) is the more reliable signal
|
||||
// and is preferred over the display label inside the file, which is where
|
||||
// the `Ter Mur` / `Tokuno Islands` drift lives. Both are carried so the
|
||||
// build can fall back to matching the label if the file name resolves to
|
||||
// nothing — a shard may well name its files differently from its facets.
|
||||
landmarks.push(
|
||||
...collectLocations(top, facetHint || top.attrs.name || '', top.attrs.name || ''),
|
||||
)
|
||||
}
|
||||
return landmarks
|
||||
}
|
||||
|
||||
function collectLocations(top, facet, label) {
|
||||
const out = []
|
||||
walkLocations(top, facet, [], out)
|
||||
for (const landmark of out) landmark.facetLabel = label
|
||||
return out
|
||||
}
|
||||
|
||||
function walkLocations(node, facet, path, out) {
|
||||
for (const child of childrenNamed(node, 'child')) {
|
||||
const name = child.attrs.name || ''
|
||||
@@ -482,7 +539,7 @@ function parseChampions(source) {
|
||||
group: spawnNode.attrs.group || '',
|
||||
type: spawnNode.attrs.type || '',
|
||||
randomType: !spawnNode.attrs.type,
|
||||
facet: normalizeFacet(attrs.map),
|
||||
facet: (attrs.map || '').trim(),
|
||||
x: toInt(attrs.x),
|
||||
y: toInt(attrs.y),
|
||||
z: toInt(attrs.z),
|
||||
@@ -514,9 +571,13 @@ function rectArea(rect) {
|
||||
*/
|
||||
function buildPlacementIndex(regions, landmarks) {
|
||||
const byFacet = new Map()
|
||||
// Keyed on facetKey(), not the raw name, so two spellings of one facet cannot
|
||||
// land in separate buckets — the failure that silently emptied the landmark
|
||||
// bucket for Ter Mur and Tokuno.
|
||||
const facet = (name) => {
|
||||
if (!byFacet.has(name)) byFacet.set(name, { regions: [], landmarks: [] })
|
||||
return byFacet.get(name)
|
||||
const key = facetKey(name)
|
||||
if (!byFacet.has(key)) byFacet.set(key, { regions: [], landmarks: [] })
|
||||
return byFacet.get(key)
|
||||
}
|
||||
for (const region of regions) facet(region.facet).regions.push(region)
|
||||
for (const landmark of landmarks) facet(landmark.facet).landmarks.push(landmark)
|
||||
@@ -542,7 +603,7 @@ function buildPlacementIndex(regions, landmarks) {
|
||||
*/
|
||||
function resolveRegion(x, y, facetName, index, options = {}) {
|
||||
const radius = options.landmarkRadius ?? DEFAULT_LANDMARK_RADIUS
|
||||
const bucket = index.get(facetName)
|
||||
const bucket = index.get(facetKey(facetName))
|
||||
const result = { region: null, landmark: null, label: 'Wilderness' }
|
||||
if (!bucket) return result
|
||||
|
||||
@@ -594,7 +655,9 @@ module.exports = {
|
||||
parseChampions,
|
||||
buildPlacementIndex,
|
||||
resolveRegion,
|
||||
normalizeFacet,
|
||||
facetKey,
|
||||
buildFacetIndex,
|
||||
resolveFacetName,
|
||||
slugify,
|
||||
decodeEntities,
|
||||
DEFAULT_LANDMARK_RADIUS,
|
||||
|
||||
319
server/src/utils/spawnAtlasSource.js
Normal file
319
server/src/utils/spawnAtlasSource.js
Normal file
@@ -0,0 +1,319 @@
|
||||
// Spawn atlas — the filesystem layer over a ServUO tree.
|
||||
//
|
||||
// `spawnAtlasParse.js` holds the pure parsers; this module is the only thing
|
||||
// that touches a ServUO tree on disk, and it is shared by both callers:
|
||||
//
|
||||
// - the server, which refreshes the atlas on boot (`shardAtlas.model.js`)
|
||||
// - the CLI (`scripts/importSpawnAtlas.js`)
|
||||
//
|
||||
// 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
|
||||
// stale against the world players actually see.
|
||||
//
|
||||
// Reading and hashing the whole tree costs ~120 ms and a full parse ~400 ms, so
|
||||
// the boot path hashes first and only parses when something actually changed.
|
||||
|
||||
const crypto = require('crypto')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const {
|
||||
parsePoints,
|
||||
parseRegions,
|
||||
parseLocations,
|
||||
parseChampions,
|
||||
buildPlacementIndex,
|
||||
buildFacetIndex,
|
||||
resolveFacetName,
|
||||
resolveRegion,
|
||||
facetKey,
|
||||
slugify,
|
||||
} = require('./spawnAtlasParse')
|
||||
|
||||
const REGIONS_FILE = path.join('Data', 'Regions.xml')
|
||||
const LOCATIONS_DIR = path.join('Data', 'Locations')
|
||||
const SPAWNS_DIR = 'Spawns'
|
||||
const CHAMPIONS_FILE = path.join('Config', 'ChampionSpawns.xml')
|
||||
|
||||
class AtlasSourceError extends Error {
|
||||
constructor(message, code) {
|
||||
super(message)
|
||||
this.name = 'AtlasSourceError'
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reading ────────────────────────────────────────────────────────────────
|
||||
|
||||
function sha256(text) {
|
||||
return crypto.createHash('sha256').update(text, 'utf8').digest('hex')
|
||||
}
|
||||
|
||||
function listXml(dir) {
|
||||
try {
|
||||
return fs
|
||||
.readdirSync(dir)
|
||||
.filter((name) => name.toLowerCase().endsWith('.xml'))
|
||||
.sort()
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return []
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
function readIfPresent(file) {
|
||||
try {
|
||||
return fs.readFileSync(file, 'utf8')
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return null
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read every atlas source file under `root`.
|
||||
*
|
||||
* Returns `{ files: [{ label, text, sha256, bytes }] }`, labels being
|
||||
* tree-relative and forward-slashed so a hash map compares equal across
|
||||
* platforms — the same tree read on Windows and Linux must produce the same
|
||||
* fingerprint or every boot would look like a change.
|
||||
*/
|
||||
function readSources(root) {
|
||||
if (!root || String(root).trim() === '') {
|
||||
throw new AtlasSourceError('No ServUO path configured', 'NO_PATH')
|
||||
}
|
||||
if (!fs.existsSync(root)) {
|
||||
throw new AtlasSourceError(`ServUO path does not exist: ${root}`, 'NOT_FOUND')
|
||||
}
|
||||
|
||||
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') })
|
||||
return true
|
||||
}
|
||||
|
||||
if (!push('Data/Regions.xml', path.join(root, REGIONS_FILE))) {
|
||||
throw new AtlasSourceError(`Missing required file: ${REGIONS_FILE}`, 'NO_REGIONS')
|
||||
}
|
||||
|
||||
for (const name of listXml(path.join(root, LOCATIONS_DIR))) {
|
||||
push(`Data/Locations/${name}`, path.join(root, LOCATIONS_DIR, name))
|
||||
}
|
||||
|
||||
const spawnFiles = listXml(path.join(root, SPAWNS_DIR))
|
||||
if (spawnFiles.length === 0) {
|
||||
throw new AtlasSourceError(`No spawn files found in ${SPAWNS_DIR}`, 'NO_SPAWNS')
|
||||
}
|
||||
for (const name of spawnFiles) push(`Spawns/${name}`, path.join(root, SPAWNS_DIR, name))
|
||||
|
||||
push('Config/ChampionSpawns.xml', path.join(root, CHAMPIONS_FILE))
|
||||
|
||||
return { files }
|
||||
}
|
||||
|
||||
/**
|
||||
* A fingerprint of the tree: `{ "<label>": "<sha256>" }`.
|
||||
*
|
||||
* The boot path compares this against what was last imported and skips the
|
||||
* parse entirely when it matches, which is the normal case on every restart
|
||||
* that did not follow a map update.
|
||||
*/
|
||||
function hashSources(root) {
|
||||
const { files } = readSources(root)
|
||||
const hashes = {}
|
||||
for (const file of files) hashes[file.label] = file.sha256
|
||||
return hashes
|
||||
}
|
||||
|
||||
/** True when two source fingerprints describe the same tree. */
|
||||
function sameSources(a, b) {
|
||||
if (!a || !b) return false
|
||||
const aKeys = Object.keys(a).sort()
|
||||
const bKeys = Object.keys(b).sort()
|
||||
if (aKeys.length !== bKeys.length) return false
|
||||
return aKeys.every((key, i) => key === bKeys[i] && a[key] === b[key])
|
||||
}
|
||||
|
||||
// ── Aggregation ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Choose one display spelling for a creature.
|
||||
*
|
||||
* Spawn files are not consistent about case — the same creature is `Lizardman`
|
||||
* in one file and `lizardman` in another. Slugging collapses them correctly, but
|
||||
* the display name would otherwise depend on file read order. Most frequent
|
||||
* spelling wins; ties break toward more capitals, then alphabetically.
|
||||
*/
|
||||
function displayName(spellings) {
|
||||
const capitals = (value) => (value.match(/[A-Z]/g) || []).length
|
||||
return [...spellings.entries()].sort((a, b) => {
|
||||
if (b[1] !== a[1]) return b[1] - a[1]
|
||||
const caps = capitals(b[0]) - capitals(a[0])
|
||||
if (caps !== 0) return caps
|
||||
return a[0].localeCompare(b[0])
|
||||
})[0][0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll spawn points up into per-type creature rows.
|
||||
*
|
||||
* `total` is the sum of each type's own max across every point that spawns it —
|
||||
* how many of this creature the world holds at once. `facets` is a per-facet
|
||||
* point count, so "where does this live" answers without touching the points.
|
||||
*/
|
||||
function aggregateCreatures(points) {
|
||||
const creatures = new Map()
|
||||
for (const point of points) {
|
||||
for (const entry of point.types) {
|
||||
const slug = slugify(entry.type)
|
||||
if (slug === '') continue
|
||||
let creature = creatures.get(slug)
|
||||
if (!creature) {
|
||||
creature = { slug, name: '', total: 0, points: 0, facets: {}, spellings: new Map() }
|
||||
creatures.set(slug, creature)
|
||||
}
|
||||
creature.total += entry.max
|
||||
creature.points += 1
|
||||
creature.facets[point.facet] = (creature.facets[point.facet] || 0) + 1
|
||||
creature.spellings.set(entry.type, (creature.spellings.get(entry.type) || 0) + 1)
|
||||
}
|
||||
}
|
||||
|
||||
return [...creatures.values()]
|
||||
.map(({ spellings, ...creature }) => ({ ...creature, name: displayName(spellings) }))
|
||||
.sort((a, b) => a.slug.localeCompare(b.slug))
|
||||
}
|
||||
|
||||
// ── Build ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse a ServUO tree into the full atlas.
|
||||
*
|
||||
* Pure with respect to the database — it reads files and returns data; nothing
|
||||
* here writes. `shardAtlas.model.js` decides what to do with the result.
|
||||
*/
|
||||
function buildAtlas(root, options = {}) {
|
||||
const { files } = readSources(root)
|
||||
const byLabel = new Map(files.map((file) => [file.label, file]))
|
||||
const source = {}
|
||||
for (const file of files) source[file.label] = { bytes: file.bytes, sha256: file.sha256 }
|
||||
|
||||
const regions = parseRegions(byLabel.get('Data/Regions.xml').text)
|
||||
|
||||
const rawLandmarks = []
|
||||
for (const file of files) {
|
||||
if (!file.label.startsWith('Data/Locations/')) continue
|
||||
const basename = path.basename(file.label, '.xml')
|
||||
rawLandmarks.push(...parseLocations(file.text, basename))
|
||||
}
|
||||
|
||||
const rawPoints = []
|
||||
for (const file of files) {
|
||||
if (!file.label.startsWith('Spawns/')) continue
|
||||
rawPoints.push(...parsePoints(file.text))
|
||||
}
|
||||
|
||||
// The facet set is whatever THIS tree declares — never a built-in list. A
|
||||
// shard may add facets, replace them outright, or rename them when its maps
|
||||
// are updated, and the atlas has to follow without a code change. Spawn
|
||||
// records and region definitions are the authority, because those are the
|
||||
// names everything else is keyed on.
|
||||
const facetIndex = buildFacetIndex([
|
||||
...rawPoints.map((point) => point.facet),
|
||||
...regions.map((region) => region.facet),
|
||||
])
|
||||
|
||||
// Landmark facets are then matched against that set, which is what absorbs the
|
||||
// `Ter Mur` / `Tokuno Islands` spelling drift between Locations and <Map>.
|
||||
const landmarks = rawLandmarks.map(({ facetLabel, ...landmark }) => {
|
||||
const fromFile = resolveFacetName(landmark.facet, facetIndex)
|
||||
const matchedFile = facetIndex.has(facetKey(fromFile))
|
||||
const resolved = matchedFile ? fromFile : resolveFacetName(facetLabel, facetIndex)
|
||||
return { ...landmark, facet: resolved || landmark.facet }
|
||||
})
|
||||
|
||||
const placement = buildPlacementIndex(regions, landmarks)
|
||||
const resolveOpts = options.landmarkRadius ? { landmarkRadius: options.landmarkRadius } : {}
|
||||
|
||||
const disabled = rawPoints.filter((point) => !point.running).length
|
||||
const points = rawPoints
|
||||
// A spawner switched off in-world produces nothing; advertising it would be
|
||||
// a straight lie to a player planning a hunt.
|
||||
.filter((point) => point.running)
|
||||
// A spawner with no types is a placeholder — nothing to show.
|
||||
.filter((point) => point.types.length > 0)
|
||||
.map((point) => {
|
||||
const place = resolveRegion(point.x, point.y, point.facet, placement, resolveOpts)
|
||||
return {
|
||||
name: point.name,
|
||||
facet: point.facet,
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
width: point.width,
|
||||
height: point.height,
|
||||
range: point.range,
|
||||
maxCount: point.maxCount,
|
||||
minDelay: point.minDelay,
|
||||
maxDelay: point.maxDelay,
|
||||
todStart: point.todStart,
|
||||
todEnd: point.todEnd,
|
||||
todMode: point.todMode,
|
||||
region: place.region,
|
||||
landmark: place.landmark,
|
||||
label: place.label,
|
||||
types: point.types,
|
||||
}
|
||||
})
|
||||
|
||||
const championsFile = byLabel.get('Config/ChampionSpawns.xml')
|
||||
const champions = (championsFile ? parseChampions(championsFile.text) : []).map((champ) => {
|
||||
const facet = resolveFacetName(champ.facet, facetIndex) || champ.facet
|
||||
return {
|
||||
...champ,
|
||||
facet,
|
||||
slug: slugify(`${facet}-${champ.name}`),
|
||||
label: resolveRegion(champ.x, champ.y, facet, placement, resolveOpts).label,
|
||||
}
|
||||
})
|
||||
|
||||
const creatures = aggregateCreatures(points)
|
||||
const facets = [...new Set(points.map((point) => point.facet))].sort()
|
||||
const unresolved = points.filter((point) => !point.region && !point.landmark).length
|
||||
|
||||
return {
|
||||
meta: {
|
||||
generatedAt: new Date().toISOString(),
|
||||
landmarkRadius: options.landmarkRadius ?? undefined,
|
||||
counts: {
|
||||
facets: facets.length,
|
||||
points: points.length,
|
||||
pointsDisabled: disabled,
|
||||
creatures: creatures.length,
|
||||
regions: regions.length,
|
||||
landmarks: landmarks.length,
|
||||
champions: champions.length,
|
||||
unresolvedPoints: unresolved,
|
||||
},
|
||||
source,
|
||||
},
|
||||
facets,
|
||||
creatures,
|
||||
regions,
|
||||
landmarks,
|
||||
champions,
|
||||
points,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
AtlasSourceError,
|
||||
readSources,
|
||||
hashSources,
|
||||
sameSources,
|
||||
buildAtlas,
|
||||
aggregateCreatures,
|
||||
displayName,
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const { aggregateCreatures, displayName, encodePoint } = require('../scripts/buildSpawnAtlas')
|
||||
const { readPoint } = require('../scripts/importSpawnAtlas')
|
||||
|
||||
// buildSpawnAtlas is the fs/CLI shell, but its aggregation and its artifact
|
||||
// encoding are pure and worth covering — the encoding especially, because it is
|
||||
// mirrored by hand in importSpawnAtlas.readPoint() and a drift between the two
|
||||
// would corrupt every imported point silently rather than failing loudly.
|
||||
|
||||
// ── aggregateCreatures ─────────────────────────────────────────────────────
|
||||
|
||||
const POINTS = [
|
||||
{
|
||||
facet: 'Felucca',
|
||||
types: [
|
||||
{ type: 'Lizardman', max: 3 },
|
||||
{ type: 'Orc', max: 1 },
|
||||
],
|
||||
},
|
||||
{ facet: 'Felucca', types: [{ type: 'Lizardman', max: 2 }] },
|
||||
{ facet: 'Trammel', types: [{ type: 'lizardman', max: 5 }] },
|
||||
]
|
||||
|
||||
test('aggregateCreatures: sums each type’s own max across points', () => {
|
||||
const creatures = aggregateCreatures(POINTS)
|
||||
const lizardman = creatures.find((c) => c.slug === 'lizardman')
|
||||
// 3 + 2 + 5 — how many exist in the world at once.
|
||||
assert.equal(lizardman.total, 10)
|
||||
assert.equal(lizardman.points, 3)
|
||||
})
|
||||
|
||||
test('aggregateCreatures: counts points per facet', () => {
|
||||
const lizardman = aggregateCreatures(POINTS).find((c) => c.slug === 'lizardman')
|
||||
assert.deepEqual(lizardman.facets, { Felucca: 2, Trammel: 1 })
|
||||
})
|
||||
|
||||
test('aggregateCreatures: differing case collapses into one creature', () => {
|
||||
// "Lizardman" and "lizardman" are the same creature; the shard's spawn files
|
||||
// are not consistent about case.
|
||||
const creatures = aggregateCreatures(POINTS)
|
||||
assert.equal(creatures.filter((c) => c.slug === 'lizardman').length, 1)
|
||||
})
|
||||
|
||||
test('aggregateCreatures: sorted by slug, and internal state is not leaked', () => {
|
||||
const creatures = aggregateCreatures(POINTS)
|
||||
assert.deepEqual(
|
||||
creatures.map((c) => c.slug),
|
||||
['lizardman', 'orc'],
|
||||
)
|
||||
// The spelling tally is a build detail and must not reach the artifact.
|
||||
assert.equal(Object.hasOwn(creatures[0], 'spellings'), false)
|
||||
})
|
||||
|
||||
test('aggregateCreatures: empty input yields no creatures', () => {
|
||||
assert.deepEqual(aggregateCreatures([]), [])
|
||||
})
|
||||
|
||||
// ── displayName ────────────────────────────────────────────────────────────
|
||||
|
||||
test('displayName: the most common spelling wins', () => {
|
||||
assert.equal(displayName(new Map([['lizardman', 9], ['Lizardman', 2]])), 'lizardman')
|
||||
assert.equal(displayName(new Map([['Lizardman', 9], ['lizardman', 2]])), 'Lizardman')
|
||||
})
|
||||
|
||||
test('displayName: ties break toward the capitalised spelling', () => {
|
||||
// Otherwise the display name depends on file read order, which would produce
|
||||
// spurious diffs in the committed artifact on an unrelated rebuild.
|
||||
assert.equal(displayName(new Map([['lizardman', 5], ['Lizardman', 5]])), 'Lizardman')
|
||||
assert.equal(displayName(new Map([['acidslug', 1], ['AcidSlug', 1]])), 'AcidSlug')
|
||||
})
|
||||
|
||||
test('displayName: fully tied spellings fall back to alphabetical, not input order', () => {
|
||||
const forward = displayName(new Map([['abc', 1], ['abd', 1]]))
|
||||
const reverse = displayName(new Map([['abd', 1], ['abc', 1]]))
|
||||
assert.equal(forward, reverse)
|
||||
})
|
||||
|
||||
// ── encodePoint / readPoint round trip ─────────────────────────────────────
|
||||
|
||||
const FULL_POINT = {
|
||||
facet: 'Trammel',
|
||||
name: 'CovetousSpawner26',
|
||||
x: 5412,
|
||||
y: 1970,
|
||||
width: 10,
|
||||
height: 10,
|
||||
range: 5,
|
||||
maxCount: 3,
|
||||
minDelay: 5,
|
||||
maxDelay: 10,
|
||||
todStart: 4,
|
||||
todEnd: 8,
|
||||
todMode: 1,
|
||||
region: 'Covetous',
|
||||
landmark: null,
|
||||
types: [
|
||||
{ type: 'Lizardman', max: 3 },
|
||||
{ type: 'Orc', max: 1 },
|
||||
],
|
||||
}
|
||||
|
||||
test('encodePoint: drops facet and tuple-encodes types', () => {
|
||||
const encoded = encodePoint(FULL_POINT)
|
||||
assert.equal(Object.hasOwn(encoded, 'facet'), false)
|
||||
assert.deepEqual(encoded.types, [
|
||||
['Lizardman', 3],
|
||||
['Orc', 1],
|
||||
])
|
||||
})
|
||||
|
||||
test('round trip: encodePoint → JSON → readPoint restores every field', () => {
|
||||
// The artifact goes through JSON on disk, so round-trip through it here too.
|
||||
const wire = JSON.parse(JSON.stringify(encodePoint(FULL_POINT)))
|
||||
const decoded = readPoint(wire, 'Trammel')
|
||||
for (const key of Object.keys(FULL_POINT)) {
|
||||
if (key === 'types') continue
|
||||
assert.deepEqual(decoded[key], FULL_POINT[key], `field "${key}" survived the round trip`)
|
||||
}
|
||||
assert.deepEqual(decoded.types, FULL_POINT.types)
|
||||
})
|
||||
|
||||
test('round trip: omitted defaults come back as zeros, not undefined', () => {
|
||||
// The build omits width/height/range/minDelay/maxDelay/tod* when they are 0,
|
||||
// which is the majority of spawners. They must decode to 0 — a NULL would
|
||||
// violate the NOT NULL columns.
|
||||
const sparse = {
|
||||
facet: 'Felucca',
|
||||
name: 'Simple',
|
||||
x: 100,
|
||||
y: 200,
|
||||
width: 0,
|
||||
height: 0,
|
||||
range: 0,
|
||||
maxCount: 1,
|
||||
minDelay: 0,
|
||||
maxDelay: 0,
|
||||
todStart: 0,
|
||||
todEnd: 0,
|
||||
todMode: 0,
|
||||
region: null,
|
||||
landmark: null,
|
||||
types: [{ type: 'Orc', max: 1 }],
|
||||
}
|
||||
const encoded = JSON.parse(JSON.stringify(encodePoint(sparse)))
|
||||
// Precondition: the build really did omit them.
|
||||
assert.equal(Object.hasOwn(encoded, 'width'), false)
|
||||
assert.equal(Object.hasOwn(encoded, 'todMode'), false)
|
||||
|
||||
const decoded = readPoint(encoded, 'Felucca')
|
||||
for (const key of ['width', 'height', 'range', 'minDelay', 'maxDelay', 'todStart', 'todEnd', 'todMode']) {
|
||||
assert.equal(decoded[key], 0, `${key} decodes to 0`)
|
||||
}
|
||||
})
|
||||
|
||||
test('round trip: label is recomputed, not stored', () => {
|
||||
const encoded = encodePoint(FULL_POINT)
|
||||
// Precondition: the build does not write it.
|
||||
assert.equal(Object.hasOwn(encoded, 'label'), false)
|
||||
|
||||
assert.equal(readPoint(encoded, 'Trammel').label, 'Covetous')
|
||||
assert.equal(readPoint({ ...encoded, region: undefined, landmark: 'Britain' }, 'T').label, 'Britain')
|
||||
assert.equal(
|
||||
readPoint({ ...encoded, region: undefined, landmark: undefined }, 'T').label,
|
||||
'Wilderness',
|
||||
)
|
||||
})
|
||||
|
||||
test('readPoint: takes its facet from the shard file, not the record', () => {
|
||||
const decoded = readPoint(encodePoint(FULL_POINT), 'Felucca')
|
||||
assert.equal(decoded.facet, 'Felucca')
|
||||
})
|
||||
|
||||
test('readPoint: tolerates already-decoded object types', () => {
|
||||
// Defensive: an artifact written before tuple encoding still imports.
|
||||
const decoded = readPoint({ x: 1, y: 2, types: [{ type: 'Orc', max: 2 }] }, 'Felucca')
|
||||
assert.deepEqual(decoded.types, [{ type: 'Orc', max: 2 }])
|
||||
})
|
||||
|
||||
test('readPoint: a record with no types decodes to an empty list', () => {
|
||||
assert.deepEqual(readPoint({ x: 1, y: 2 }, 'Felucca').types, [])
|
||||
})
|
||||
@@ -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 ─────────────────────────────────────────────────────────
|
||||
|
||||
375
server/test/spawnAtlas.source.test.js
Normal file
375
server/test/spawnAtlas.source.test.js
Normal 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 type’s 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'])
|
||||
})
|
||||
Reference in New Issue
Block a user