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
319 lines
11 KiB
JavaScript
319 lines
11 KiB
JavaScript
#!/usr/bin/env node
|
|
//
|
|
// Load the committed spawn atlas artifact into the database.
|
|
//
|
|
// npm run atlas:import [-- --dir db/data]
|
|
//
|
|
// 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 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.
|
|
|
|
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 }
|
|
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
|
|
}
|
|
return args
|
|
}
|
|
|
|
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`,
|
|
)
|
|
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`,
|
|
)
|
|
}
|
|
|
|
if (require.main === module) {
|
|
main()
|
|
.catch((err) => {
|
|
process.stderr.write(`atlas:import failed: ${err.message}\n`)
|
|
process.exitCode = 1
|
|
})
|
|
.finally(() => db().close())
|
|
}
|
|
|
|
module.exports = { importAtlas, readPoint, loadArtifact }
|