feat(shard)!: Protocol 3.0 cutover — visibility framework, spawn atlas, marketplace #118
8
.gitignore
vendored
8
.gitignore
vendored
@@ -21,6 +21,14 @@ uploads/
|
||||
server/logs/
|
||||
logs/
|
||||
|
||||
# Operator-supplied spawn atlas artwork. The atlas artifact itself
|
||||
# (db/data/spawnAtlas.*.json) IS committed, but creature art is not: sprites are
|
||||
# extracted from the operator's own UO client .mul/.uop files and are theirs, not
|
||||
# ours to redistribute. The art map is generated locally; the images it points at
|
||||
# live under server/uploads/atlas/, already ignored above.
|
||||
# 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/
|
||||
|
||||
|
||||
1
server/db/data/spawnAtlas.Felucca.json
Normal file
1
server/db/data/spawnAtlas.Felucca.json
Normal file
File diff suppressed because one or more lines are too long
1
server/db/data/spawnAtlas.Ilshenar.json
Normal file
1
server/db/data/spawnAtlas.Ilshenar.json
Normal file
File diff suppressed because one or more lines are too long
1
server/db/data/spawnAtlas.Malas.json
Normal file
1
server/db/data/spawnAtlas.Malas.json
Normal file
File diff suppressed because one or more lines are too long
1
server/db/data/spawnAtlas.TerMur.json
Normal file
1
server/db/data/spawnAtlas.TerMur.json
Normal file
File diff suppressed because one or more lines are too long
1
server/db/data/spawnAtlas.Tokuno.json
Normal file
1
server/db/data/spawnAtlas.Tokuno.json
Normal file
File diff suppressed because one or more lines are too long
1
server/db/data/spawnAtlas.Trammel.json
Normal file
1
server/db/data/spawnAtlas.Trammel.json
Normal file
File diff suppressed because one or more lines are too long
1
server/db/data/spawnAtlas.index.json
Normal file
1
server/db/data/spawnAtlas.index.json
Normal file
File diff suppressed because one or more lines are too long
100
server/db/data/spawnAtlas.meta.json
Normal file
100
server/db/data/spawnAtlas.meta.json
Normal file
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"version": 1,
|
||||
"generatedAt": "2026-07-28T21:05:07.987Z",
|
||||
"counts": {
|
||||
"facets": 6,
|
||||
"points": 6455,
|
||||
"pointsDisabled": 10,
|
||||
"creatures": 800,
|
||||
"regions": 387,
|
||||
"landmarks": 558,
|
||||
"champions": 25,
|
||||
"unresolvedPoints": 1086
|
||||
},
|
||||
"source": {
|
||||
"Data/Regions.xml": {
|
||||
"bytes": 129008,
|
||||
"sha256": "05c505b3806a3ee617ce503f6cb9ccf1d0ebadca0821d8d9346587d36a790ed2"
|
||||
},
|
||||
"Data/Locations/felucca.xml": {
|
||||
"bytes": 12481,
|
||||
"sha256": "cfd4c49a808c2bf1f904722740fe8dd3e10311ec9e832bd74fc17dcb171ad108"
|
||||
},
|
||||
"Data/Locations/ilshenar.xml": {
|
||||
"bytes": 4279,
|
||||
"sha256": "92a85e2ad894d4c428b0ba24c1d4547b585e47086aeb4b8119d24bc1a74f7fa0"
|
||||
},
|
||||
"Data/Locations/malas.xml": {
|
||||
"bytes": 3903,
|
||||
"sha256": "c7dafa6a958c8c7b7f0e7a448c679d90cb2f736a878c60b58057a4d1105b6d69"
|
||||
},
|
||||
"Data/Locations/termur.xml": {
|
||||
"bytes": 3568,
|
||||
"sha256": "065c8f4077eb77d6c65c089a8ef9d422a7f197cb6002859cb4296960c3619da5"
|
||||
},
|
||||
"Data/Locations/tokuno.xml": {
|
||||
"bytes": 1546,
|
||||
"sha256": "7c70950555fc6f983b6db6c85a569621ba02cbbdc42ab3f76bbaeff795cc1bb8"
|
||||
},
|
||||
"Data/Locations/trammel.xml": {
|
||||
"bytes": 11772,
|
||||
"sha256": "fd5cfc941bf6bf5dc685d6753d71fee869df3063688d060ded59cd4567531e57"
|
||||
},
|
||||
"Spawns/Eodon.xml": {
|
||||
"bytes": 100654,
|
||||
"sha256": "632053fe7ed1d2fbcb03c5b18d9bd13a700d1f66300574d12603202c56c8e4c3"
|
||||
},
|
||||
"Spawns/GravewaterLake.xml": {
|
||||
"bytes": 50657,
|
||||
"sha256": "4b9f48cc18c176382026e04eb8dbcf2a080afd1386fee47fb3fea469cad4fdac"
|
||||
},
|
||||
"Spawns/TheExodusEncounterQuest.xml": {
|
||||
"bytes": 25802,
|
||||
"sha256": "167498c8f5c8f3804a74046c0e1e6ebb7db709c5bd1cdac5623042abf9e694ff"
|
||||
},
|
||||
"Spawns/TreasuresOfKotl.xml": {
|
||||
"bytes": 8665,
|
||||
"sha256": "8e0ed17ef8942db7275719ce82989018d74caf8a382e86c4711c123ef6a913db"
|
||||
},
|
||||
"Spawns/felucca.xml": {
|
||||
"bytes": 3749158,
|
||||
"sha256": "eb2ed7de2623d3b95262eba9fffaddbc2dca24f53680e970e4e5f809def32891"
|
||||
},
|
||||
"Spawns/ilshenar.xml": {
|
||||
"bytes": 705359,
|
||||
"sha256": "e6b7cf796af2b72221ed2df7d718111f38055c170cdd463b222afb747048df5d"
|
||||
},
|
||||
"Spawns/malas.xml": {
|
||||
"bytes": 445464,
|
||||
"sha256": "2eb69d0006f507e027f8afc3e0e02b70a4caf32ca6e3ecdfef788c41a0907a3b"
|
||||
},
|
||||
"Spawns/solenhives.xml": {
|
||||
"bytes": 101524,
|
||||
"sha256": "0b73ae59c2058cd3354d5b11bec133d6f374eed43f9b818353640240af159ed9"
|
||||
},
|
||||
"Spawns/termur.xml": {
|
||||
"bytes": 187184,
|
||||
"sha256": "d6351e7879845f412d460433dad7438ae0795f1c38f371c78ec3dd4829b42eac"
|
||||
},
|
||||
"Spawns/tokuno.xml": {
|
||||
"bytes": 732784,
|
||||
"sha256": "f03fd70c301b97457b3e9732d6fef58c009eb6c498d15109ae0334eed133e860"
|
||||
},
|
||||
"Spawns/trammel.xml": {
|
||||
"bytes": 4226171,
|
||||
"sha256": "29d1e9d2fa5ce64ac0cc6396195ff3bdb464a140f6fb4ac61f1459174a2b9995"
|
||||
},
|
||||
"Spawns/twistedweald.xml": {
|
||||
"bytes": 35456,
|
||||
"sha256": "927e8f7177e1269231ea43a595fc156d9d94e4c3f2bfc1cc7574366d3aa495f8"
|
||||
},
|
||||
"Spawns/underworld.xml": {
|
||||
"bytes": 76729,
|
||||
"sha256": "e0c813331a52808b9bd93fd252cfcc8c967397ab8b0ead0e2c4e768db2f5baf6"
|
||||
},
|
||||
"Config/ChampionSpawns.xml": {
|
||||
"bytes": 4838,
|
||||
"sha256": "4e6798ae552d707e8f8a07ec611ebb7aca9046b8385017cad01ad0d397ca1171"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1022,6 +1022,132 @@ CREATE TABLE IF NOT EXISTS announce_jobs (
|
||||
INDEX idx_announce_due_discord (discord_status, discord_next_attempt_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- ── Spawn atlas (Protocol 3.0 Part C) ───────────────────────────────────────
|
||||
-- Static shard CONTENT, not live shard state: what spawns where, which regions
|
||||
-- and landmarks exist, and which champion altars are configured. Nothing here
|
||||
-- comes from the sidecar — it is imported from a committed artifact built off a
|
||||
-- ServUO tree by `npm run atlas:build` (see docs/website/SPAWN_ATLAS.md), so
|
||||
-- these tables stay populated whether the shard is up or not.
|
||||
--
|
||||
-- Every table is import-owned: `npm run atlas:import` TRUNCATEs and reloads them
|
||||
-- in one transaction. Nothing else may write here, and nothing else may hold a
|
||||
-- foreign key to them. No FKs at all, consistent with every other shard_* table.
|
||||
|
||||
-- One row per spawnable type, aggregated across the world. `total` is the sum of
|
||||
-- each type's own MX across every point that spawns it (how many exist at once);
|
||||
-- `facets` is a per-facet point count, so the facet filter and "where does this
|
||||
-- live" both answer without touching shard_spawn_points.
|
||||
CREATE TABLE IF NOT EXISTS shard_spawn_creatures (
|
||||
slug VARCHAR(120) NOT NULL PRIMARY KEY, -- slugified class name; the /atlas/:slug key
|
||||
name VARCHAR(120) NOT NULL, -- display spelling chosen by the build
|
||||
total INT NOT NULL DEFAULT 0,
|
||||
points INT NOT NULL DEFAULT 0,
|
||||
facets JSON NULL, -- { "Felucca": 171, "Trammel": 160, ... }
|
||||
-- Operator-supplied artwork, always NULL on a fresh import. The repo ships no
|
||||
-- creature art: sprites live in the operator's own client .mul/.uop files and
|
||||
-- are theirs to extract and place under uploads/atlas/. The UI renders without
|
||||
-- art when this is NULL, which is the normal case.
|
||||
art VARCHAR(255) NULL,
|
||||
-- Plain INDEX, deliberately NOT FULLTEXT: ~800 rows makes a LIKE scan free,
|
||||
-- and FULLTEXT's min-token-length would break searches for names like "orc".
|
||||
INDEX idx_shard_spawn_creatures_name (name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- One row per spawner. `region`/`landmark` are the resolved place name — the
|
||||
-- point-in-rect transform that turns "5411,1234" into "Despise" — and `label` is
|
||||
-- the resolved display string (region, else landmark, else 'Wilderness').
|
||||
CREATE TABLE IF NOT EXISTS shard_spawn_points (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
facet VARCHAR(40) NOT NULL,
|
||||
name VARCHAR(120) NULL, -- the ServUO spawner's own name
|
||||
x INT NOT NULL,
|
||||
y INT NOT NULL,
|
||||
width INT NOT NULL DEFAULT 0,
|
||||
height INT NOT NULL DEFAULT 0,
|
||||
spawn_range INT NOT NULL DEFAULT 0, -- `range` is reserved in MariaDB
|
||||
max_count INT NOT NULL DEFAULT 0,
|
||||
min_delay INT NOT NULL DEFAULT 0,
|
||||
max_delay INT NOT NULL DEFAULT 0,
|
||||
tod_start INT NOT NULL DEFAULT 0, -- meaningless unless tod_mode <> 0
|
||||
tod_end INT NOT NULL DEFAULT 0,
|
||||
tod_mode INT NOT NULL DEFAULT 0,
|
||||
region VARCHAR(120) NULL,
|
||||
landmark VARCHAR(120) NULL,
|
||||
label VARCHAR(120) NOT NULL DEFAULT 'Wilderness',
|
||||
INDEX idx_shard_spawn_points_facet (facet),
|
||||
INDEX idx_shard_spawn_points_label (label)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- The many-to-many between the two above: one spawner commonly carries several
|
||||
-- types (a single Trammel point spawns six), each with its own max. This is how
|
||||
-- /atlas/creatures/:slug finds the places a creature appears.
|
||||
CREATE TABLE IF NOT EXISTS shard_spawn_point_types (
|
||||
point_id INT NOT NULL,
|
||||
slug VARCHAR(120) NOT NULL, -- → shard_spawn_creatures.slug (no FK)
|
||||
max_count INT NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (point_id, slug),
|
||||
INDEX idx_shard_spawn_point_types_slug (slug)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Named regions from Data/Regions.xml, flattened out of their nesting. `rects`
|
||||
-- holds the region's rectangles; `priority` and rect area are what resolved each
|
||||
-- spawn point at build time, kept here so the admin drift check can re-derive.
|
||||
CREATE TABLE IF NOT EXISTS shard_regions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
facet VARCHAR(40) NOT NULL,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
type VARCHAR(80) NULL, -- ServUO region class
|
||||
priority INT NOT NULL DEFAULT 0,
|
||||
parent VARCHAR(120) NULL, -- enclosing named region, if any
|
||||
rects JSON NULL,
|
||||
INDEX idx_shard_regions_facet (facet),
|
||||
INDEX idx_shard_regions_name (name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Points of interest from Data/Locations/*.xml. `grp` is the innermost enclosing
|
||||
-- parent ("Covetous"), which is the label worth showing — "Covetous" reads
|
||||
-- better than the individual marker "Level 1". (`group` is reserved in SQL.)
|
||||
CREATE TABLE IF NOT EXISTS shard_landmarks (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
facet VARCHAR(40) NOT NULL,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
grp VARCHAR(120) NULL,
|
||||
x INT NOT NULL,
|
||||
y INT NOT NULL,
|
||||
z INT NOT NULL DEFAULT 0,
|
||||
INDEX idx_shard_landmarks_facet (facet),
|
||||
INDEX idx_shard_landmarks_name (name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Configured champion altars from Config/ChampionSpawns.xml. This is static
|
||||
-- roster data ("there is an Unholy Terror altar in Deceit") and is distinct from
|
||||
-- the live champ.update feed in shard_champs ("it is on level 3 right now").
|
||||
CREATE TABLE IF NOT EXISTS shard_champion_spawns (
|
||||
slug VARCHAR(160) NOT NULL PRIMARY KEY, -- facet-name, e.g. "felucca-deceit"
|
||||
name VARCHAR(120) NOT NULL,
|
||||
grp VARCHAR(80) NULL, -- spawn group; one active per group
|
||||
type VARCHAR(80) NULL, -- '' when randomised per activation
|
||||
random_type TINYINT(1) NOT NULL DEFAULT 0,
|
||||
facet VARCHAR(40) NOT NULL,
|
||||
x INT NOT NULL,
|
||||
y INT NOT NULL,
|
||||
z INT NOT NULL DEFAULT 0,
|
||||
radius INT NOT NULL DEFAULT 0,
|
||||
label VARCHAR(120) NULL, -- resolved place name
|
||||
INDEX idx_shard_champion_spawns_facet (facet)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Singleton (id = 1) describing the artifact currently loaded: when it was
|
||||
-- built, its counts, and a sha256 per ServUO source file. The admin drift check
|
||||
-- compares this against db/data/spawnAtlas.meta.json to report when the database
|
||||
-- is behind the committed artifact.
|
||||
CREATE TABLE IF NOT EXISTS shard_atlas_meta (
|
||||
id TINYINT NOT NULL PRIMARY KEY DEFAULT 1,
|
||||
payload JSON NOT NULL,
|
||||
imported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT chk_shard_atlas_meta_singleton CHECK (id = 1)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- Migrations for databases created before the wiki upgrade. Each statement uses
|
||||
-- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get
|
||||
-- these columns from the CREATE TABLE above; existing installs get them here.
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
"seed": "node db/seed.js",
|
||||
"swagger": "node swagger/swagger.js",
|
||||
"routes:manifest": "node scripts/routeManifest.js",
|
||||
"atlas:build": "node scripts/buildSpawnAtlas.js",
|
||||
"atlas:import": "node scripts/importSpawnAtlas.js",
|
||||
"test": "node --test"
|
||||
},
|
||||
"keywords": [
|
||||
|
||||
383
server/scripts/buildSpawnAtlas.js
Normal file
383
server/scripts/buildSpawnAtlas.js
Normal file
@@ -0,0 +1,383 @@
|
||||
#!/usr/bin/env node
|
||||
//
|
||||
// Build the committed spawn atlas artifact from a ServUO tree.
|
||||
//
|
||||
// npm run atlas:build -- --servuo C:\path\to\ServUO [--out db/data]
|
||||
//
|
||||
// This is the ONLY thing in the repo that reads a ServUO tree, and it is
|
||||
// CLI-only by design: the website container has no ServUO tree, and pushing
|
||||
// 10.5 MB of XML through a parser on every boot to produce data that changes
|
||||
// when an operator edits their spawns — that is, almost never — would be pure
|
||||
// waste. The build runs on a machine that has the tree, its output is committed,
|
||||
// and `atlas:import` (which needs no tree) loads it into the database.
|
||||
//
|
||||
// All parsing lives in `src/utils/spawnAtlasParse.js` as pure functions so it
|
||||
// is unit-tested in CI without a ServUO tree. This file is the fs/CLI shell
|
||||
// around it: read, transform, shard, write.
|
||||
//
|
||||
// It writes NO images. Creature art is deliberately not part of the artifact —
|
||||
// sprites live in the operator's own client files (`.mul`/`.uop`) and are theirs
|
||||
// to extract and supply. See `db/data/spawnAtlas.art.example.json` and
|
||||
// docs/website/SPAWN_ATLAS.md for that path; `art` stays NULL without it.
|
||||
|
||||
const crypto = require('crypto')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const {
|
||||
parsePoints,
|
||||
parseRegions,
|
||||
parseLocations,
|
||||
parseChampions,
|
||||
buildPlacementIndex,
|
||||
resolveRegion,
|
||||
normalizeFacet,
|
||||
slugify,
|
||||
} = require('../src/utils/spawnAtlasParse')
|
||||
|
||||
const ARTIFACT_VERSION = 1
|
||||
|
||||
// ── CLI ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = { servuo: '', out: path.join(__dirname, '..', 'db', 'data'), radius: undefined }
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const flag = argv[i]
|
||||
if (flag === '--servuo') args.servuo = argv[++i]
|
||||
else if (flag === '--out') args.out = argv[++i]
|
||||
else if (flag === '--landmark-radius') args.radius = Number(argv[++i])
|
||||
else if (flag === '--help' || flag === '-h') args.help = true
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
const USAGE = `
|
||||
Build the spawn atlas artifact from a ServUO tree.
|
||||
|
||||
node scripts/buildSpawnAtlas.js --servuo <path> [--out <dir>] [--landmark-radius <tiles>]
|
||||
|
||||
--servuo Path to the ServUO server root (the directory holding
|
||||
Spawns/, Data/ and Config/). Required.
|
||||
--out Output directory. Default: server/db/data
|
||||
--landmark-radius Max tile distance for the landmark fallback. Default: 200
|
||||
|
||||
Writes spawnAtlas.index.json plus one spawnAtlas.<facet>.json per facet.
|
||||
Commit the result; load it with "npm run atlas:import".
|
||||
`
|
||||
|
||||
// ── Source files ───────────────────────────────────────────────────────────
|
||||
|
||||
function readIfPresent(file) {
|
||||
try {
|
||||
return fs.readFileSync(file, 'utf8')
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT') return null
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
function sha256(text) {
|
||||
return crypto.createHash('sha256').update(text, 'utf8').digest('hex')
|
||||
}
|
||||
|
||||
function listXml(dir) {
|
||||
try {
|
||||
return fs
|
||||
.readdirSync(dir)
|
||||
.filter((name) => name.toLowerCase().endsWith('.xml'))
|
||||
.sort()
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT') return []
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
// ── Transform ──────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Roll spawn points up into per-type creature rows.
|
||||
*
|
||||
* `total` is the sum of each type's own MX across every point that spawns it —
|
||||
* i.e. how many of this creature the world holds at once, which is the number
|
||||
* worth showing. `facets` is a per-facet point count, used both for the facet
|
||||
* filter and to answer "where does this live" without loading its points.
|
||||
*/
|
||||
function aggregateCreatures(points) {
|
||||
const creatures = new Map()
|
||||
for (const point of points) {
|
||||
for (const entry of point.types) {
|
||||
const slug = slugify(entry.type)
|
||||
if (slug === '') continue
|
||||
let creature = creatures.get(slug)
|
||||
if (!creature) {
|
||||
creature = { slug, name: '', total: 0, points: 0, facets: {}, spellings: new Map() }
|
||||
creatures.set(slug, creature)
|
||||
}
|
||||
creature.total += entry.max
|
||||
creature.points += 1
|
||||
creature.facets[point.facet] = (creature.facets[point.facet] || 0) + 1
|
||||
creature.spellings.set(entry.type, (creature.spellings.get(entry.type) || 0) + 1)
|
||||
}
|
||||
}
|
||||
|
||||
return [...creatures.values()]
|
||||
.map(({ spellings, ...creature }) => ({
|
||||
...creature,
|
||||
name: displayName(spellings),
|
||||
}))
|
||||
.sort((a, b) => a.slug.localeCompare(b.slug))
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose one display spelling for a creature.
|
||||
*
|
||||
* The shard's spawn files are not consistent about case — the same creature is
|
||||
* written `Lizardman` in one file and `lizardman` in another. Slugging collapses
|
||||
* them into one creature correctly, but the display name then depended on
|
||||
* whichever file happened to be read first, which is exactly the kind of thing
|
||||
* that produces a spurious diff in a committed artifact on an unrelated rebuild.
|
||||
*
|
||||
* So: most frequent spelling wins; ties break toward the one with more
|
||||
* capitals (`Lizardman` over `lizardman`), then alphabetically. Fully
|
||||
* deterministic, and independent of file read order.
|
||||
*/
|
||||
function displayName(spellings) {
|
||||
const capitals = (value) => (value.match(/[A-Z]/g) || []).length
|
||||
return [...spellings.entries()].sort((a, b) => {
|
||||
if (b[1] !== a[1]) return b[1] - a[1]
|
||||
const caps = capitals(b[0]) - capitals(a[0])
|
||||
if (caps !== 0) return caps
|
||||
return a[0].localeCompare(b[0])
|
||||
})[0][0]
|
||||
}
|
||||
|
||||
function build(args) {
|
||||
const root = args.servuo
|
||||
if (!fs.existsSync(root)) {
|
||||
throw new Error(`ServUO path does not exist: ${root}`)
|
||||
}
|
||||
|
||||
const source = {}
|
||||
const record = (label, file, text) => {
|
||||
source[label] = { bytes: Buffer.byteLength(text, 'utf8'), sha256: sha256(text) }
|
||||
return text
|
||||
}
|
||||
|
||||
// Regions + landmarks first — the placement index needs both before any
|
||||
// point can be resolved.
|
||||
const regionsPath = path.join(root, 'Data', 'Regions.xml')
|
||||
const regionsXml = readIfPresent(regionsPath)
|
||||
if (regionsXml === null) throw new Error(`Missing required file: ${regionsPath}`)
|
||||
const regions = parseRegions(record('Data/Regions.xml', regionsPath, regionsXml))
|
||||
|
||||
const locationsDir = path.join(root, 'Data', 'Locations')
|
||||
const landmarks = []
|
||||
for (const name of listXml(locationsDir)) {
|
||||
const file = path.join(locationsDir, name)
|
||||
const xml = record(`Data/Locations/${name}`, file, fs.readFileSync(file, 'utf8'))
|
||||
landmarks.push(...parseLocations(xml, path.basename(name, '.xml')))
|
||||
}
|
||||
|
||||
const index = buildPlacementIndex(regions, landmarks)
|
||||
const resolveOpts = args.radius ? { landmarkRadius: args.radius } : {}
|
||||
|
||||
// Spawn points. There are 13 files but only 6 facets — the facet comes from
|
||||
// each record's <Map>, so sharding is driven by the data, not the file names.
|
||||
const spawnsDir = path.join(root, 'Spawns')
|
||||
const spawnFiles = listXml(spawnsDir)
|
||||
if (spawnFiles.length === 0) throw new Error(`No spawn files found in ${spawnsDir}`)
|
||||
|
||||
const rawPoints = []
|
||||
for (const name of spawnFiles) {
|
||||
const file = path.join(spawnsDir, name)
|
||||
const xml = record(`Spawns/${name}`, file, fs.readFileSync(file, 'utf8'))
|
||||
rawPoints.push(...parsePoints(xml))
|
||||
}
|
||||
|
||||
// A spawner switched off in-world produces nothing; advertising it would be a
|
||||
// straight lie to a player planning a hunt.
|
||||
const disabled = rawPoints.filter((point) => !point.running).length
|
||||
const points = rawPoints
|
||||
.filter((point) => point.running)
|
||||
// A spawner with no types is a placeholder — it has nothing to show.
|
||||
.filter((point) => point.types.length > 0)
|
||||
.map((point) => {
|
||||
const placement = resolveRegion(point.x, point.y, point.facet, index, resolveOpts)
|
||||
// The full, honest shape. Compaction happens once, in encodePoint(), at
|
||||
// write time — so everything in between (aggregateCreatures, the facet
|
||||
// grouping, the counts) works on real fields rather than on holes.
|
||||
return {
|
||||
name: point.name,
|
||||
facet: point.facet,
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
width: point.width,
|
||||
height: point.height,
|
||||
range: point.range,
|
||||
maxCount: point.maxCount,
|
||||
minDelay: point.minDelay,
|
||||
maxDelay: point.maxDelay,
|
||||
todStart: point.todStart,
|
||||
todEnd: point.todEnd,
|
||||
todMode: point.todMode,
|
||||
region: placement.region,
|
||||
landmark: placement.landmark,
|
||||
types: point.types,
|
||||
}
|
||||
})
|
||||
|
||||
// Champions are configured altars, not the live champ.update feed. Resolving
|
||||
// them through the same index means "Deceit" reads consistently on both.
|
||||
const championsPath = path.join(root, 'Config', 'ChampionSpawns.xml')
|
||||
const championsXml = readIfPresent(championsPath)
|
||||
const champions = (
|
||||
championsXml === null
|
||||
? []
|
||||
: parseChampions(record('Config/ChampionSpawns.xml', championsPath, championsXml))
|
||||
).map((champ) => ({
|
||||
...champ,
|
||||
slug: slugify(`${champ.facet}-${champ.name}`),
|
||||
label: resolveRegion(champ.x, champ.y, champ.facet, index, resolveOpts).label,
|
||||
}))
|
||||
|
||||
const creatures = aggregateCreatures(points)
|
||||
const facets = [...new Set(points.map((point) => point.facet))].sort()
|
||||
|
||||
const unresolved = points.filter((point) => !point.region && !point.landmark).length
|
||||
const meta = {
|
||||
version: ARTIFACT_VERSION,
|
||||
generatedAt: new Date().toISOString(),
|
||||
landmarkRadius: args.radius ?? undefined,
|
||||
counts: {
|
||||
facets: facets.length,
|
||||
points: points.length,
|
||||
pointsDisabled: disabled,
|
||||
creatures: creatures.length,
|
||||
regions: regions.length,
|
||||
landmarks: landmarks.length,
|
||||
champions: champions.length,
|
||||
unresolvedPoints: unresolved,
|
||||
},
|
||||
source,
|
||||
}
|
||||
|
||||
return { meta, facets, creatures, regions, landmarks, champions, points }
|
||||
}
|
||||
|
||||
// ── Write ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Encode one point for the artifact. The whole compaction scheme lives here, so
|
||||
* this function and `readPoint()` in importSpawnAtlas.js are exact inverses;
|
||||
* test/spawnAtlas.build.test.js round-trips the pair. Change one, change both.
|
||||
*
|
||||
* Three space savings, worth ~3 MB across the world:
|
||||
*
|
||||
* - `facet` is dropped. The shard file names its facet once at the top rather
|
||||
* than repeating it on all ~2,500 of its records.
|
||||
* - Fields at their default are omitted rather than written as 0. Most
|
||||
* spawners are a single point with no time-of-day gating, so `width`,
|
||||
* `height`, `range` and the three `tod*` fields are zero on the large
|
||||
* majority of records.
|
||||
* - `types` becomes [name, max] tuples. There are ~24,000 type entries across
|
||||
* the world, and `{"type":"Orc","max":1}` spends 15 bytes apiece restating
|
||||
* two key names that never vary.
|
||||
*
|
||||
* `label` is not written at all: it is exactly `region || landmark ||
|
||||
* "Wilderness"`, and the importer recomputes it.
|
||||
*/
|
||||
function encodePoint(point) {
|
||||
const out = {
|
||||
name: point.name,
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
maxCount: point.maxCount,
|
||||
types: point.types.map((entry) => [entry.type, entry.max]),
|
||||
}
|
||||
if (point.width) out.width = point.width
|
||||
if (point.height) out.height = point.height
|
||||
if (point.range) out.range = point.range
|
||||
if (point.minDelay) out.minDelay = point.minDelay
|
||||
if (point.maxDelay) out.maxDelay = point.maxDelay
|
||||
// The tod_* trio is meaningless unless gating is on, so it travels together.
|
||||
if (point.todMode) {
|
||||
out.todMode = point.todMode
|
||||
out.todStart = point.todStart
|
||||
out.todEnd = point.todEnd
|
||||
}
|
||||
if (point.region) out.region = point.region
|
||||
else if (point.landmark) out.landmark = point.landmark
|
||||
return out
|
||||
}
|
||||
|
||||
// Compact, not pretty-printed. This is a generated artifact that is read by
|
||||
// `atlas:import` and never by a human — indenting it added ~2 MB of leading
|
||||
// whitespace to the committed repo for no benefit. Reviewers read the counts
|
||||
// printed by this script and the meta block, not 6,455 JSON records.
|
||||
function writeJson(file, value) {
|
||||
fs.writeFileSync(file, `${JSON.stringify(value)}\n`, 'utf8')
|
||||
return fs.statSync(file).size
|
||||
}
|
||||
|
||||
// `meta` alone stays indented. It is ~2.6 KB and it is the part a reviewer and
|
||||
// the drift check actually read, so a changed source hash shows up as a
|
||||
// one-line diff instead of being buried in a single-line 200 KB blob.
|
||||
function writeJsonPretty(file, value) {
|
||||
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
|
||||
return fs.statSync(file).size
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
if (args.help || !args.servuo) {
|
||||
process.stdout.write(USAGE)
|
||||
process.exit(args.help ? 0 : 1)
|
||||
}
|
||||
|
||||
const atlas = build(args)
|
||||
fs.mkdirSync(args.out, { recursive: true })
|
||||
|
||||
// Points shard per facet; everything else is small enough to share one index.
|
||||
let totalBytes = 0
|
||||
for (const facet of atlas.facets) {
|
||||
const file = path.join(args.out, `spawnAtlas.${normalizeFacet(facet)}.json`)
|
||||
const facetPoints = atlas.points
|
||||
.filter((point) => point.facet === facet)
|
||||
.map(encodePoint)
|
||||
totalBytes += writeJson(file, { facet, points: facetPoints })
|
||||
process.stdout.write(` ${path.basename(file)} ${facetPoints.length} points\n`)
|
||||
}
|
||||
|
||||
const metaFile = path.join(args.out, 'spawnAtlas.meta.json')
|
||||
totalBytes += writeJsonPretty(metaFile, atlas.meta)
|
||||
|
||||
const indexFile = path.join(args.out, 'spawnAtlas.index.json')
|
||||
totalBytes += writeJson(indexFile, {
|
||||
facets: atlas.facets,
|
||||
creatures: atlas.creatures,
|
||||
regions: atlas.regions,
|
||||
landmarks: atlas.landmarks,
|
||||
champions: atlas.champions,
|
||||
})
|
||||
|
||||
const c = atlas.meta.counts
|
||||
process.stdout.write(
|
||||
` ${path.basename(indexFile)} ${c.creatures} creatures, ${c.regions} regions, ` +
|
||||
`${c.landmarks} landmarks, ${c.champions} champions\n` +
|
||||
`\nAtlas built: ${c.points} points across ${c.facets} facets ` +
|
||||
`(${c.pointsDisabled} disabled spawners skipped, ` +
|
||||
`${c.unresolvedPoints} unplaced), ${(totalBytes / 1024 / 1024).toFixed(2)} MB total.\n` +
|
||||
'Commit db/data/spawnAtlas.*.json, then run: npm run atlas:import\n',
|
||||
)
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
try {
|
||||
main()
|
||||
} catch (err) {
|
||||
process.stderr.write(`atlas:build failed: ${err.message}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { build, aggregateCreatures, displayName, encodePoint, ARTIFACT_VERSION }
|
||||
318
server/scripts/importSpawnAtlas.js
Normal file
318
server/scripts/importSpawnAtlas.js
Normal file
@@ -0,0 +1,318 @@
|
||||
#!/usr/bin/env node
|
||||
//
|
||||
// Load the committed spawn atlas artifact into the database.
|
||||
//
|
||||
// npm run atlas:import [-- --dir db/data]
|
||||
//
|
||||
// Deliberately separate from `atlas:build`: building needs a ServUO tree, which
|
||||
// the website container does not have, while importing needs only the committed
|
||||
// JSON and a database. That split is what lets the atlas ship in the image and
|
||||
// be loaded on any deployment.
|
||||
//
|
||||
// The atlas tables are import-owned. This TRUNCATEs and reloads all of them
|
||||
// inside ONE transaction, so a failed import leaves the previous atlas intact
|
||||
// rather than a half-loaded world — there is no partial state worth keeping,
|
||||
// since the artifact is the whole truth.
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
// Slugging must match the build's exactly, so it comes from the same module.
|
||||
const { slugify: slugOf } = require('../src/utils/spawnAtlasParse')
|
||||
|
||||
// `src/utils/db` is required lazily, inside the functions that actually talk to
|
||||
// the database, rather than at module load. Requiring it opens a connection
|
||||
// pool as a side effect, and readPoint() — which the tests round-trip against
|
||||
// buildSpawnAtlas.encodePoint() — is pure. Loading it eagerly made the unit
|
||||
// tests hold a pool open against a dead port for ~11 seconds.
|
||||
const db = () => require('../src/utils/db')
|
||||
|
||||
const DEFAULT_DIR = path.join(__dirname, '..', 'db', 'data')
|
||||
const BATCH = 500
|
||||
|
||||
// ── Artifact reading ───────────────────────────────────────────────────────
|
||||
|
||||
function readJson(file) {
|
||||
return JSON.parse(fs.readFileSync(file, 'utf8'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode one point record back to the full shape.
|
||||
*
|
||||
* The exact mirror of the two space-saving encodings buildSpawnAtlas.js applies
|
||||
* on write — omitted-when-default fields, and `types` as [name, max] tuples.
|
||||
* If one side changes, this must change with it.
|
||||
*/
|
||||
function readPoint(raw, facet) {
|
||||
const region = raw.region ?? null
|
||||
const landmark = raw.landmark ?? null
|
||||
return {
|
||||
facet,
|
||||
name: raw.name ?? null,
|
||||
x: raw.x ?? 0,
|
||||
y: raw.y ?? 0,
|
||||
width: raw.width ?? 0,
|
||||
height: raw.height ?? 0,
|
||||
range: raw.range ?? 0,
|
||||
maxCount: raw.maxCount ?? 0,
|
||||
minDelay: raw.minDelay ?? 0,
|
||||
maxDelay: raw.maxDelay ?? 0,
|
||||
todStart: raw.todStart ?? 0,
|
||||
todEnd: raw.todEnd ?? 0,
|
||||
todMode: raw.todMode ?? 0,
|
||||
region,
|
||||
landmark,
|
||||
// Recomputed rather than stored — it is exactly this expression, and the
|
||||
// build omits it for that reason.
|
||||
label: region || landmark || 'Wilderness',
|
||||
types: (raw.types ?? []).map((entry) =>
|
||||
Array.isArray(entry) ? { type: entry[0], max: entry[1] } : entry,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function loadArtifact(dir) {
|
||||
const indexFile = path.join(dir, 'spawnAtlas.index.json')
|
||||
const metaFile = path.join(dir, 'spawnAtlas.meta.json')
|
||||
if (!fs.existsSync(indexFile)) {
|
||||
throw new Error(
|
||||
`No atlas artifact at ${indexFile}. Build one first:\n` +
|
||||
' npm run atlas:build -- --servuo <path to ServUO>',
|
||||
)
|
||||
}
|
||||
|
||||
const index = readJson(indexFile)
|
||||
const meta = fs.existsSync(metaFile) ? readJson(metaFile) : {}
|
||||
|
||||
const points = []
|
||||
for (const facet of index.facets ?? []) {
|
||||
const file = path.join(dir, `spawnAtlas.${facet}.json`)
|
||||
if (!fs.existsSync(file)) {
|
||||
throw new Error(`Artifact is incomplete: ${path.basename(file)} is missing`)
|
||||
}
|
||||
const shard = readJson(file)
|
||||
for (const raw of shard.points ?? []) points.push(readPoint(raw, shard.facet || facet))
|
||||
}
|
||||
|
||||
return { index, meta, points }
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional operator-supplied art map, `{ "<slug>": "<file under uploads/atlas/>" }`.
|
||||
*
|
||||
* Never committed and never shipped — creature sprites come out of the
|
||||
* operator's own client `.mul`/`.uop` files, which are theirs, not ours. Absent
|
||||
* (the normal case) every `art` stays NULL and the UI renders text-only.
|
||||
* See docs/website/SPAWN_ATLAS.md.
|
||||
*/
|
||||
function loadArtMap(dir) {
|
||||
const file = path.join(dir, 'spawnAtlas.art.json')
|
||||
if (!fs.existsSync(file)) return {}
|
||||
const map = readJson(file)
|
||||
return map && typeof map === 'object' ? map : {}
|
||||
}
|
||||
|
||||
// ── Insert helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
async function insertBatched(conn, sql, rows) {
|
||||
for (let i = 0; i < rows.length; i += BATCH) {
|
||||
await conn.batch(sql, rows.slice(i, i + BATCH))
|
||||
}
|
||||
return rows.length
|
||||
}
|
||||
|
||||
// ── Import ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function importAtlas(dir) {
|
||||
const { index, meta, points } = loadArtifact(dir)
|
||||
const art = loadArtMap(dir)
|
||||
const conn = await db().pool.getConnection()
|
||||
const counts = {}
|
||||
|
||||
try {
|
||||
await conn.beginTransaction()
|
||||
|
||||
// TRUNCATE is DDL in MariaDB and would commit the transaction implicitly,
|
||||
// defeating the all-or-nothing guarantee. DELETE is transactional, which is
|
||||
// what this needs; at ~7k rows the difference is not worth the atomicity.
|
||||
for (const table of [
|
||||
'shard_spawn_point_types',
|
||||
'shard_spawn_points',
|
||||
'shard_spawn_creatures',
|
||||
'shard_regions',
|
||||
'shard_landmarks',
|
||||
'shard_champion_spawns',
|
||||
]) {
|
||||
await conn.query(`DELETE FROM ${table}`)
|
||||
}
|
||||
await conn.query('ALTER TABLE shard_spawn_points AUTO_INCREMENT = 1')
|
||||
|
||||
counts.creatures = await insertBatched(
|
||||
conn,
|
||||
'INSERT INTO shard_spawn_creatures (slug, name, total, points, facets, art) VALUES (?,?,?,?,?,?)',
|
||||
(index.creatures ?? []).map((c) => [
|
||||
c.slug,
|
||||
c.name,
|
||||
c.total ?? 0,
|
||||
c.points ?? 0,
|
||||
JSON.stringify(c.facets ?? {}),
|
||||
art[c.slug] ?? null,
|
||||
]),
|
||||
)
|
||||
|
||||
counts.regions = await insertBatched(
|
||||
conn,
|
||||
'INSERT INTO shard_regions (facet, name, type, priority, parent, rects) VALUES (?,?,?,?,?,?)',
|
||||
(index.regions ?? []).map((r) => [
|
||||
r.facet,
|
||||
r.name,
|
||||
r.type || null,
|
||||
r.priority ?? 0,
|
||||
r.parent || null,
|
||||
JSON.stringify(r.rects ?? []),
|
||||
]),
|
||||
)
|
||||
|
||||
counts.landmarks = await insertBatched(
|
||||
conn,
|
||||
'INSERT INTO shard_landmarks (facet, name, grp, x, y, z) VALUES (?,?,?,?,?,?)',
|
||||
(index.landmarks ?? []).map((l) => [
|
||||
l.facet,
|
||||
l.name,
|
||||
l.group || null,
|
||||
l.x ?? 0,
|
||||
l.y ?? 0,
|
||||
l.z ?? 0,
|
||||
]),
|
||||
)
|
||||
|
||||
counts.champions = await insertBatched(
|
||||
conn,
|
||||
'INSERT INTO shard_champion_spawns ' +
|
||||
'(slug, name, grp, type, random_type, facet, x, y, z, radius, label) ' +
|
||||
'VALUES (?,?,?,?,?,?,?,?,?,?,?)',
|
||||
(index.champions ?? []).map((c) => [
|
||||
c.slug,
|
||||
c.name,
|
||||
c.group || null,
|
||||
c.type || null,
|
||||
c.randomType ? 1 : 0,
|
||||
c.facet,
|
||||
c.x ?? 0,
|
||||
c.y ?? 0,
|
||||
c.z ?? 0,
|
||||
c.radius ?? 0,
|
||||
c.label || null,
|
||||
]),
|
||||
)
|
||||
|
||||
// Point ids are assigned here rather than left to AUTO_INCREMENT, because
|
||||
// the join rows need to know them and `conn.batch()` reports no usable
|
||||
// insertId for a multi-row insert. Assigning them explicitly is safe — the
|
||||
// atlas tables are import-owned and this transaction just emptied them — and
|
||||
// it makes the ids a pure function of artifact order instead of something
|
||||
// derived from driver behaviour.
|
||||
const pointRows = points.map((p, i) => [
|
||||
i + 1,
|
||||
p.facet,
|
||||
p.name,
|
||||
p.x,
|
||||
p.y,
|
||||
p.width,
|
||||
p.height,
|
||||
p.range,
|
||||
p.maxCount,
|
||||
p.minDelay,
|
||||
p.maxDelay,
|
||||
p.todStart,
|
||||
p.todEnd,
|
||||
p.todMode,
|
||||
p.region,
|
||||
p.landmark,
|
||||
p.label,
|
||||
])
|
||||
counts.points = await insertBatched(
|
||||
conn,
|
||||
'INSERT INTO shard_spawn_points ' +
|
||||
'(id, facet, name, x, y, width, height, spawn_range, max_count, min_delay, max_delay, ' +
|
||||
'tod_start, tod_end, tod_mode, region, landmark, label) ' +
|
||||
'VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
|
||||
pointRows,
|
||||
)
|
||||
|
||||
const typeRows = []
|
||||
points.forEach((point, i) => {
|
||||
// A spawner may legitimately list the same type twice (six-type points
|
||||
// repeating a creature). The primary key is (point_id, slug), so collapse
|
||||
// duplicates to the larger max rather than letting the insert fail.
|
||||
const bySlug = new Map()
|
||||
for (const entry of point.types) {
|
||||
const slug = slugOf(entry.type)
|
||||
if (slug === '') continue
|
||||
bySlug.set(slug, Math.max(bySlug.get(slug) ?? 0, entry.max ?? 1))
|
||||
}
|
||||
for (const [slug, max] of bySlug) typeRows.push([i + 1, slug, max])
|
||||
})
|
||||
counts.pointTypes = await insertBatched(
|
||||
conn,
|
||||
'INSERT INTO shard_spawn_point_types (point_id, slug, max_count) VALUES (?,?,?)',
|
||||
typeRows,
|
||||
)
|
||||
|
||||
await conn.query(
|
||||
'INSERT INTO shard_atlas_meta (id, payload) VALUES (1, ?) ' +
|
||||
'ON DUPLICATE KEY UPDATE payload = VALUES(payload), imported_at = CURRENT_TIMESTAMP',
|
||||
[JSON.stringify({ ...meta, importedCounts: counts })],
|
||||
)
|
||||
|
||||
await conn.commit()
|
||||
return counts
|
||||
} catch (err) {
|
||||
await conn.rollback().catch(() => {})
|
||||
throw err
|
||||
} finally {
|
||||
conn.release()
|
||||
}
|
||||
}
|
||||
|
||||
// ── CLI ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = { dir: DEFAULT_DIR }
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
if (argv[i] === '--dir') args.dir = argv[++i]
|
||||
else if (argv[i] === '--help' || argv[i] === '-h') args.help = true
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
if (args.help) {
|
||||
process.stdout.write(
|
||||
'\nLoad the committed spawn atlas artifact into the database.\n\n' +
|
||||
' node scripts/importSpawnAtlas.js [--dir <artifact dir>]\n\n' +
|
||||
` --dir Artifact directory. Default: ${DEFAULT_DIR}\n\n`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const counts = await importAtlas(args.dir)
|
||||
require('../src/utils/logger')('atlas:import').info('spawn atlas imported', counts)
|
||||
process.stdout.write(
|
||||
`Atlas imported: ${counts.points} points, ${counts.creatures} creatures, ` +
|
||||
`${counts.pointTypes} point/type rows, ${counts.regions} regions, ` +
|
||||
`${counts.landmarks} landmarks, ${counts.champions} champion altars.\n`,
|
||||
)
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main()
|
||||
.catch((err) => {
|
||||
process.stderr.write(`atlas:import failed: ${err.message}\n`)
|
||||
process.exitCode = 1
|
||||
})
|
||||
.finally(() => db().close())
|
||||
}
|
||||
|
||||
module.exports = { importAtlas, readPoint, loadArtifact }
|
||||
601
server/src/utils/spawnAtlasParse.js
Normal file
601
server/src/utils/spawnAtlasParse.js
Normal file
@@ -0,0 +1,601 @@
|
||||
// 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 ────────────────────────────────────────────────────────────
|
||||
|
||||
// The three sources disagree about facet spelling and nothing in the files
|
||||
// reconciles them: `Spawns/*.xml` `<Map>` and `Regions.xml` `<Facet name>` both
|
||||
// say `TerMur`/`Tokuno`, while `Data/Locations/*.xml` spells the same facets
|
||||
// `Ter Mur` and `Tokuno Islands`. Left alone this is silent — the landmark
|
||||
// fallback simply never matches on those two facets and every unregioned spawn
|
||||
// in Ter Mur and Tokuno reads "Wilderness" — so every facet name entering the
|
||||
// atlas is canonicalised through here first.
|
||||
const FACET_CANONICAL = new Map([
|
||||
['felucca', 'Felucca'],
|
||||
['trammel', 'Trammel'],
|
||||
['ilshenar', 'Ilshenar'],
|
||||
['malas', 'Malas'],
|
||||
['tokuno', 'Tokuno'],
|
||||
['tokunoislands', 'Tokuno'],
|
||||
['termur', 'TerMur'],
|
||||
])
|
||||
|
||||
/**
|
||||
* Canonicalise a facet name to the `<Map>` spelling the atlas keys on.
|
||||
* Unknown facets pass through trimmed rather than being dropped, so a custom
|
||||
* shard facet still gets an atlas rather than vanishing.
|
||||
*/
|
||||
function normalizeFacet(value) {
|
||||
const raw = String(value ?? '').trim()
|
||||
if (raw === '') return ''
|
||||
return FACET_CANONICAL.get(raw.toLowerCase().replace(/[\s_-]+/g, '')) ?? 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]
|
||||
const facet = normalizeFacet(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 = normalizeFacet(facetNode.attrs.name)
|
||||
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')) {
|
||||
const facet = normalizeFacet(top.attrs.name || facetHint)
|
||||
walkLocations(top, facet, [], landmarks)
|
||||
}
|
||||
return landmarks
|
||||
}
|
||||
|
||||
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: normalizeFacet(attrs.map),
|
||||
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()
|
||||
const facet = (name) => {
|
||||
if (!byFacet.has(name)) byFacet.set(name, { regions: [], landmarks: [] })
|
||||
return byFacet.get(name)
|
||||
}
|
||||
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(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,
|
||||
normalizeFacet,
|
||||
slugify,
|
||||
decodeEntities,
|
||||
DEFAULT_LANDMARK_RADIUS,
|
||||
}
|
||||
183
server/test/spawnAtlas.build.test.js
Normal file
183
server/test/spawnAtlas.build.test.js
Normal file
@@ -0,0 +1,183 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const { aggregateCreatures, displayName, encodePoint } = require('../scripts/buildSpawnAtlas')
|
||||
const { readPoint } = require('../scripts/importSpawnAtlas')
|
||||
|
||||
// buildSpawnAtlas is the fs/CLI shell, but its aggregation and its artifact
|
||||
// encoding are pure and worth covering — the encoding especially, because it is
|
||||
// mirrored by hand in importSpawnAtlas.readPoint() and a drift between the two
|
||||
// would corrupt every imported point silently rather than failing loudly.
|
||||
|
||||
// ── aggregateCreatures ─────────────────────────────────────────────────────
|
||||
|
||||
const POINTS = [
|
||||
{
|
||||
facet: 'Felucca',
|
||||
types: [
|
||||
{ type: 'Lizardman', max: 3 },
|
||||
{ type: 'Orc', max: 1 },
|
||||
],
|
||||
},
|
||||
{ facet: 'Felucca', types: [{ type: 'Lizardman', max: 2 }] },
|
||||
{ facet: 'Trammel', types: [{ type: 'lizardman', max: 5 }] },
|
||||
]
|
||||
|
||||
test('aggregateCreatures: sums each type’s own max across points', () => {
|
||||
const creatures = aggregateCreatures(POINTS)
|
||||
const lizardman = creatures.find((c) => c.slug === 'lizardman')
|
||||
// 3 + 2 + 5 — how many exist in the world at once.
|
||||
assert.equal(lizardman.total, 10)
|
||||
assert.equal(lizardman.points, 3)
|
||||
})
|
||||
|
||||
test('aggregateCreatures: counts points per facet', () => {
|
||||
const lizardman = aggregateCreatures(POINTS).find((c) => c.slug === 'lizardman')
|
||||
assert.deepEqual(lizardman.facets, { Felucca: 2, Trammel: 1 })
|
||||
})
|
||||
|
||||
test('aggregateCreatures: differing case collapses into one creature', () => {
|
||||
// "Lizardman" and "lizardman" are the same creature; the shard's spawn files
|
||||
// are not consistent about case.
|
||||
const creatures = aggregateCreatures(POINTS)
|
||||
assert.equal(creatures.filter((c) => c.slug === 'lizardman').length, 1)
|
||||
})
|
||||
|
||||
test('aggregateCreatures: sorted by slug, and internal state is not leaked', () => {
|
||||
const creatures = aggregateCreatures(POINTS)
|
||||
assert.deepEqual(
|
||||
creatures.map((c) => c.slug),
|
||||
['lizardman', 'orc'],
|
||||
)
|
||||
// The spelling tally is a build detail and must not reach the artifact.
|
||||
assert.equal(Object.hasOwn(creatures[0], 'spellings'), false)
|
||||
})
|
||||
|
||||
test('aggregateCreatures: empty input yields no creatures', () => {
|
||||
assert.deepEqual(aggregateCreatures([]), [])
|
||||
})
|
||||
|
||||
// ── displayName ────────────────────────────────────────────────────────────
|
||||
|
||||
test('displayName: the most common spelling wins', () => {
|
||||
assert.equal(displayName(new Map([['lizardman', 9], ['Lizardman', 2]])), 'lizardman')
|
||||
assert.equal(displayName(new Map([['Lizardman', 9], ['lizardman', 2]])), 'Lizardman')
|
||||
})
|
||||
|
||||
test('displayName: ties break toward the capitalised spelling', () => {
|
||||
// Otherwise the display name depends on file read order, which would produce
|
||||
// spurious diffs in the committed artifact on an unrelated rebuild.
|
||||
assert.equal(displayName(new Map([['lizardman', 5], ['Lizardman', 5]])), 'Lizardman')
|
||||
assert.equal(displayName(new Map([['acidslug', 1], ['AcidSlug', 1]])), 'AcidSlug')
|
||||
})
|
||||
|
||||
test('displayName: fully tied spellings fall back to alphabetical, not input order', () => {
|
||||
const forward = displayName(new Map([['abc', 1], ['abd', 1]]))
|
||||
const reverse = displayName(new Map([['abd', 1], ['abc', 1]]))
|
||||
assert.equal(forward, reverse)
|
||||
})
|
||||
|
||||
// ── encodePoint / readPoint round trip ─────────────────────────────────────
|
||||
|
||||
const FULL_POINT = {
|
||||
facet: 'Trammel',
|
||||
name: 'CovetousSpawner26',
|
||||
x: 5412,
|
||||
y: 1970,
|
||||
width: 10,
|
||||
height: 10,
|
||||
range: 5,
|
||||
maxCount: 3,
|
||||
minDelay: 5,
|
||||
maxDelay: 10,
|
||||
todStart: 4,
|
||||
todEnd: 8,
|
||||
todMode: 1,
|
||||
region: 'Covetous',
|
||||
landmark: null,
|
||||
types: [
|
||||
{ type: 'Lizardman', max: 3 },
|
||||
{ type: 'Orc', max: 1 },
|
||||
],
|
||||
}
|
||||
|
||||
test('encodePoint: drops facet and tuple-encodes types', () => {
|
||||
const encoded = encodePoint(FULL_POINT)
|
||||
assert.equal(Object.hasOwn(encoded, 'facet'), false)
|
||||
assert.deepEqual(encoded.types, [
|
||||
['Lizardman', 3],
|
||||
['Orc', 1],
|
||||
])
|
||||
})
|
||||
|
||||
test('round trip: encodePoint → JSON → readPoint restores every field', () => {
|
||||
// The artifact goes through JSON on disk, so round-trip through it here too.
|
||||
const wire = JSON.parse(JSON.stringify(encodePoint(FULL_POINT)))
|
||||
const decoded = readPoint(wire, 'Trammel')
|
||||
for (const key of Object.keys(FULL_POINT)) {
|
||||
if (key === 'types') continue
|
||||
assert.deepEqual(decoded[key], FULL_POINT[key], `field "${key}" survived the round trip`)
|
||||
}
|
||||
assert.deepEqual(decoded.types, FULL_POINT.types)
|
||||
})
|
||||
|
||||
test('round trip: omitted defaults come back as zeros, not undefined', () => {
|
||||
// The build omits width/height/range/minDelay/maxDelay/tod* when they are 0,
|
||||
// which is the majority of spawners. They must decode to 0 — a NULL would
|
||||
// violate the NOT NULL columns.
|
||||
const sparse = {
|
||||
facet: 'Felucca',
|
||||
name: 'Simple',
|
||||
x: 100,
|
||||
y: 200,
|
||||
width: 0,
|
||||
height: 0,
|
||||
range: 0,
|
||||
maxCount: 1,
|
||||
minDelay: 0,
|
||||
maxDelay: 0,
|
||||
todStart: 0,
|
||||
todEnd: 0,
|
||||
todMode: 0,
|
||||
region: null,
|
||||
landmark: null,
|
||||
types: [{ type: 'Orc', max: 1 }],
|
||||
}
|
||||
const encoded = JSON.parse(JSON.stringify(encodePoint(sparse)))
|
||||
// Precondition: the build really did omit them.
|
||||
assert.equal(Object.hasOwn(encoded, 'width'), false)
|
||||
assert.equal(Object.hasOwn(encoded, 'todMode'), false)
|
||||
|
||||
const decoded = readPoint(encoded, 'Felucca')
|
||||
for (const key of ['width', 'height', 'range', 'minDelay', 'maxDelay', 'todStart', 'todEnd', 'todMode']) {
|
||||
assert.equal(decoded[key], 0, `${key} decodes to 0`)
|
||||
}
|
||||
})
|
||||
|
||||
test('round trip: label is recomputed, not stored', () => {
|
||||
const encoded = encodePoint(FULL_POINT)
|
||||
// Precondition: the build does not write it.
|
||||
assert.equal(Object.hasOwn(encoded, 'label'), false)
|
||||
|
||||
assert.equal(readPoint(encoded, 'Trammel').label, 'Covetous')
|
||||
assert.equal(readPoint({ ...encoded, region: undefined, landmark: 'Britain' }, 'T').label, 'Britain')
|
||||
assert.equal(
|
||||
readPoint({ ...encoded, region: undefined, landmark: undefined }, 'T').label,
|
||||
'Wilderness',
|
||||
)
|
||||
})
|
||||
|
||||
test('readPoint: takes its facet from the shard file, not the record', () => {
|
||||
const decoded = readPoint(encodePoint(FULL_POINT), 'Felucca')
|
||||
assert.equal(decoded.facet, 'Felucca')
|
||||
})
|
||||
|
||||
test('readPoint: tolerates already-decoded object types', () => {
|
||||
// Defensive: an artifact written before tuple encoding still imports.
|
||||
const decoded = readPoint({ x: 1, y: 2, types: [{ type: 'Orc', max: 2 }] }, 'Felucca')
|
||||
assert.deepEqual(decoded.types, [{ type: 'Orc', max: 2 }])
|
||||
})
|
||||
|
||||
test('readPoint: a record with no types decodes to an empty list', () => {
|
||||
assert.deepEqual(readPoint({ x: 1, y: 2 }, 'Felucca').types, [])
|
||||
})
|
||||
502
server/test/spawnAtlas.parse.test.js
Normal file
502
server/test/spawnAtlas.parse.test.js
Normal file
@@ -0,0 +1,502 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const {
|
||||
parseXml,
|
||||
parseObjects2,
|
||||
parsePoints,
|
||||
parseRegions,
|
||||
parseLocations,
|
||||
parseChampions,
|
||||
buildPlacementIndex,
|
||||
resolveRegion,
|
||||
normalizeFacet,
|
||||
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 ─────────────────────────────────────────────────
|
||||
|
||||
test('normalizeFacet: reconciles the Locations spellings with <Map>', () => {
|
||||
// Left unreconciled, every unregioned Ter Mur and Tokuno spawn silently
|
||||
// resolves to "Wilderness" because the landmark bucket is keyed differently.
|
||||
assert.equal(normalizeFacet('Ter Mur'), 'TerMur')
|
||||
assert.equal(normalizeFacet('TerMur'), 'TerMur')
|
||||
assert.equal(normalizeFacet('Tokuno Islands'), 'Tokuno')
|
||||
assert.equal(normalizeFacet('Tokuno'), 'Tokuno')
|
||||
assert.equal(normalizeFacet('felucca'), 'Felucca')
|
||||
})
|
||||
|
||||
test('normalizeFacet: an unknown facet passes through instead of vanishing', () => {
|
||||
assert.equal(normalizeFacet('CustomShardFacet'), 'CustomShardFacet')
|
||||
assert.equal(normalizeFacet(''), '')
|
||||
assert.equal(normalizeFacet(null), '')
|
||||
})
|
||||
|
||||
test('parseLocations and parsePoints agree on facet after normalisation', () => {
|
||||
const landmarks = parseLocations(
|
||||
'<places><parent name="Ter Mur"><parent name="Holy City">' +
|
||||
'<child name="Bank" x="1000" y="1000" z="0" /></parent></parent></places>',
|
||||
)
|
||||
const points = parsePoints(
|
||||
'<Spawns><Points><Name>a</Name><Map>TerMur</Map><X>1000</X><Y>1000</Y></Points></Spawns>',
|
||||
)
|
||||
assert.equal(landmarks[0].facet, points[0].facet)
|
||||
})
|
||||
|
||||
// ── 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')
|
||||
})
|
||||
Reference in New Issue
Block a user