Merge pull request 'feat(atlas): derive a spawn atlas from the shard tree on every boot' (#112) from feat/spawn-atlas-parse into edge

Reviewed-on: #112
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
This commit is contained in:
2026-07-28 21:51:28 +00:00
12 changed files with 2740 additions and 0 deletions

7
.gitignore vendored
View File

@@ -21,6 +21,13 @@ uploads/
server/logs/
logs/
# 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
# reference material (extracted from the provided archives)
_reference/

View 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"
}

View File

@@ -1040,6 +1040,156 @@ CREATE TABLE IF NOT EXISTS announce_jobs (
INDEX idx_announce_due_discord (discord_status, discord_next_attempt_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ── Spawn atlas (Protocol 3.0 Part C) ───────────────────────────────────────
-- Static shard CONTENT, not live shard state: what spawns where, which regions
-- and landmarks exist, and which champion altars are configured. Nothing here
-- comes from the sidecar — it is imported from a committed artifact built off a
-- ServUO tree by `npm run atlas:build` (see docs/website/SPAWN_ATLAS.md), so
-- these tables stay populated whether the shard is up or not.
--
-- Every table is import-owned: `npm run atlas:import` TRUNCATEs and reloads them
-- in one transaction. Nothing else may write here, and nothing else may hold a
-- foreign key to them. No FKs at all, consistent with every other shard_* table.
-- One row per spawnable type, aggregated across the world. `total` is the sum of
-- each type's own MX across every point that spawns it (how many exist at once);
-- `facets` is a per-facet point count, so the facet filter and "where does this
-- live" both answer without touching shard_spawn_points.
CREATE TABLE IF NOT EXISTS shard_spawn_creatures (
slug VARCHAR(120) NOT NULL PRIMARY KEY, -- slugified class name; the /atlas/:slug key
name VARCHAR(120) NOT NULL, -- display spelling chosen by the build
total INT NOT NULL DEFAULT 0,
points INT NOT NULL DEFAULT 0,
facets JSON NULL, -- { "Felucca": 171, "Trammel": 160, ... }
-- Operator-supplied artwork, always NULL on a fresh import. The repo ships no
-- creature art: sprites live in the operator's own client .mul/.uop files and
-- are theirs to extract and place under uploads/atlas/. The UI renders without
-- art when this is NULL, which is the normal case.
art VARCHAR(255) NULL,
-- Plain INDEX, deliberately NOT FULLTEXT: ~800 rows makes a LIKE scan free,
-- and FULLTEXT's min-token-length would break searches for names like "orc".
INDEX idx_shard_spawn_creatures_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- One row per spawner. `region`/`landmark` are the resolved place name — the
-- point-in-rect transform that turns "5411,1234" into "Despise" — and `label` is
-- the resolved display string (region, else landmark, else 'Wilderness').
CREATE TABLE IF NOT EXISTS shard_spawn_points (
id INT AUTO_INCREMENT PRIMARY KEY,
facet VARCHAR(40) NOT NULL,
name VARCHAR(120) NULL, -- the ServUO spawner's own name
x INT NOT NULL,
y INT NOT NULL,
width INT NOT NULL DEFAULT 0,
height INT NOT NULL DEFAULT 0,
spawn_range INT NOT NULL DEFAULT 0, -- `range` is reserved in MariaDB
max_count INT NOT NULL DEFAULT 0,
min_delay INT NOT NULL DEFAULT 0,
max_delay INT NOT NULL DEFAULT 0,
tod_start INT NOT NULL DEFAULT 0, -- meaningless unless tod_mode <> 0
tod_end INT NOT NULL DEFAULT 0,
tod_mode INT NOT NULL DEFAULT 0,
region VARCHAR(120) NULL,
landmark VARCHAR(120) NULL,
label VARCHAR(120) NOT NULL DEFAULT 'Wilderness',
INDEX idx_shard_spawn_points_facet (facet),
INDEX idx_shard_spawn_points_label (label)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- The many-to-many between the two above: one spawner commonly carries several
-- types (a single Trammel point spawns six), each with its own max. This is how
-- /atlas/creatures/:slug finds the places a creature appears.
CREATE TABLE IF NOT EXISTS shard_spawn_point_types (
point_id INT NOT NULL,
slug VARCHAR(120) NOT NULL, -- → shard_spawn_creatures.slug (no FK)
max_count INT NOT NULL DEFAULT 1,
PRIMARY KEY (point_id, slug),
INDEX idx_shard_spawn_point_types_slug (slug)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Named regions from Data/Regions.xml, flattened out of their nesting. `rects`
-- holds the region's rectangles; `priority` and rect area are what resolved each
-- spawn point at build time, kept here so the admin drift check can re-derive.
CREATE TABLE IF NOT EXISTS shard_regions (
id INT AUTO_INCREMENT PRIMARY KEY,
facet VARCHAR(40) NOT NULL,
name VARCHAR(120) NOT NULL,
type VARCHAR(80) NULL, -- ServUO region class
priority INT NOT NULL DEFAULT 0,
parent VARCHAR(120) NULL, -- enclosing named region, if any
rects JSON NULL,
INDEX idx_shard_regions_facet (facet),
INDEX idx_shard_regions_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Points of interest from Data/Locations/*.xml. `grp` is the innermost enclosing
-- parent ("Covetous"), which is the label worth showing — "Covetous" reads
-- better than the individual marker "Level 1". (`group` is reserved in SQL.)
CREATE TABLE IF NOT EXISTS shard_landmarks (
id INT AUTO_INCREMENT PRIMARY KEY,
facet VARCHAR(40) NOT NULL,
name VARCHAR(120) NOT NULL,
grp VARCHAR(120) NULL,
x INT NOT NULL,
y INT NOT NULL,
z INT NOT NULL DEFAULT 0,
INDEX idx_shard_landmarks_facet (facet),
INDEX idx_shard_landmarks_name (name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Configured champion altars from Config/ChampionSpawns.xml. This is static
-- roster data ("there is an Unholy Terror altar in Deceit") and is distinct from
-- the live champ.update feed in shard_champs ("it is on level 3 right now").
CREATE TABLE IF NOT EXISTS shard_champion_spawns (
slug VARCHAR(160) NOT NULL PRIMARY KEY, -- facet-name, e.g. "felucca-deceit"
name VARCHAR(120) NOT NULL,
grp VARCHAR(80) NULL, -- spawn group; one active per group
type VARCHAR(80) NULL, -- '' when randomised per activation
random_type TINYINT(1) NOT NULL DEFAULT 0,
facet VARCHAR(40) NOT NULL,
x INT NOT NULL,
y INT NOT NULL,
z INT NOT NULL DEFAULT 0,
radius INT NOT NULL DEFAULT 0,
label VARCHAR(120) NULL, -- resolved place name
INDEX idx_shard_champion_spawns_facet (facet)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Singleton (id = 1) describing the artifact currently loaded: when it was
-- built, its counts, and a sha256 per ServUO source file. The admin drift check
-- compares this against db/data/spawnAtlas.meta.json to report when the database
-- is behind the committed artifact.
CREATE TABLE IF NOT EXISTS shard_atlas_meta (
id TINYINT NOT NULL PRIMARY KEY DEFAULT 1,
payload JSON NOT NULL,
imported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
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.

View File

@@ -9,6 +9,7 @@
"seed": "node db/seed.js",
"swagger": "node swagger/swagger.js",
"routes:manifest": "node scripts/routeManifest.js",
"atlas:import": "node scripts/importSpawnAtlas.js",
"test": "node --test"
},
"keywords": [

View File

@@ -0,0 +1,127 @@
#!/usr/bin/env node
//
// Refresh the spawn atlas from a ServUO tree, from the command line.
//
// 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
//
// 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.
//
// All the logic lives in `src/model/shardAtlas/shardAtlas.model.js`; this file
// is argument parsing and output formatting.
const db = () => require('../src/utils/db')
function parseArgs(argv) {
const args = {}
for (let i = 0; i < argv.length; i += 1) {
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(USAGE)
return
}
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) {
main()
.catch((err) => {
process.stderr.write(`atlas:import failed: ${err.message}\n`)
process.exitCode = 1
})
.finally(() => db().close())
}
module.exports = { describe, parseArgs }

View 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,
}

View 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,
}

View File

@@ -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()}`)

View File

@@ -0,0 +1,664 @@
// Spawn atlas parsers — pure functions over strings, no `fs`, no dependencies.
//
// These back the CLI build script (`scripts/buildSpawnAtlas.js`), which is the
// only thing that reads a ServUO tree. Keeping every parser pure and fs-free is
// what lets the test suite cover them in CI, where no ServUO tree exists: the
// tests hand these functions literal XML strings.
//
// Four source shapes, two very different parsing strategies:
//
// Spawns/*.xml ~10.5 MB across 13 files, FLAT <Points> records
// → streaming regex, never a DOM. See parsePoints().
// Data/Regions.xml 129 KB, genuinely nested <region> inside <region>
// Data/Locations/*.xml nested <parent>/<child>
// Config/ChampionSpawns.xml 4.8 KB, <spawn>/<location>
// → the small recursive tokenizer below.
//
// The server has zero XML dependencies and this adds none. The tokenizer is
// deliberately a *subset* parser: it handles the constructs these four files
// actually use (elements, attributes, self-closing tags, comments, the XML
// declaration, CDATA, the five predefined entities plus numeric refs) and
// nothing else. It is not a general-purpose XML parser and must not be reused
// as one — no namespaces, no DTDs, no entity declarations.
// ── Entities ───────────────────────────────────────────────────────────────
const NAMED_ENTITIES = {
amp: '&',
lt: '<',
gt: '>',
quot: '"',
apos: "'",
}
// Region and location names carry apostrophes ("Mondain's Legacy", "Wrong's
// Level 3"), so entity decoding is load-bearing here, not decorative.
function decodeEntities(text) {
if (!text.includes('&')) return text
return text.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (match, body) => {
if (body[0] === '#') {
const code =
body[1] === 'x' || body[1] === 'X'
? Number.parseInt(body.slice(2), 16)
: Number.parseInt(body.slice(1), 10)
return Number.isFinite(code) ? String.fromCodePoint(code) : match
}
const named = NAMED_ENTITIES[body.toLowerCase()]
return named === undefined ? match : named
})
}
// ── The tokenizer ──────────────────────────────────────────────────────────
const ATTR_RE = /([\w:.-]+)\s*=\s*("([^"]*)"|'([^']*)')/g
function parseAttrs(source) {
const attrs = {}
ATTR_RE.lastIndex = 0
let match
while ((match = ATTR_RE.exec(source)) !== null) {
const raw = match[3] !== undefined ? match[3] : match[4]
attrs[match[1]] = decodeEntities(raw)
}
return attrs
}
/**
* Parse a small nested XML document into `{ name, attrs, children, text }`.
*
* Intended for Regions.xml / Locations / ChampionSpawns.xml only — never for
* the multi-megabyte Spawns files. Returns the root element, or `null` for a
* document with no elements.
*
* Mismatched or stray closing tags are ignored rather than thrown on: these are
* hand-maintained shard config files, and one malformed region should degrade
* to a missing region, not abort a build that is otherwise fine.
*/
function parseXml(source) {
const text = String(source)
const root = { name: '#document', attrs: {}, children: [], text: '' }
const stack = [root]
let i = 0
while (i < text.length) {
const lt = text.indexOf('<', i)
if (lt === -1) {
appendText(stack[stack.length - 1], text.slice(i))
break
}
if (lt > i) appendText(stack[stack.length - 1], text.slice(i, lt))
// Comment, declaration/DOCTYPE, or CDATA — skipped wholesale.
if (text.startsWith('<!--', lt)) {
const end = text.indexOf('-->', lt + 4)
i = end === -1 ? text.length : end + 3
continue
}
if (text.startsWith('<![CDATA[', lt)) {
const end = text.indexOf(']]>', lt + 9)
const stop = end === -1 ? text.length : end
appendRawText(stack[stack.length - 1], text.slice(lt + 9, stop))
i = end === -1 ? text.length : end + 3
continue
}
if (text.startsWith('<?', lt)) {
const end = text.indexOf('?>', lt + 2)
i = end === -1 ? text.length : end + 2
continue
}
if (text.startsWith('<!', lt)) {
const end = text.indexOf('>', lt + 2)
i = end === -1 ? text.length : end + 1
continue
}
const gt = findTagEnd(text, lt)
if (gt === -1) {
// Unterminated tag: nothing sane is left to read.
break
}
const inner = text.slice(lt + 1, gt)
if (inner[0] === '/') {
const name = inner.slice(1).trim()
// Pop to the nearest matching open element. If there is no match the tag
// is stray and we drop it rather than unwinding the whole stack.
for (let depth = stack.length - 1; depth > 0; depth -= 1) {
if (stack[depth].name === name) {
stack.length = depth
break
}
}
i = gt + 1
continue
}
const selfClosing = inner.endsWith('/')
const body = selfClosing ? inner.slice(0, -1) : inner
const space = body.search(/\s/)
const name = (space === -1 ? body : body.slice(0, space)).trim()
const node = {
name,
attrs: space === -1 ? {} : parseAttrs(body.slice(space)),
children: [],
text: '',
}
stack[stack.length - 1].children.push(node)
if (!selfClosing) stack.push(node)
i = gt + 1
}
return root.children.length > 0 ? root.children[0] : null
}
// `>` inside a quoted attribute value must not end the tag.
function findTagEnd(text, from) {
let quote = null
for (let i = from + 1; i < text.length; i += 1) {
const ch = text[i]
if (quote) {
if (ch === quote) quote = null
} else if (ch === '"' || ch === "'") {
quote = ch
} else if (ch === '>') {
return i
}
}
return -1
}
function appendText(node, chunk) {
if (chunk.trim() === '') return
appendRawText(node, decodeEntities(chunk))
}
function appendRawText(node, chunk) {
node.text = node.text ? `${node.text}${chunk}` : chunk
}
function childrenNamed(node, name) {
if (!node || !node.children) return []
return node.children.filter((child) => child.name === name)
}
// ── Facet names ────────────────────────────────────────────────────────────
//
// 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.
/**
* Collapse a facet name to a comparison key: lowercase, alphanumerics only.
* `TerMur`, `Ter Mur` and `ter-mur` all key alike.
*/
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()
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 ────────────────────────────────────────────────────────
function toInt(value, fallback = 0) {
const n = Number.parseInt(value, 10)
return Number.isFinite(n) ? n : fallback
}
function toBool(value) {
return String(value).trim().toLowerCase() === 'true'
}
/**
* URL-safe slug used as the creature primary key and in `/atlas/:slug`.
* Spawn type tokens are C# class names, so they are already ASCII-ish; this
* mainly lowercases and collapses punctuation.
*/
function slugify(value) {
return String(value)
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
// ── Objects2 ───────────────────────────────────────────────────────────────
/**
* Parse a `<Objects2>` value into `[{ type, max }]`.
*
* The format is one or more segments joined by `:OBJ=`, each segment being
* `Type:MX=n:SB=0:RT=0:...` — the type is the token before the first `:`, and
* every following token is a `KEY=value` pair. Verified against trammel.xml,
* where a single point carries six types:
*
* Giantserpent:MX=1:...:OBJ=Giantspider:MX=1:...:OBJ=Boar:MX=1:...
*
* Splitting on `:` alone would shred this, which is why the `:OBJ=` split comes
* first. `MX` is that type's own max count and is what the atlas displays;
* every other flag (spawn/trigger/refractory bookkeeping) is dropped.
*
* The type token itself may carry XmlSpawner directives appended to the class
* name — property assignments after `/` and an amount/argument list after `,`:
*
* Agralem/Name/Agralem alchemist/z/-50 Fairy,{RND,4,8}
* GargishRefugee/hue/34532 greatape,true GargishRouser,1
*
* Taken literally these produce creatures that do not exist ("alchemist/z/-50")
* AND split real ones in two, because `Fairy` and `Fairy,{RND,4,8}` slug apart —
* 71 of 845 entries were affected before this was stripped. Only the leading
* class name identifies the creature, so everything from the first `/` or `,`
* is dropped.
*/
/** Reduce an XmlSpawner type token to the bare class name. */
function stripSpawnerDirectives(token) {
const cut = String(token).search(/[/,]/)
return (cut === -1 ? String(token) : String(token).slice(0, cut)).trim()
}
function parseObjects2(value) {
const source = String(value ?? '').trim()
if (source === '') return []
return source
.split(':OBJ=')
.map((segment) => {
const tokens = segment.split(':')
const type = stripSpawnerDirectives(tokens.shift() ?? '')
if (type === '') return null
let max = 1
for (const token of tokens) {
const eq = token.indexOf('=')
if (eq === -1) continue
if (token.slice(0, eq).trim().toUpperCase() === 'MX') {
max = toInt(token.slice(eq + 1), 1)
}
}
return { type, max }
})
.filter((entry) => entry !== null)
}
// ── Spawns/*.xml ───────────────────────────────────────────────────────────
const POINT_RE = /<Points>([\s\S]*?)<\/Points>/g
function tagValue(block, name) {
const match = block.match(new RegExp(`<${name}>([\\s\\S]*?)</${name}>`))
return match ? decodeEntities(match[1]).trim() : ''
}
/**
* Parse a `Spawns/<facet>.xml` file into spawn point records.
*
* Deliberately regex/streaming and NOT `parseXml` — these files total ~10.5 MB
* and putting them through a DOM builder would allocate a node per element for
* ~40 fields on every one of ~6,500 records to keep 14 of them. The records are
* flat, so a per-record regex sweep is both correct and cheap.
*
* Only the fields the site can actually show are kept. Everything to do with
* triggering, refractory windows, proximity, sequential spawning, sounds and
* `UniqueId` is dropped here rather than downstream — that is what holds the
* committed artifact under 1 MB.
*
* NOTE: the facet comes from each record's own `<Map>`, never from the file
* name. `Eodon.xml`, `GravewaterLake.xml` and the other named-area files all
* carry TerMur/Trammel points, so there are 13 files but only 6 facets.
*/
function parsePoints(source) {
const text = String(source)
const points = []
POINT_RE.lastIndex = 0
let match
while ((match = POINT_RE.exec(text)) !== null) {
const block = match[1]
// 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({
name: tagValue(block, 'Name'),
facet,
x: toInt(tagValue(block, 'X')),
y: toInt(tagValue(block, 'Y')),
width: toInt(tagValue(block, 'Width')),
height: toInt(tagValue(block, 'Height')),
range: toInt(tagValue(block, 'Range')),
maxCount: toInt(tagValue(block, 'MaxCount')),
minDelay: toInt(tagValue(block, 'MinDelay')),
maxDelay: toInt(tagValue(block, 'MaxDelay')),
// Time-of-day gating: TODMode 0 means "always", in which case the start
// and end values are meaningless and the site must not render them.
todStart: toInt(tagValue(block, 'TODStart')),
todEnd: toInt(tagValue(block, 'TODEnd')),
todMode: toInt(tagValue(block, 'TODMode')),
// A spawner switched off in-world spawns nothing; the build filters these
// out so the atlas describes what actually appears, not what is merely
// configured. Parsed here so the decision stays in the build script.
running: toBool(tagValue(block, 'IsRunning')),
types: parseObjects2(tagValue(block, 'Objects2')),
})
}
return points
}
// ── Data/Regions.xml ───────────────────────────────────────────────────────
/**
* Flatten `Data/Regions.xml` into `[{ facet, name, type, priority, parent, rects }]`.
*
* Regions nest: a `<region>` may contain further `<region>` elements, and the
* inner ones frequently omit `name` and `priority` (`<region type="CrystalField">`
* inside "Prism of Light"). Unnamed regions are skipped — they cannot label a
* spawn point — but their children are still walked, and a child that omits
* `priority` inherits its parent's rather than defaulting to 0, which would
* quietly sort it below every top-level region.
*/
function parseRegions(source) {
const root = parseXml(source)
const regions = []
if (!root) return regions
for (const facetNode of childrenNamed(root, 'Facet')) {
const facet = (facetNode.attrs.name || '').trim()
if (facet === '') continue
walkRegions(facetNode, facet, null, 0, regions)
}
return regions
}
function walkRegions(node, facet, parentName, parentPriority, out) {
for (const regionNode of childrenNamed(node, 'region')) {
const name = regionNode.attrs.name || ''
const priority = Object.hasOwn(regionNode.attrs, 'priority')
? toInt(regionNode.attrs.priority, parentPriority)
: parentPriority
if (name !== '') {
const rects = childrenNamed(regionNode, 'rect').map((rect) => ({
x: toInt(rect.attrs.x),
y: toInt(rect.attrs.y),
width: toInt(rect.attrs.width),
height: toInt(rect.attrs.height),
}))
// A named region with no rects (some exist purely to carry music or a
// `go` point) can never contain anything, so it is not worth indexing.
if (rects.length > 0) {
out.push({
facet,
name,
type: regionNode.attrs.type || '',
priority,
parent: parentName,
rects,
})
}
}
walkRegions(regionNode, facet, name === '' ? parentName : name, priority, out)
}
}
// ── Data/Locations/*.xml ───────────────────────────────────────────────────
/**
* Flatten a `Data/Locations/<facet>.xml` into landmark points.
*
* The file nests `<parent>` arbitrarily deep and puts coordinates only on
* `<child>`: Trammel → Dungeons → Covetous → "Level 1". The outermost parent is
* the facet itself and is dropped from `path`; `group` is the innermost
* enclosing parent ("Covetous"), which is the label worth showing — "Covetous"
* reads better than "Level 1" when naming where a spawn is.
*/
function parseLocations(source, facetHint = '') {
const root = parseXml(source)
const landmarks = []
if (!root) return landmarks
for (const top of childrenNamed(root, 'parent')) {
// 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 || ''
if (name === '') continue
out.push({
facet,
name,
group: path.length > 0 ? path[path.length - 1] : name,
path: [...path],
x: toInt(child.attrs.x),
y: toInt(child.attrs.y),
z: toInt(child.attrs.z),
})
}
for (const parent of childrenNamed(node, 'parent')) {
const name = parent.attrs.name || ''
walkLocations(parent, facet, name === '' ? path : [...path, name], out)
}
}
// ── Config/ChampionSpawns.xml ──────────────────────────────────────────────
/**
* Parse `Config/ChampionSpawns.xml` into champion altar records.
*
* This is the shard's *configured* champion roster — which altars exist, where,
* and which type each is pinned to. It is static content and distinct from the
* live `champ.update` feed the bridge already carries: this says "there is an
* Unholy Terror altar in Deceit", the feed says "it is on level 3 right now".
*
* A spawn with no `type` is randomised on every activation, which the site must
* render as "random" rather than as an empty type.
*/
function parseChampions(source) {
const root = parseXml(source)
const champions = []
if (!root) return champions
for (const spawnNode of childrenNamed(root, 'spawn')) {
const location = childrenNamed(spawnNode, 'location')[0]
const attrs = location ? location.attrs : {}
champions.push({
name: spawnNode.attrs.name || '',
group: spawnNode.attrs.group || '',
type: spawnNode.attrs.type || '',
randomType: !spawnNode.attrs.type,
facet: (attrs.map || '').trim(),
x: toInt(attrs.x),
y: toInt(attrs.y),
z: toInt(attrs.z),
radius: toInt(attrs.radius),
})
}
return champions
}
// ── Placement ──────────────────────────────────────────────────────────────
const DEFAULT_LANDMARK_RADIUS = 200
function inRect(x, y, rect) {
return (
x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height
)
}
function rectArea(rect) {
return Math.max(1, rect.width) * Math.max(1, rect.height)
}
/**
* Group parsed regions and landmarks by facet once, so the per-point resolve
* below is a scan of one facet instead of the whole world. With ~6,500 points
* and a few thousand rects this stays comfortably sub-second; there is no need
* for a spatial index and none is worth the complexity.
*/
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) => {
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)
return byFacet
}
/**
* Turn a raw coordinate into a human place name.
*
* This is the transform the whole atlas exists for: it is what makes a row read
* "Lizardman — Despise, Felucca" instead of "Lizardman — 5411, 1234".
*
* Resolution order:
* 1. The highest-`priority` named region whose rect contains the point. Ties
* break toward the SMALLEST rect, so a specific room inside a dungeon wins
* over the dungeon-wide rect it sits in.
* 2. Otherwise the nearest landmark within `landmarkRadius` tiles, labelled by
* its group ("Covetous"), not the individual marker ("Level 1").
* 3. Otherwise "Wilderness". The radius cap is what keeps step 3 reachable —
* without it the nearest landmark is always *some* landmark, however far,
* and open countryside would get labelled with a dungeon on the far side
* of the map.
*/
function resolveRegion(x, y, facetName, index, options = {}) {
const radius = options.landmarkRadius ?? DEFAULT_LANDMARK_RADIUS
const bucket = index.get(facetKey(facetName))
const result = { region: null, landmark: null, label: 'Wilderness' }
if (!bucket) return result
let best = null
let bestPriority = -Infinity
let bestArea = Infinity
for (const region of bucket.regions) {
for (const rect of region.rects) {
if (!inRect(x, y, rect)) continue
const area = rectArea(rect)
if (region.priority > bestPriority || (region.priority === bestPriority && area < bestArea)) {
best = region
bestPriority = region.priority
bestArea = area
}
}
}
if (best) {
result.region = best.name
result.label = best.name
return result
}
let nearest = null
let nearestDistance = Infinity
const limit = radius * radius
for (const landmark of bucket.landmarks) {
const dx = landmark.x - x
const dy = landmark.y - y
const distance = dx * dx + dy * dy
if (distance < nearestDistance) {
nearest = landmark
nearestDistance = distance
}
}
if (nearest && nearestDistance <= limit) {
result.landmark = nearest.group || nearest.name
result.label = result.landmark
}
return result
}
module.exports = {
parseXml,
parseObjects2,
parsePoints,
parseRegions,
parseLocations,
parseChampions,
buildPlacementIndex,
resolveRegion,
facetKey,
buildFacetIndex,
resolveFacetName,
slugify,
decodeEntities,
DEFAULT_LANDMARK_RADIUS,
}

View 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,
}

View File

@@ -0,0 +1,563 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const {
parseXml,
parseObjects2,
parsePoints,
parseRegions,
parseLocations,
parseChampions,
buildPlacementIndex,
resolveRegion,
facetKey,
buildFacetIndex,
resolveFacetName,
slugify,
decodeEntities,
} = require('../src/utils/spawnAtlasParse')
// These parsers are pure and fs-free precisely so this suite can run in CI,
// where there is no ServUO tree. Every fixture below is a literal excerpt of a
// real shard file, trimmed — not invented shapes.
// ── parseObjects2 ──────────────────────────────────────────────────────────
test('parseObjects2: single type', () => {
const types = parseObjects2('Jacob:MX=1:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1')
assert.deepEqual(types, [{ type: 'Jacob', max: 1 }])
})
test('parseObjects2: splits six types on :OBJ= and keeps each MX', () => {
// Verbatim from trammel.xml — the case that a naive split(':') destroys.
const raw =
'Gazer:MX=1:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1' +
':OBJ=Giantspider:MX=1:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1' +
':OBJ=Harpy:MX=1:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1' +
':OBJ=Headlessone:MX=1:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1' +
':OBJ=Lizardman:MX=3:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1' +
':OBJ=Mongbat:MX=1:SB=0:RT=0:TO=0:KL=0:RK=0:CA=1:DN=-1:DX=-1:SP=1:PR=-1'
const types = parseObjects2(raw)
assert.equal(types.length, 6)
assert.deepEqual(
types.map((t) => t.type),
['Gazer', 'Giantspider', 'Harpy', 'Headlessone', 'Lizardman', 'Mongbat'],
)
// MX is per type, not per spawner: the lizardman entry carries 3.
assert.equal(types.find((t) => t.type === 'Lizardman').max, 3)
assert.equal(types.find((t) => t.type === 'Gazer').max, 1)
})
test('parseObjects2: empty and whitespace values yield no types', () => {
assert.deepEqual(parseObjects2(''), [])
assert.deepEqual(parseObjects2(' '), [])
assert.deepEqual(parseObjects2(null), [])
assert.deepEqual(parseObjects2(undefined), [])
})
test('parseObjects2: strips XmlSpawner property directives after "/"', () => {
// Left in place these become creatures that do not exist.
assert.deepEqual(parseObjects2('Agralem/Name/Agralem:MX=1'), [{ type: 'Agralem', max: 1 }])
assert.deepEqual(parseObjects2('alchemist/z/-50:MX=1'), [{ type: 'alchemist', max: 1 }])
assert.deepEqual(parseObjects2('GargishRefugee/hue/34532'), [
{ type: 'GargishRefugee', max: 1 },
])
})
test('parseObjects2: strips argument lists after ","', () => {
assert.deepEqual(parseObjects2('Fairy,{RND,4,8}:MX=1'), [{ type: 'Fairy', max: 1 }])
assert.deepEqual(parseObjects2('GargishRouser,1'), [{ type: 'GargishRouser', max: 1 }])
assert.deepEqual(parseObjects2('greatape,true'), [{ type: 'greatape', max: 1 }])
})
test('parseObjects2: a directive-laden token slugs the same as the bare one', () => {
// The bug this closes: `Fairy` and `Fairy,{RND,4,8}` slugged apart and showed
// as two different creatures on the same page.
const bare = parseObjects2('Fairy:MX=1')[0]
const decorated = parseObjects2('Fairy,{RND,4,8}:MX=1')[0]
assert.equal(slugify(decorated.type), slugify(bare.type))
})
test('parseObjects2: strips a long EQUIP directive chain containing "<" and ">"', () => {
const raw =
'xmlquestnpc/UNEQUIP,Innertorso/UNEQUIP,MiddleTorso/EQUIP/<robe/loottype/blessed' +
'/itemid/8259>/blessed/true/name/lord blackthorne/z/:MX=1'
assert.deepEqual(parseObjects2(raw), [{ type: 'xmlquestnpc', max: 1 }])
})
test('parseObjects2: a token that is only a directive yields nothing', () => {
assert.deepEqual(parseObjects2('/Name/Foo:MX=1'), [])
assert.deepEqual(parseObjects2(',1:MX=1'), [])
})
test('parseObjects2: a type with no MX token defaults to 1', () => {
assert.deepEqual(parseObjects2('Orc'), [{ type: 'Orc', max: 1 }])
assert.deepEqual(parseObjects2('Orc:SB=0:RT=0'), [{ type: 'Orc', max: 1 }])
})
// ── parsePoints ────────────────────────────────────────────────────────────
const POINTS_XML = `<Spawns>
<Points>
<Name>CovetousSpawner26</Name>
<UniqueId>001a34e5-0efa-46de-9c93-b6a163d96370</UniqueId>
<Map>Trammel</Map>
<X>5412</X>
<Y>1970</Y>
<Width>10</Width>
<Height>10</Height>
<Range>5</Range>
<MaxCount>3</MaxCount>
<MinDelay>5</MinDelay>
<MaxDelay>10</MaxDelay>
<ProximityTriggerSound>500</ProximityTriggerSound>
<TODStart>0</TODStart>
<TODEnd>0</TODEnd>
<TODMode>0</TODMode>
<IsRunning>True</IsRunning>
<Objects2>Lizardman:MX=3:SB=0</Objects2>
</Points>
<Points>
<Name>Disabled</Name>
<Map>Felucca</Map>
<X>100</X>
<Y>200</Y>
<MaxCount>1</MaxCount>
<IsRunning>False</IsRunning>
<Objects2>Orc:MX=1</Objects2>
</Points>
</Spawns>`
test('parsePoints: reads the kept fields and drops the rest', () => {
const points = parsePoints(POINTS_XML)
assert.equal(points.length, 2)
const covetous = points[0]
assert.equal(covetous.name, 'CovetousSpawner26')
assert.equal(covetous.facet, 'Trammel')
assert.equal(covetous.x, 5412)
assert.equal(covetous.y, 1970)
assert.equal(covetous.width, 10)
assert.equal(covetous.range, 5)
assert.equal(covetous.maxCount, 3)
assert.equal(covetous.minDelay, 5)
assert.equal(covetous.maxDelay, 10)
assert.deepEqual(covetous.types, [{ type: 'Lizardman', max: 3 }])
// Dropped fields must not survive into the artifact — this is what keeps it
// under 1 MB.
assert.equal(covetous.uniqueId, undefined)
assert.equal(covetous.proximityTriggerSound, undefined)
})
test('parsePoints: IsRunning is parsed so the build can drop dead spawners', () => {
const points = parsePoints(POINTS_XML)
assert.equal(points[0].running, true)
assert.equal(points[1].running, false)
})
test('parsePoints: facet comes from <Map>, never the file name', () => {
// Eodon.xml holds TerMur points; a file-name assumption would mislabel every
// one of them.
const points = parsePoints(
'<Spawns><Points><Name>a</Name><Map>TerMur</Map><X>1</X><Y>2</Y></Points></Spawns>',
)
assert.equal(points[0].facet, 'TerMur')
})
test('parsePoints: a record with no <Map> is skipped rather than misfiled', () => {
const points = parsePoints('<Spawns><Points><Name>a</Name><X>1</X><Y>2</Y></Points></Spawns>')
assert.deepEqual(points, [])
})
test('parsePoints: empty document yields no points', () => {
assert.deepEqual(parsePoints('<Spawns></Spawns>'), [])
assert.deepEqual(parsePoints(''), [])
})
// ── parseRegions ───────────────────────────────────────────────────────────
const REGIONS_XML = `<?xml version="1.0" encoding="utf-8"?>
<ServerRegions>
<Facet name="Felucca">
<region type="GuardedRegion" priority="50" name="Moongates">
<!-- britain -->
<rect x="1330" y="1991" width="13" height="13" />
<rect x="761" y="741" width="19" height="21" />
</region>
<region type="MondainRegion" priority="50" name="Prism of Light">
<rect x="6400" y="0" width="221" height="255" />
<go x="6474" y="188" z="0" />
<music name="Dungeon9" />
<region type="CrystalField" name="Crystal Field">
<rect x="6506" y="83" width="7" height="7" />
<zrange min="-4" />
</region>
<region type="IcyRiver">
<rect x="6576" y="73" width="10" height="31" />
</region>
</region>
<region type="TownRegion" priority="10" name="Music Only">
<music name="Britain" />
</region>
</Facet>
</ServerRegions>`
test('parseRegions: flattens nested regions and collects rects', () => {
const regions = parseRegions(REGIONS_XML)
const byName = new Map(regions.map((r) => [r.name, r]))
assert.ok(byName.has('Moongates'))
assert.ok(byName.has('Prism of Light'))
assert.equal(byName.get('Moongates').rects.length, 2)
assert.deepEqual(byName.get('Moongates').rects[0], {
x: 1330,
y: 1991,
width: 13,
height: 13,
})
assert.equal(byName.get('Moongates').facet, 'Felucca')
assert.equal(byName.get('Moongates').type, 'GuardedRegion')
})
test('parseRegions: a nested child records its parent', () => {
const regions = parseRegions(REGIONS_XML)
const crystal = regions.find((r) => r.name === 'Crystal Field')
assert.ok(crystal, 'the nested named region should be indexed')
assert.equal(crystal.parent, 'Prism of Light')
assert.equal(crystal.facet, 'Felucca')
})
test('parseRegions: a child with no priority inherits its parent', () => {
// Defaulting to 0 instead would sort this specific room below every
// top-level region that contains it.
const crystal = parseRegions(REGIONS_XML).find((r) => r.name === 'Crystal Field')
assert.equal(crystal.priority, 50)
})
test('parseRegions: unnamed regions are skipped but still walked', () => {
const regions = parseRegions(REGIONS_XML)
// IcyRiver has a type but no name — it cannot label anything.
assert.equal(regions.some((r) => r.type === 'IcyRiver'), false)
})
test('parseRegions: a named region with no rects is not indexed', () => {
// It can never contain a point, so indexing it only costs scan time.
assert.equal(parseRegions(REGIONS_XML).some((r) => r.name === 'Music Only'), false)
})
// ── parseLocations ─────────────────────────────────────────────────────────
const LOCATIONS_XML = `<?xml version="1.0" encoding="utf-8" standalone="yes" ?>
<places>
<parent name="Trammel">
<parent name="Dungeons">
<parent name="Covetous">
<child name="Entrance" x="2499" y="919" z="0" />
<child name="Level 1" x="5456" y="1863" z="0" />
</parent>
<parent name="Despise">
<child name="Level 3" x="5407" y="859" z="45" />
</parent>
</parent>
</parent>
</places>`
test('parseLocations: flattens to points carrying their group', () => {
const landmarks = parseLocations(LOCATIONS_XML)
assert.equal(landmarks.length, 3)
const level1 = landmarks.find((l) => l.name === 'Level 1')
assert.equal(level1.x, 5456)
assert.equal(level1.y, 1863)
assert.equal(level1.z, 0)
assert.equal(level1.facet, 'Trammel')
// "Covetous" is the useful label, not "Level 1".
assert.equal(level1.group, 'Covetous')
// The facet-level parent is dropped from the path.
assert.deepEqual(level1.path, ['Dungeons', 'Covetous'])
})
// ── Facet canonicalisation ─────────────────────────────────────────────────
// 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('facetKey: distinct facets keep distinct keys', () => {
assert.notEqual(facetKey('Felucca'), facetKey('Trammel'))
})
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>Sosaria</Map><X>1</X><Y>2</Y></Points></Spawns>',
)
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 ─────────────────────────────────────────────────────────
const CHAMPIONS_XML = `<?xml version="1.0" encoding="UTF-8"?>
<championSystem>
<!-- comment describing the schema -->
<spawn name="Deceit" group="FelDungeons" type="UnholyTerror">
<location x="5178" y="708" z="20" map="Felucca" radius="60" />
</spawn>
<spawn name="Wandering" group="FelDungeons">
<location x="100" y="200" z="0" map="Felucca" radius="40" />
</spawn>
</championSystem>`
test('parseChampions: reads altar name, type and location', () => {
const champs = parseChampions(CHAMPIONS_XML)
assert.equal(champs.length, 2)
assert.deepEqual(champs[0], {
name: 'Deceit',
group: 'FelDungeons',
type: 'UnholyTerror',
randomType: false,
facet: 'Felucca',
x: 5178,
y: 708,
z: 20,
radius: 60,
})
})
test('parseChampions: a spawn with no type is flagged random, not blank', () => {
const champs = parseChampions(CHAMPIONS_XML)
assert.equal(champs[1].randomType, true)
assert.equal(champs[1].type, '')
})
// ── resolveRegion ──────────────────────────────────────────────────────────
function fixtureIndex() {
const regions = [
{
facet: 'Felucca',
name: 'Britain',
type: 'TownRegion',
priority: 10,
parent: null,
rects: [{ x: 1000, y: 1000, width: 500, height: 500 }],
},
{
facet: 'Felucca',
name: 'Britain Bank',
type: 'TownRegion',
priority: 50,
parent: 'Britain',
rects: [{ x: 1400, y: 1400, width: 20, height: 20 }],
},
{
facet: 'Felucca',
name: 'Wide Low Priority',
type: 'TownRegion',
priority: 10,
parent: null,
rects: [{ x: 1000, y: 1000, width: 2000, height: 2000 }],
},
]
const landmarks = [
{ facet: 'Felucca', name: 'Level 1', group: 'Covetous', path: [], x: 5000, y: 5000, z: 0 },
{ facet: 'Felucca', name: 'Far Away', group: 'Vesper', path: [], x: 9000, y: 9000, z: 0 },
]
return buildPlacementIndex(regions, landmarks)
}
test('resolveRegion: a contained point takes the region name', () => {
const result = resolveRegion(1100, 1100, 'Felucca', fixtureIndex())
assert.equal(result.region, 'Britain')
assert.equal(result.label, 'Britain')
assert.equal(result.landmark, null)
})
test('resolveRegion: higher priority wins over a containing region', () => {
const result = resolveRegion(1410, 1410, 'Felucca', fixtureIndex())
assert.equal(result.region, 'Britain Bank')
})
test('resolveRegion: equal priority breaks toward the smaller rect', () => {
// Both "Britain" (500x500) and "Wide Low Priority" (2000x2000) contain this
// point at priority 10; the specific one must win.
const result = resolveRegion(1200, 1200, 'Felucca', fixtureIndex())
assert.equal(result.region, 'Britain')
})
test('resolveRegion: rects are half-open — the far edge is outside', () => {
const index = fixtureIndex()
// Britain spans x 1000..1499. 1499 is in, 1500 belongs to the next region.
assert.equal(resolveRegion(1499, 1499, 'Felucca', index).region, 'Britain')
assert.equal(resolveRegion(1500, 1500, 'Felucca', index).region, 'Wide Low Priority')
})
test('resolveRegion: falls back to the nearest landmark group', () => {
const result = resolveRegion(5050, 5050, 'Felucca', fixtureIndex())
assert.equal(result.region, null)
assert.equal(result.landmark, 'Covetous')
assert.equal(result.label, 'Covetous')
})
test('resolveRegion: a landmark beyond the radius yields Wilderness', () => {
// Without the radius cap the nearest landmark is always *some* landmark, and
// open countryside would get labelled with a dungeon across the map.
const result = resolveRegion(7000, 7000, 'Felucca', fixtureIndex())
assert.equal(result.landmark, null)
assert.equal(result.label, 'Wilderness')
})
test('resolveRegion: the radius is configurable', () => {
const wide = resolveRegion(7000, 7000, 'Felucca', fixtureIndex(), { landmarkRadius: 5000 })
assert.equal(wide.label, 'Covetous')
})
test('resolveRegion: an unknown facet degrades to Wilderness, not a throw', () => {
const result = resolveRegion(1100, 1100, 'Malas', fixtureIndex())
assert.equal(result.label, 'Wilderness')
assert.equal(result.region, null)
})
test('resolveRegion: does not leak across facets', () => {
const index = buildPlacementIndex(
[
{
facet: 'Trammel',
name: 'Britain',
type: 'TownRegion',
priority: 10,
parent: null,
rects: [{ x: 1000, y: 1000, width: 500, height: 500 }],
},
],
[],
)
assert.equal(resolveRegion(1100, 1100, 'Trammel', index).region, 'Britain')
assert.equal(resolveRegion(1100, 1100, 'Felucca', index).region, null)
})
// ── Tokenizer edge cases ───────────────────────────────────────────────────
test('parseXml: skips comments, declarations and DOCTYPE', () => {
const root = parseXml(
'<?xml version="1.0"?><!DOCTYPE r><r><!-- <fake a="b"/> --><a x="1"/></r>',
)
assert.equal(root.name, 'r')
assert.equal(root.children.length, 1)
assert.equal(root.children[0].name, 'a')
assert.equal(root.children[0].attrs.x, '1')
})
test('parseXml: a ">" inside an attribute value does not end the tag', () => {
const root = parseXml('<r><a name="1 > 0" b="2"/></r>')
assert.equal(root.children[0].attrs.name, '1 > 0')
assert.equal(root.children[0].attrs.b, '2')
})
test('parseXml: single-quoted attributes are read', () => {
const root = parseXml("<r><a name='Mondain' /></r>")
assert.equal(root.children[0].attrs.name, 'Mondain')
})
test('parseXml: a stray closing tag is ignored, not fatal', () => {
// Hand-maintained shard config: one malformed element should degrade to a
// missing element, not abort an otherwise good build.
const root = parseXml('<r><a/></b><c/></r>')
assert.equal(root.name, 'r')
assert.deepEqual(root.children.map((n) => n.name), ['a', 'c'])
})
test('parseXml: empty or element-free input yields null', () => {
assert.equal(parseXml(''), null)
assert.equal(parseXml('<!-- only a comment -->'), null)
})
test('decodeEntities: named, numeric and hex refs', () => {
assert.equal(decodeEntities("Mondain&apos;s Legacy"), "Mondain's Legacy")
assert.equal(decodeEntities('a &amp; b'), 'a & b')
assert.equal(decodeEntities('&lt;tag&gt;'), '<tag>')
assert.equal(decodeEntities('&#65;&#x42;'), 'AB')
// An unknown entity is left alone rather than silently eaten.
assert.equal(decodeEntities('&nosuch;'), '&nosuch;')
})
test('parseXml: decodes entities in attribute values', () => {
const root = parseXml('<r><region name="Mondain&apos;s Legacy" /></r>')
assert.equal(root.children[0].attrs.name, "Mondain's Legacy")
})
// ── slugify ────────────────────────────────────────────────────────────────
test('slugify: produces URL-safe keys', () => {
assert.equal(slugify('Lizardman'), 'lizardman')
assert.equal(slugify('Giant Spider'), 'giant-spider')
assert.equal(slugify("Mondain's Legacy"), 'mondain-s-legacy')
assert.equal(slugify(' Orc '), 'orc')
})

View 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 types 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'])
})