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