Protocol 3.0 order 3 (Part C), first of two website PRs. This half is the data
pipeline only — parsers, the build/import CLI, and the tables. No routes and no
client, so nothing is user-visible yet; the API and pages follow in PR 2.
Part C is website-only: no plugin, no sidecar, no new event kinds, no wire
change.
## Parsing
`src/utils/spawnAtlasParse.js` is pure and fs-free so CI covers it with no
ServUO tree. Zero new dependencies — `Regions.xml` genuinely nests, so it gets a
small hand-rolled subset tokenizer rather than a new XML package. The 10.5 MB of
`Spawns/*.xml` never touches it: those records are flat and get a streaming
regex sweep instead.
The high-value transform is point-in-rect placement — highest region priority
wins, ties break to the smaller rect, then a nearest-landmark fallback within
200 tiles, else "Wilderness". That is what turns "lizardman at 5411,1234" into
"Despise, Felucca", and it resolves 83.2% of points (5,369 of 6,455).
Three things the real data forced, none of which were in the design:
- **Only 6 facets, not 13.** `Eodon.xml`, `GravewaterLake.xml` and the other
named-area files carry TerMur/Trammel points, so the facet comes from each
record's own `<Map>` and the artifact shards 6 ways.
- **Facet names disagree across sources.** `Data/Locations/*.xml` spells them
`Ter Mur` and `Tokuno Islands`; `<Map>` and `<Facet name>` say `TerMur` and
`Tokuno`. Unreconciled this is silent — the landmark fallback simply never
fires on those facets and every unregioned spawn there reads "Wilderness".
- **Spawn type tokens carry XmlSpawner directives**: `Fairy,{RND,4,8}`,
`alchemist/z/-50`, `Agralem/Name/Agralem`. Taken literally these invent
creatures that do not exist AND split real ones in two, since `Fairy` and
`Fairy,{RND,4,8}` slug apart. 71 of 845 entries were affected; stripping at
the first `/` or `,` leaves 800 clean ones.
## Artifact
`npm run atlas:build -- --servuo <path>` writes `db/data/spawnAtlas.*.json`:
6 facet shards + a compact index + a small indented `meta`. 1.41 MB committed,
down from 4.40 MB by dropping `facet` per record, omitting defaulted fields, and
tuple-encoding the ~24,000 type entries. `encodePoint()` and the importer's
`readPoint()` are exact inverses and are round-tripped in tests.
Display spelling is chosen deterministically (most common, ties to the
capitalised form) because the spawn files are inconsistent about case and the
name would otherwise depend on file read order — a spurious diff on every
unrelated rebuild.
## Import
`npm run atlas:import` needs no ServUO tree, which is the whole reason build and
import are separate: the container has the artifact but not the tree. It
reloads all six tables in one transaction (DELETE, not TRUNCATE, which is DDL
and would implicitly commit), so a failed import leaves the previous atlas
intact.
## No artwork, by design
The repo ships no creature art and no extraction tooling. Sprites live in the
operator's own client `.mul`/`.uop` files and are theirs, not ours to
redistribute. `shard_spawn_creatures.art` is nullable and NULL on every fresh
import; an operator who wants art extracts it themselves, drops it under
`server/uploads/atlas/` (already gitignored) and maps slugs in a gitignored
`spawnAtlas.art.json`. Text-only is the normal, fully supported state.
## Verification
- **544 server tests pass**, 57 new across `spawnAtlas.parse.test.js` (the
`:OBJ=` split, directive stripping, nested-region priority inheritance,
half-open rects, the facet reconciliation, tokenizer edge cases) and
`spawnAtlas.build.test.js` (aggregation, deterministic naming, and the
encode/decode round trip).
- Built and imported for real against the local MariaDB and the ServUO tree at
`C:\Users\colby\Desktop\ServUO`: 6,455 points, 800 creatures, 23,927
point/type rows, 387 regions, 558 landmarks, 25 champion altars.
- "Where does a lizardman spawn?" answers Shrines / Isamu-Jima / Yew across
Felucca, Trammel and Tokuno.
No routes changed, so the OpenAPI spec and route manifest are untouched.
---
- [x] AI-assisted: written with **Claude Code** (Claude Opus 5), reviewed before opening.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U7CBg11prhLimL9iHSX1bP
384 lines
14 KiB
JavaScript
384 lines
14 KiB
JavaScript
#!/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 }
|