#!/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 ', ) } 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, `{ "": "" }`. * * 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 ]\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 }