#!/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 [--out ] [--landmark-radius ] --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..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 , 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 }