feat(atlas): parse a ServUO tree into a committed spawn atlas artifact

Protocol 3.0 order 3 (Part C), first of two website PRs. This half is the data
pipeline only — parsers, the build/import CLI, and the tables. No routes and no
client, so nothing is user-visible yet; the API and pages follow in PR 2.

Part C is website-only: no plugin, no sidecar, no new event kinds, no wire
change.

## Parsing

`src/utils/spawnAtlasParse.js` is pure and fs-free so CI covers it with no
ServUO tree. Zero new dependencies — `Regions.xml` genuinely nests, so it gets a
small hand-rolled subset tokenizer rather than a new XML package. The 10.5 MB of
`Spawns/*.xml` never touches it: those records are flat and get a streaming
regex sweep instead.

The high-value transform is point-in-rect placement — highest region priority
wins, ties break to the smaller rect, then a nearest-landmark fallback within
200 tiles, else "Wilderness". That is what turns "lizardman at 5411,1234" into
"Despise, Felucca", and it resolves 83.2% of points (5,369 of 6,455).

Three things the real data forced, none of which were in the design:

- **Only 6 facets, not 13.** `Eodon.xml`, `GravewaterLake.xml` and the other
  named-area files carry TerMur/Trammel points, so the facet comes from each
  record's own `<Map>` and the artifact shards 6 ways.
- **Facet names disagree across sources.** `Data/Locations/*.xml` spells them
  `Ter Mur` and `Tokuno Islands`; `<Map>` and `<Facet name>` say `TerMur` and
  `Tokuno`. Unreconciled this is silent — the landmark fallback simply never
  fires on those facets and every unregioned spawn there reads "Wilderness".
- **Spawn type tokens carry XmlSpawner directives**: `Fairy,{RND,4,8}`,
  `alchemist/z/-50`, `Agralem/Name/Agralem`. Taken literally these invent
  creatures that do not exist AND split real ones in two, since `Fairy` and
  `Fairy,{RND,4,8}` slug apart. 71 of 845 entries were affected; stripping at
  the first `/` or `,` leaves 800 clean ones.

## Artifact

`npm run atlas:build -- --servuo <path>` writes `db/data/spawnAtlas.*.json`:
6 facet shards + a compact index + a small indented `meta`. 1.41 MB committed,
down from 4.40 MB by dropping `facet` per record, omitting defaulted fields, and
tuple-encoding the ~24,000 type entries. `encodePoint()` and the importer's
`readPoint()` are exact inverses and are round-tripped in tests.

Display spelling is chosen deterministically (most common, ties to the
capitalised form) because the spawn files are inconsistent about case and the
name would otherwise depend on file read order — a spurious diff on every
unrelated rebuild.

## Import

`npm run atlas:import` needs no ServUO tree, which is the whole reason build and
import are separate: the container has the artifact but not the tree. It
reloads all six tables in one transaction (DELETE, not TRUNCATE, which is DDL
and would implicitly commit), so a failed import leaves the previous atlas
intact.

## No artwork, by design

The repo ships no creature art and no extraction tooling. Sprites live in the
operator's own client `.mul`/`.uop` files and are theirs, not ours to
redistribute. `shard_spawn_creatures.art` is nullable and NULL on every fresh
import; an operator who wants art extracts it themselves, drops it under
`server/uploads/atlas/` (already gitignored) and maps slugs in a gitignored
`spawnAtlas.art.json`. Text-only is the normal, fully supported state.

## Verification

- **544 server tests pass**, 57 new across `spawnAtlas.parse.test.js` (the
  `:OBJ=` split, directive stripping, nested-region priority inheritance,
  half-open rects, the facet reconciliation, tokenizer edge cases) and
  `spawnAtlas.build.test.js` (aggregation, deterministic naming, and the
  encode/decode round trip).
- Built and imported for real against the local MariaDB and the ServUO tree at
  `C:\Users\colby\Desktop\ServUO`: 6,455 points, 800 creatures, 23,927
  point/type rows, 387 regions, 558 landmarks, 25 champion altars.
- "Where does a lizardman spawn?" answers Shrines / Isamu-Jima / Yew across
  Felucca, Trammel and Tokuno.

No routes changed, so the OpenAPI spec and route manifest are untouched.

---

- [x] AI-assisted: written with **Claude Code** (Claude Opus 5), reviewed before opening.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U7CBg11prhLimL9iHSX1bP
This commit is contained in:
2026-07-28 16:07:30 -05:00
parent 6b1396dd2f
commit 353cce9f26
16 changed files with 2230 additions and 0 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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

View File

@@ -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.

View File

@@ -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": [

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

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

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

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

View 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&apos;s Legacy"), "Mondain's Legacy")
assert.equal(decodeEntities('a &amp; b'), 'a & b')
assert.equal(decodeEntities('&lt;tag&gt;'), '<tag>')
assert.equal(decodeEntities('&#65;&#x42;'), 'AB')
// An unknown entity is left alone rather than silently eaten.
assert.equal(decodeEntities('&nosuch;'), '&nosuch;')
})
test('parseXml: decodes entities in attribute values', () => {
const root = parseXml('<r><region name="Mondain&apos;s Legacy" /></r>')
assert.equal(root.children[0].attrs.name, "Mondain's Legacy")
})
// ── slugify ────────────────────────────────────────────────────────────────
test('slugify: produces URL-safe keys', () => {
assert.equal(slugify('Lizardman'), 'lizardman')
assert.equal(slugify('Giant Spider'), 'giant-spider')
assert.equal(slugify("Mondain's Legacy"), 'mondain-s-legacy')
assert.equal(slugify(' Orc '), 'orc')
})