feat(shard)!: Protocol 3.0 cutover — visibility framework, spawn atlas, marketplace #118
7
.gitignore
vendored
7
.gitignore
vendored
@@ -21,6 +21,13 @@ uploads/
|
|||||||
server/logs/
|
server/logs/
|
||||||
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 material (extracted from the provided archives)
|
||||||
_reference/
|
_reference/
|
||||||
|
|
||||||
|
|||||||
24
server/db/data/spawnAtlas.art.example.json
Normal file
24
server/db/data/spawnAtlas.art.example.json
Normal 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"
|
||||||
|
}
|
||||||
@@ -1040,6 +1040,156 @@ CREATE TABLE IF NOT EXISTS announce_jobs (
|
|||||||
INDEX idx_announce_due_discord (discord_status, discord_next_attempt_at)
|
INDEX idx_announce_due_discord (discord_status, discord_next_attempt_at)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) 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
|
-- 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
|
-- 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.
|
-- these columns from the CREATE TABLE above; existing installs get them here.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"seed": "node db/seed.js",
|
"seed": "node db/seed.js",
|
||||||
"swagger": "node swagger/swagger.js",
|
"swagger": "node swagger/swagger.js",
|
||||||
"routes:manifest": "node scripts/routeManifest.js",
|
"routes:manifest": "node scripts/routeManifest.js",
|
||||||
|
"atlas:import": "node scripts/importSpawnAtlas.js",
|
||||||
"test": "node --test"
|
"test": "node --test"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
|
|||||||
127
server/scripts/importSpawnAtlas.js
Normal file
127
server/scripts/importSpawnAtlas.js
Normal 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 }
|
||||||
202
server/src/model/shardAtlas/shardAtlas.db.js
Normal file
202
server/src/model/shardAtlas/shardAtlas.db.js
Normal 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,
|
||||||
|
}
|
||||||
296
server/src/model/shardAtlas/shardAtlas.model.js
Normal file
296
server/src/model/shardAtlas/shardAtlas.model.js
Normal 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,
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed')
|
|||||||
const settings = require('./model/settings/settings.model')
|
const settings = require('./model/settings/settings.model')
|
||||||
const revokedSessions = require('./model/revokedSessions/revokedSessions.model')
|
const revokedSessions = require('./model/revokedSessions/revokedSessions.model')
|
||||||
const mobileAuthBridge = require('./model/mobileAuthBridge/mobileAuthBridge.model')
|
const mobileAuthBridge = require('./model/mobileAuthBridge/mobileAuthBridge.model')
|
||||||
|
const shardAtlas = require('./model/shardAtlas/shardAtlas.model')
|
||||||
const createLogger = require('./utils/logger')
|
const createLogger = require('./utils/logger')
|
||||||
const { evaluateBotInternalKey } = require('./utils/botInternalKey')
|
const { evaluateBotInternalKey } = require('./utils/botInternalKey')
|
||||||
const brand = require('./config/brand')
|
const brand = require('./config/brand')
|
||||||
@@ -77,6 +78,17 @@ async function start() {
|
|||||||
log.warn('mobile-auth-bridge prune failed', { error: err.message })
|
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')
|
const mode = await settings.get('site_mode')
|
||||||
log.info(`site mode: ${String(mode || 'live').toUpperCase()}`)
|
log.info(`site mode: ${String(mode || 'live').toUpperCase()}`)
|
||||||
|
|
||||||
|
|||||||
664
server/src/utils/spawnAtlasParse.js
Normal file
664
server/src/utils/spawnAtlasParse.js
Normal 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,
|
||||||
|
}
|
||||||
319
server/src/utils/spawnAtlasSource.js
Normal file
319
server/src/utils/spawnAtlasSource.js
Normal 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,
|
||||||
|
}
|
||||||
563
server/test/spawnAtlas.parse.test.js
Normal file
563
server/test/spawnAtlas.parse.test.js
Normal 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's Legacy"), "Mondain's Legacy")
|
||||||
|
assert.equal(decodeEntities('a & b'), 'a & b')
|
||||||
|
assert.equal(decodeEntities('<tag>'), '<tag>')
|
||||||
|
assert.equal(decodeEntities('AB'), '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'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')
|
||||||
|
})
|
||||||
375
server/test/spawnAtlas.source.test.js
Normal file
375
server/test/spawnAtlas.source.test.js
Normal 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 type’s 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'])
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user