diff --git a/.gitignore b/.gitignore index 987f65b..622c8c6 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,13 @@ uploads/ server/logs/ logs/ +# Operator-supplied spawn atlas artwork. Creature art is never committed: sprites +# are extracted from the operator's own UO client .mul/.uop files and are theirs, +# not ours to redistribute. The images live under server/uploads/atlas/, already +# ignored above; this is the slug -> file-name map pointing at them. +# See docs/website/SPAWN_ATLAS.md and db/data/spawnAtlas.art.example.json. +server/db/data/spawnAtlas.art.json + # reference material (extracted from the provided archives) _reference/ diff --git a/server/db/data/spawnAtlas.art.example.json b/server/db/data/spawnAtlas.art.example.json new file mode 100644 index 0000000..309108a --- /dev/null +++ b/server/db/data/spawnAtlas.art.example.json @@ -0,0 +1,24 @@ +{ + "_comment": [ + "OPTIONAL operator-supplied creature art for the spawn atlas. Copy this file to", + "spawnAtlas.art.json (same directory) and edit it, then restart the server or run", + "`npm run atlas:import` — the art map is read on every atlas refresh.", + "", + "This project ships NO creature artwork and never will. UO sprites live in your", + "own client's .mul/.uop files and are yours to extract, not ours to redistribute.", + "If you want art on the atlas pages, export it yourself (UOFiddler, ClassicUO's", + "tooling, or any art extractor), drop the images under server/uploads/atlas/, and", + "map each creature slug to its file name here.", + "", + "Both spawnAtlas.art.json and server/uploads/ are gitignored, so neither the map", + "nor the images can be committed by accident.", + "", + "Keys are creature slugs, as reported by the atlas API and derived from the type", + "names in your own shard's Spawns/*.xml. Values are file names relative to", + "server/uploads/atlas/. Any creature with no entry here simply renders without", + "art — that is the default and fully supported state, not a degraded one." + ], + "lizardman": "lizardman.png", + "orc": "orc.png", + "dragon": "dragon.png" +} diff --git a/server/db/schema.sql b/server/db/schema.sql index 1ef54ff..430827a 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -1040,6 +1040,156 @@ CREATE TABLE IF NOT EXISTS announce_jobs ( INDEX idx_announce_due_discord (discord_status, discord_next_attempt_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +-- ── Spawn atlas (Protocol 3.0 Part C) ─────────────────────────────────────── +-- Static shard CONTENT, not live shard state: what spawns where, which regions +-- and landmarks exist, and which champion altars are configured. Nothing here +-- comes from the sidecar — it is imported from a committed artifact built off a +-- ServUO tree by `npm run atlas:build` (see docs/website/SPAWN_ATLAS.md), so +-- these tables stay populated whether the shard is up or not. +-- +-- Every table is import-owned: `npm run atlas:import` TRUNCATEs and reloads them +-- in one transaction. Nothing else may write here, and nothing else may hold a +-- foreign key to them. No FKs at all, consistent with every other shard_* table. + +-- One row per spawnable type, aggregated across the world. `total` is the sum of +-- each type's own MX across every point that spawns it (how many exist at once); +-- `facets` is a per-facet point count, so the facet filter and "where does this +-- live" both answer without touching shard_spawn_points. +CREATE TABLE IF NOT EXISTS shard_spawn_creatures ( + slug VARCHAR(120) NOT NULL PRIMARY KEY, -- slugified class name; the /atlas/:slug key + name VARCHAR(120) NOT NULL, -- display spelling chosen by the build + total INT NOT NULL DEFAULT 0, + points INT NOT NULL DEFAULT 0, + facets JSON NULL, -- { "Felucca": 171, "Trammel": 160, ... } + -- Operator-supplied artwork, always NULL on a fresh import. The repo ships no + -- creature art: sprites live in the operator's own client .mul/.uop files and + -- are theirs to extract and place under uploads/atlas/. The UI renders without + -- art when this is NULL, which is the normal case. + art VARCHAR(255) NULL, + -- Plain INDEX, deliberately NOT FULLTEXT: ~800 rows makes a LIKE scan free, + -- and FULLTEXT's min-token-length would break searches for names like "orc". + INDEX idx_shard_spawn_creatures_name (name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- One row per spawner. `region`/`landmark` are the resolved place name — the +-- point-in-rect transform that turns "5411,1234" into "Despise" — and `label` is +-- the resolved display string (region, else landmark, else 'Wilderness'). +CREATE TABLE IF NOT EXISTS shard_spawn_points ( + id INT AUTO_INCREMENT PRIMARY KEY, + facet VARCHAR(40) NOT NULL, + name VARCHAR(120) NULL, -- the ServUO spawner's own name + x INT NOT NULL, + y INT NOT NULL, + width INT NOT NULL DEFAULT 0, + height INT NOT NULL DEFAULT 0, + spawn_range INT NOT NULL DEFAULT 0, -- `range` is reserved in MariaDB + max_count INT NOT NULL DEFAULT 0, + min_delay INT NOT NULL DEFAULT 0, + max_delay INT NOT NULL DEFAULT 0, + tod_start INT NOT NULL DEFAULT 0, -- meaningless unless tod_mode <> 0 + tod_end INT NOT NULL DEFAULT 0, + tod_mode INT NOT NULL DEFAULT 0, + region VARCHAR(120) NULL, + landmark VARCHAR(120) NULL, + label VARCHAR(120) NOT NULL DEFAULT 'Wilderness', + INDEX idx_shard_spawn_points_facet (facet), + INDEX idx_shard_spawn_points_label (label) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- The many-to-many between the two above: one spawner commonly carries several +-- types (a single Trammel point spawns six), each with its own max. This is how +-- /atlas/creatures/:slug finds the places a creature appears. +CREATE TABLE IF NOT EXISTS shard_spawn_point_types ( + point_id INT NOT NULL, + slug VARCHAR(120) NOT NULL, -- → shard_spawn_creatures.slug (no FK) + max_count INT NOT NULL DEFAULT 1, + PRIMARY KEY (point_id, slug), + INDEX idx_shard_spawn_point_types_slug (slug) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Named regions from Data/Regions.xml, flattened out of their nesting. `rects` +-- holds the region's rectangles; `priority` and rect area are what resolved each +-- spawn point at build time, kept here so the admin drift check can re-derive. +CREATE TABLE IF NOT EXISTS shard_regions ( + id INT AUTO_INCREMENT PRIMARY KEY, + facet VARCHAR(40) NOT NULL, + name VARCHAR(120) NOT NULL, + type VARCHAR(80) NULL, -- ServUO region class + priority INT NOT NULL DEFAULT 0, + parent VARCHAR(120) NULL, -- enclosing named region, if any + rects JSON NULL, + INDEX idx_shard_regions_facet (facet), + INDEX idx_shard_regions_name (name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Points of interest from Data/Locations/*.xml. `grp` is the innermost enclosing +-- parent ("Covetous"), which is the label worth showing — "Covetous" reads +-- better than the individual marker "Level 1". (`group` is reserved in SQL.) +CREATE TABLE IF NOT EXISTS shard_landmarks ( + id INT AUTO_INCREMENT PRIMARY KEY, + facet VARCHAR(40) NOT NULL, + name VARCHAR(120) NOT NULL, + grp VARCHAR(120) NULL, + x INT NOT NULL, + y INT NOT NULL, + z INT NOT NULL DEFAULT 0, + INDEX idx_shard_landmarks_facet (facet), + INDEX idx_shard_landmarks_name (name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Configured champion altars from Config/ChampionSpawns.xml. This is static +-- roster data ("there is an Unholy Terror altar in Deceit") and is distinct from +-- the live champ.update feed in shard_champs ("it is on level 3 right now"). +CREATE TABLE IF NOT EXISTS shard_champion_spawns ( + slug VARCHAR(160) NOT NULL PRIMARY KEY, -- facet-name, e.g. "felucca-deceit" + name VARCHAR(120) NOT NULL, + grp VARCHAR(80) NULL, -- spawn group; one active per group + type VARCHAR(80) NULL, -- '' when randomised per activation + random_type TINYINT(1) NOT NULL DEFAULT 0, + facet VARCHAR(40) NOT NULL, + x INT NOT NULL, + y INT NOT NULL, + z INT NOT NULL DEFAULT 0, + radius INT NOT NULL DEFAULT 0, + label VARCHAR(120) NULL, -- resolved place name + INDEX idx_shard_champion_spawns_facet (facet) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Singleton (id = 1) describing the artifact currently loaded: when it was +-- built, its counts, and a sha256 per ServUO source file. The admin drift check +-- compares this against db/data/spawnAtlas.meta.json to report when the database +-- is behind the committed artifact. +CREATE TABLE IF NOT EXISTS shard_atlas_meta ( + id TINYINT NOT NULL PRIMARY KEY DEFAULT 1, + payload JSON NOT NULL, + imported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT chk_shard_atlas_meta_singleton CHECK (id = 1) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- Singleton (id = 1) holding an atlas refresh that was parsed but deliberately +-- NOT applied, because it would remove a facet the site currently serves. +-- +-- Losing a facet is the signature of a half-copied or mid-update ServUO tree as +-- much as of a real map change, and boot cannot tell the two apart — so the +-- refresh is staged here for a human instead of being applied. Startup is never +-- blocked by it: the site comes up serving the atlas it already had. +-- +-- Only the DECISION is stored, not the parsed world: `payload` holds the source +-- hashes and the facet diff (a few KB), and approving re-parses the tree. That +-- keeps a multi-megabyte blob out of the database and guarantees the applied +-- atlas matches the tree as it is at approval time, not as it was at boot. +-- +-- `rejected` is remembered against those exact source hashes so a declined +-- refresh does not re-prompt on every restart; changing the tree changes the +-- hashes and asks again. +CREATE TABLE IF NOT EXISTS shard_atlas_pending ( + id TINYINT NOT NULL PRIMARY KEY DEFAULT 1, + status ENUM('pending','rejected') NOT NULL DEFAULT 'pending', + payload JSON NOT NULL, -- source hashes + facet diff + detected_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT chk_shard_atlas_pending_singleton CHECK (id = 1) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + -- Migrations for databases created before the wiki upgrade. Each statement uses -- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get -- these columns from the CREATE TABLE above; existing installs get them here. diff --git a/server/package.json b/server/package.json index 9f01910..9bc9042 100644 --- a/server/package.json +++ b/server/package.json @@ -9,6 +9,7 @@ "seed": "node db/seed.js", "swagger": "node swagger/swagger.js", "routes:manifest": "node scripts/routeManifest.js", + "atlas:import": "node scripts/importSpawnAtlas.js", "test": "node --test" }, "keywords": [ diff --git a/server/scripts/importSpawnAtlas.js b/server/scripts/importSpawnAtlas.js new file mode 100644 index 0000000..b9ecbb8 --- /dev/null +++ b/server/scripts/importSpawnAtlas.js @@ -0,0 +1,127 @@ +#!/usr/bin/env node +// +// Refresh the spawn atlas from a ServUO tree, from the command line. +// +// npm run atlas:import # use the configured path +// npm run atlas:import -- --servuo # override it for this run +// npm run atlas:import -- --force # reimport even if unchanged +// npm run atlas:import -- --approve # apply a staged refresh +// npm run atlas:import -- --status # report without changing anything +// +// The server does this itself on every boot (see `shardAtlas.refreshOnBoot`), so +// this is for operators who want to apply a map change without a restart, and +// for approving a refresh that was staged because it would remove a facet. +// +// All the logic lives in `src/model/shardAtlas/shardAtlas.model.js`; this file +// is argument parsing and output formatting. + +const db = () => require('../src/utils/db') + +function parseArgs(argv) { + const args = {} + for (let i = 0; i < argv.length; i += 1) { + const flag = argv[i] + if (flag === '--servuo') args.servuo = argv[++i] + else if (flag === '--force') args.force = true + else if (flag === '--approve') args.approve = true + else if (flag === '--reject') args.reject = true + else if (flag === '--status') args.status = true + else if (flag === '--help' || flag === '-h') args.help = true + } + return args +} + +const USAGE = ` +Refresh the spawn atlas from a ServUO tree. + + node scripts/importSpawnAtlas.js [options] + + --servuo Use this tree for this run instead of the configured path. + --force Reimport even when the source files are unchanged. + --approve Apply a refresh that was staged for removing a facet. + --reject Keep the current atlas and dismiss the staged refresh. + --status Report atlas and source state; change nothing. + +With no options this imports only if the tree differs from what is loaded. +` + +function describe(result) { + switch (result.status) { + case 'skipped': + return ( + 'No ServUO path configured — nothing to import.\n' + + 'Set one with SERVUO_PATH, the admin panel, or --servuo .\n' + ) + case 'unavailable': + return `ServUO tree unavailable: ${result.reason}\n` + case 'unchanged': + return `Atlas is already up to date${result.reason ? ` (${result.reason})` : ''}.\n` + case 'needsReview': { + return ( + 'Refresh NOT applied — it would remove ' + + `${result.removedFacets.length} facet(s): ${result.removedFacets.join(', ')}.\n` + + 'This is what a half-copied or mid-update tree looks like, so it has been\n' + + 'staged for review. The current atlas is unchanged.\n' + + 'Apply it with --approve, or dismiss it with --reject.\n' + ) + } + case 'imported': { + const c = result.counts + const added = result.addedFacets?.length ? ` Added facets: ${result.addedFacets.join(', ')}.` : '' + const removed = result.removedFacets?.length + ? ` Removed facets: ${result.removedFacets.join(', ')}.` + : '' + return ( + `Atlas imported: ${c.points} points, ${c.creatures} creatures, ` + + `${c.pointTypes} point/type rows, ${c.regions} regions, ` + + `${c.landmarks} landmarks, ${c.champions} champion altars.${added}${removed}\n` + ) + } + case 'failed': + return `Atlas refresh failed: ${result.reason}\n` + default: + return `${JSON.stringify(result, null, 2)}\n` + } +} + +async function main() { + const args = parseArgs(process.argv.slice(2)) + if (args.help) { + process.stdout.write(USAGE) + return + } + + const shardAtlas = require('../src/model/shardAtlas/shardAtlas.model') + + // `--servuo` is a per-run override and deliberately does NOT persist to the + // configured path; changing where the atlas permanently reads from is an + // admin action, not a side effect of a one-off import. + const override = { path: args.servuo ?? '' } + + if (args.status) { + process.stdout.write(`${JSON.stringify(await shardAtlas.status(override), null, 2)}\n`) + return + } + if (args.reject) { + process.stdout.write(`${JSON.stringify(await shardAtlas.rejectPending(), null, 2)}\n`) + return + } + + const result = args.approve + ? await shardAtlas.approvePending(override) + : await shardAtlas.refresh({ ...override, force: Boolean(args.force) }) + + process.stdout.write(describe(result)) + if (result.status === 'failed') process.exitCode = 1 +} + +if (require.main === module) { + main() + .catch((err) => { + process.stderr.write(`atlas:import failed: ${err.message}\n`) + process.exitCode = 1 + }) + .finally(() => db().close()) +} + +module.exports = { describe, parseArgs } diff --git a/server/src/model/shardAtlas/shardAtlas.db.js b/server/src/model/shardAtlas/shardAtlas.db.js new file mode 100644 index 0000000..14e9e0a --- /dev/null +++ b/server/src/model/shardAtlas/shardAtlas.db.js @@ -0,0 +1,202 @@ +const { pool, query } = require('../../utils/db') + +// Raw SQL for the spawn atlas. Every table here is IMPORT-OWNED: `replaceAtlas` +// empties and refills all six inside one transaction, and nothing else in the +// codebase writes to them. There are no foreign keys, consistent with every +// other shard_* table. + +const BATCH = 500 + +const ATLAS_TABLES = [ + 'shard_spawn_point_types', + 'shard_spawn_points', + 'shard_spawn_creatures', + 'shard_regions', + 'shard_landmarks', + 'shard_champion_spawns', +] + +async function insertBatched(conn, sql, rows) { + for (let i = 0; i < rows.length; i += BATCH) { + await conn.batch(sql, rows.slice(i, i + BATCH)) + } + return rows.length +} + +/** + * Replace the entire atlas in one transaction. + * + * All-or-nothing on purpose: a failed reload must leave the previous atlas + * intact rather than a half-loaded world, since a partially-imported atlas is + * indistinguishable from a real one to anyone reading it. + * + * `DELETE`, not `TRUNCATE` — `TRUNCATE` is DDL in MariaDB and implicitly + * commits, which would defeat exactly that guarantee. At ~7k rows the cost of + * `DELETE` is irrelevant. + */ +async function replaceAtlas(atlas, art = {}) { + const conn = await pool.getConnection() + const counts = {} + try { + await conn.beginTransaction() + + for (const table of ATLAS_TABLES) await conn.query(`DELETE FROM ${table}`) + + counts.creatures = await insertBatched( + conn, + 'INSERT INTO shard_spawn_creatures (slug, name, total, points, facets, art) VALUES (?,?,?,?,?,?)', + atlas.creatures.map((c) => [ + c.slug, + c.name, + c.total ?? 0, + c.points ?? 0, + JSON.stringify(c.facets ?? {}), + art[c.slug] ?? null, + ]), + ) + + counts.regions = await insertBatched( + conn, + 'INSERT INTO shard_regions (facet, name, type, priority, parent, rects) VALUES (?,?,?,?,?,?)', + atlas.regions.map((r) => [ + r.facet, + r.name, + r.type || null, + r.priority ?? 0, + r.parent || null, + JSON.stringify(r.rects ?? []), + ]), + ) + + counts.landmarks = await insertBatched( + conn, + 'INSERT INTO shard_landmarks (facet, name, grp, x, y, z) VALUES (?,?,?,?,?,?)', + atlas.landmarks.map((l) => [ + l.facet, + l.name, + l.group || null, + l.x ?? 0, + l.y ?? 0, + l.z ?? 0, + ]), + ) + + counts.champions = await insertBatched( + conn, + 'INSERT INTO shard_champion_spawns ' + + '(slug, name, grp, type, random_type, facet, x, y, z, radius, label) ' + + 'VALUES (?,?,?,?,?,?,?,?,?,?,?)', + atlas.champions.map((c) => [ + c.slug, + c.name, + c.group || null, + c.type || null, + c.randomType ? 1 : 0, + c.facet, + c.x ?? 0, + c.y ?? 0, + c.z ?? 0, + c.radius ?? 0, + c.label || null, + ]), + ) + + // Point ids are assigned explicitly rather than left to AUTO_INCREMENT: the + // join rows need to know them and `conn.batch()` reports no usable insertId + // for a multi-row insert. Safe because this transaction just emptied the + // table and nothing else writes to it. + counts.points = await insertBatched( + conn, + 'INSERT INTO shard_spawn_points ' + + '(id, facet, name, x, y, width, height, spawn_range, max_count, min_delay, max_delay, ' + + 'tod_start, tod_end, tod_mode, region, landmark, label) ' + + 'VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)', + atlas.points.map((p, i) => [ + i + 1, + p.facet, + p.name, + p.x, + p.y, + p.width ?? 0, + p.height ?? 0, + p.range ?? 0, + p.maxCount ?? 0, + p.minDelay ?? 0, + p.maxDelay ?? 0, + p.todStart ?? 0, + p.todEnd ?? 0, + p.todMode ?? 0, + p.region, + p.landmark, + p.label || 'Wilderness', + ]), + ) + + counts.pointTypes = await insertBatched( + conn, + 'INSERT INTO shard_spawn_point_types (point_id, slug, max_count) VALUES (?,?,?)', + atlas.pointTypes, + ) + + await conn.query( + 'INSERT INTO shard_atlas_meta (id, payload) VALUES (1, ?) ' + + 'ON DUPLICATE KEY UPDATE payload = VALUES(payload), imported_at = CURRENT_TIMESTAMP', + [JSON.stringify({ ...atlas.meta, importedCounts: counts })], + ) + + // A completed import answers whatever was pending. + await conn.query('DELETE FROM shard_atlas_pending') + + await conn.commit() + return counts + } catch (err) { + await conn.rollback().catch(() => {}) + throw err + } finally { + conn.release() + } +} + +async function getMeta() { + const rows = await query('SELECT payload, imported_at FROM shard_atlas_meta WHERE id = 1') + if (rows.length === 0) return null + const payload = typeof rows[0].payload === 'string' ? JSON.parse(rows[0].payload) : rows[0].payload + return { ...payload, importedAt: rows[0].imported_at } +} + +/** Facet names currently loaded, used to detect a facet disappearing. */ +async function getFacets() { + const rows = await query('SELECT DISTINCT facet FROM shard_spawn_points ORDER BY facet') + return rows.map((row) => row.facet) +} + +// ── Pending review ───────────────────────────────────────────────────────── + +async function getPending() { + const rows = await query('SELECT payload, status, detected_at FROM shard_atlas_pending WHERE id = 1') + if (rows.length === 0) return null + const payload = typeof rows[0].payload === 'string' ? JSON.parse(rows[0].payload) : rows[0].payload + return { ...payload, status: rows[0].status, detectedAt: rows[0].detected_at } +} + +async function setPending(payload, status = 'pending') { + return query( + 'INSERT INTO shard_atlas_pending (id, status, payload) VALUES (1, ?, ?) ' + + 'ON DUPLICATE KEY UPDATE status = VALUES(status), payload = VALUES(payload), ' + + 'detected_at = CURRENT_TIMESTAMP', + [status, JSON.stringify(payload)], + ) +} + +async function clearPending() { + return query('DELETE FROM shard_atlas_pending') +} + +module.exports = { + replaceAtlas, + getMeta, + getFacets, + getPending, + setPending, + clearPending, +} diff --git a/server/src/model/shardAtlas/shardAtlas.model.js b/server/src/model/shardAtlas/shardAtlas.model.js new file mode 100644 index 0000000..054a982 --- /dev/null +++ b/server/src/model/shardAtlas/shardAtlas.model.js @@ -0,0 +1,296 @@ +const fs = require('fs') +const path = require('path') + +const db = require('./shardAtlas.db') +const settings = require('../settings/settings.model') +const { slugify } = require('../../utils/spawnAtlasParse') +const { + AtlasSourceError, + buildAtlas, + hashSources, + sameSources, +} = require('../../utils/spawnAtlasSource') +const log = require('../../utils/logger')('shardAtlas') + +// The spawn atlas, refreshed from the shard's own ServUO tree. +// +// The tree is the single source of truth. Nothing is precomputed and committed, +// because a shard's maps change over its lifetime — facets get added, replaced +// or renamed — and a snapshot in the repo would go stale against the world +// players actually see. So the atlas is re-derived on every boot. +// +// Two rules govern the boot path: +// +// 1. **It never blocks startup.** No configured path, an unreadable path, a +// malformed file, a database error — all of it is caught and logged. The +// site comes up either way, serving whatever atlas it already had. +// 2. **A facet disappearing is not applied automatically.** Losing a facet is +// the signature of a half-copied or mid-update tree as much as of a real +// map change, and the two are indistinguishable from here. The refresh is +// staged for a human instead, and an admin approves or rejects it. +// +// Everything else — new facets, renamed regions, changed spawns — applies +// straight away, because none of it can silently destroy data an operator would +// miss. + +const SETTING_KEY = 'spawn_atlas_servuo_path' + +/** + * Where the ServUO tree lives. + * + * The admin setting wins over the environment so an operator can point the + * atlas at a different tree without a redeploy, matching how the rest of the + * shard integration is admin-managed rather than env-configured. `SERVUO_PATH` + * remains as the deploy-time default, since the path usually describes a mount + * that the deployment sets up. + */ +async function getServuoPath() { + try { + const configured = await settings.get(SETTING_KEY) + if (configured && String(configured).trim() !== '') return String(configured).trim() + } catch { + // Settings unavailable is not fatal — fall through to the env default. + } + const fromEnv = process.env.SERVUO_PATH + return fromEnv && fromEnv.trim() !== '' ? fromEnv.trim() : '' +} + +async function setServuoPath(value, updatedBy = null) { + return settings.set(SETTING_KEY, String(value ?? '').trim(), updatedBy) +} + +/** + * Optional operator-supplied art map, `{ "": "" }`. + * + * Never committed and never shipped — creature sprites come out of the + * operator's own client `.mul`/`.uop` files, which are theirs, not ours to + * redistribute. Absent (the normal case) every `art` stays NULL and the UI + * renders text-only. + */ +function loadArtMap(dir = path.join(__dirname, '..', '..', '..', 'db', 'data')) { + try { + const file = path.join(dir, 'spawnAtlas.art.json') + if (!fs.existsSync(file)) return {} + const map = JSON.parse(fs.readFileSync(file, 'utf8')) + return map && typeof map === 'object' ? map : {} + } catch (err) { + log.warn('spawn atlas art map could not be read', { error: err.message }) + return {} + } +} + +/** + * Flatten each point's types into `shard_spawn_point_types` rows. + * + * A spawner may legitimately list the same type twice, and the primary key is + * (point_id, slug), so duplicates collapse to the larger max rather than + * failing the insert. + */ +function pointTypeRows(points) { + const rows = [] + points.forEach((point, i) => { + const bySlug = new Map() + for (const entry of point.types ?? []) { + const slug = slugify(entry.type) + if (slug === '') continue + bySlug.set(slug, Math.max(bySlug.get(slug) ?? 0, entry.max ?? 1)) + } + for (const [slug, max] of bySlug) rows.push([i + 1, slug, max]) + }) + return rows +} + +async function applyAtlas(atlas) { + return db.replaceAtlas({ ...atlas, pointTypes: pointTypeRows(atlas.points) }, loadArtMap()) +} + +/** + * Refresh the atlas from the configured ServUO tree. + * + * Returns a result describing what happened rather than throwing, so the caller + * — including the boot path — can log it and move on: + * + * `skipped` no path configured + * `unavailable` path configured but unreadable / missing required files + * `unchanged` source hashes match the loaded atlas; nothing parsed + * `imported` parsed and applied + * `needsReview` parsed, but a facet would be lost; staged for an admin + * `failed` parsed or applied and something went wrong + * + * `force` skips the hash check (an admin asking for a reimport) and `approve` + * additionally accepts facet loss (an admin approving a staged refresh). + */ +async function refresh({ force = false, approve = false, path: pathOverride = '' } = {}) { + // An explicit override wins outright — it is a one-off "use this tree", and it + // must not be silently overruled by the configured path the way an env default + // would be. + const root = pathOverride.trim() !== '' ? pathOverride.trim() : await getServuoPath() + if (root === '') return { status: 'skipped', reason: 'no ServUO path configured' } + + let hashes + try { + hashes = hashSources(root) + } catch (err) { + if (err instanceof AtlasSourceError) { + return { status: 'unavailable', reason: err.message, code: err.code, path: root } + } + return { status: 'failed', reason: err.message, path: root } + } + + const meta = await db.getMeta().catch(() => null) + const loaded = meta?.source + ? Object.fromEntries(Object.entries(meta.source).map(([label, v]) => [label, v.sha256])) + : null + + if (!force && sameSources(hashes, loaded)) { + return { status: 'unchanged', path: root } + } + + // A rejected refresh must not re-prompt on every boot. It stays rejected until + // the tree changes again, at which point the hashes differ and it is a new + // decision. + const pending = await db.getPending().catch(() => null) + if (!approve && !force && pending?.status === 'rejected' && sameSources(hashes, pending.hashes)) { + return { status: 'unchanged', path: root, reason: 'refresh previously rejected' } + } + + let atlas + try { + atlas = buildAtlas(root) + } catch (err) { + return { status: 'failed', reason: err.message, path: root } + } + + const currentFacets = await db.getFacets().catch(() => []) + const incomingFacets = atlas.facets + const removedFacets = currentFacets.filter((facet) => !incomingFacets.includes(facet)) + const addedFacets = incomingFacets.filter((facet) => !currentFacets.includes(facet)) + + // Losing a facet is indistinguishable here from a half-copied tree, so it is + // staged rather than applied — but startup is never blocked by it. + if (removedFacets.length > 0 && !approve) { + const summary = { + hashes, + path: root, + currentFacets, + incomingFacets, + removedFacets, + addedFacets, + counts: atlas.meta.counts, + } + await db.setPending(summary, 'pending').catch((err) => { + log.warn('could not stage spawn atlas refresh', { error: err.message }) + }) + return { status: 'needsReview', ...summary } + } + + try { + const counts = await applyAtlas(atlas) + return { status: 'imported', path: root, counts, addedFacets, removedFacets } + } catch (err) { + return { status: 'failed', reason: err.message, path: root } + } +} + +/** Admin approved a staged refresh: apply it, facet loss and all. */ +async function approvePending(options = {}) { + return refresh({ ...options, approve: true, force: true }) +} + +/** + * Admin rejected a staged refresh: keep the current atlas and remember the + * decision against those exact source hashes, so it does not re-prompt every + * boot. A further change to the tree produces different hashes and asks again. + */ +async function rejectPending() { + const pending = await db.getPending() + if (!pending) return { status: 'none' } + await db.setPending({ ...pending, rejectedAt: new Date().toISOString() }, 'rejected') + return { status: 'rejected' } +} + +/** Everything the admin panel needs to describe atlas state. */ +async function status({ path: pathOverride = '' } = {}) { + const root = pathOverride.trim() !== '' ? pathOverride.trim() : await getServuoPath() + const [meta, pending, facets] = await Promise.all([ + db.getMeta().catch(() => null), + db.getPending().catch(() => null), + db.getFacets().catch(() => []), + ]) + + let treeReadable = false + let drift = null + if (root !== '') { + try { + const hashes = hashSources(root) + treeReadable = true + const loaded = meta?.source + ? Object.fromEntries(Object.entries(meta.source).map(([l, v]) => [l, v.sha256])) + : null + drift = !sameSources(hashes, loaded) + } catch { + treeReadable = false + } + } + + return { + configured: root !== '', + path: root, + treeReadable, + drift, + facets, + importedAt: meta?.importedAt ?? null, + counts: meta?.counts ?? null, + pending, + } +} + +/** + * Boot hook. Best-effort by contract: it logs and returns, never throws, so a + * missing tree or a bad file can never stop the site coming up. + */ +async function refreshOnBoot() { + try { + const result = await refresh() + switch (result.status) { + case 'imported': + log.info('spawn atlas refreshed from ServUO tree', { + ...result.counts, + added: result.addedFacets, + }) + break + case 'needsReview': + log.warn( + 'spawn atlas refresh staged for admin review — a facet would be removed; ' + + 'the existing atlas is unchanged', + { removed: result.removedFacets, added: result.addedFacets }, + ) + break + case 'unavailable': + log.warn('spawn atlas source unavailable', { reason: result.reason, path: result.path }) + break + case 'failed': + log.warn('spawn atlas refresh failed', { reason: result.reason }) + break + default: + break + } + return result + } catch (err) { + log.warn('spawn atlas refresh errored', { error: err.message }) + return { status: 'failed', reason: err.message } + } +} + +module.exports = { + refresh, + refreshOnBoot, + approvePending, + rejectPending, + status, + getServuoPath, + setServuoPath, + pointTypeRows, + loadArtMap, + SETTING_KEY, +} diff --git a/server/src/server.js b/server/src/server.js index f9890a3..ae06133 100644 --- a/server/src/server.js +++ b/server/src/server.js @@ -14,6 +14,7 @@ const { seedDefaults, createInitialAdminFromEnv } = require('../db/seed') const settings = require('./model/settings/settings.model') const revokedSessions = require('./model/revokedSessions/revokedSessions.model') const mobileAuthBridge = require('./model/mobileAuthBridge/mobileAuthBridge.model') +const shardAtlas = require('./model/shardAtlas/shardAtlas.model') const createLogger = require('./utils/logger') const { evaluateBotInternalKey } = require('./utils/botInternalKey') const brand = require('./config/brand') @@ -77,6 +78,17 @@ async function start() { log.warn('mobile-auth-bridge prune failed', { error: err.message }) } + // Re-derive the spawn atlas from the shard's own ServUO tree. The shard's maps + // change over its lifetime — facets get added, replaced or renamed — so the + // atlas is rebuilt on every boot rather than shipped as a snapshot that would + // silently go stale. Hash-gated, so an unchanged tree costs one read pass and + // no database write. + // + // Best-effort by contract: no configured path, an unreadable mount or a + // malformed file must never stop the site coming up. A refresh that would + // REMOVE a facet is staged for admin approval instead of being applied. + await shardAtlas.refreshOnBoot() + const mode = await settings.get('site_mode') log.info(`site mode: ${String(mode || 'live').toUpperCase()}`) diff --git a/server/src/utils/spawnAtlasParse.js b/server/src/utils/spawnAtlasParse.js new file mode 100644 index 0000000..3b414b5 --- /dev/null +++ b/server/src/utils/spawnAtlasParse.js @@ -0,0 +1,664 @@ +// Spawn atlas parsers — pure functions over strings, no `fs`, no dependencies. +// +// These back the CLI build script (`scripts/buildSpawnAtlas.js`), which is the +// only thing that reads a ServUO tree. Keeping every parser pure and fs-free is +// what lets the test suite cover them in CI, where no ServUO tree exists: the +// tests hand these functions literal XML strings. +// +// Four source shapes, two very different parsing strategies: +// +// Spawns/*.xml ~10.5 MB across 13 files, FLAT records +// → streaming regex, never a DOM. See parsePoints(). +// Data/Regions.xml 129 KB, genuinely nested inside +// Data/Locations/*.xml nested / +// Config/ChampionSpawns.xml 4.8 KB, / +// → 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 + 4) + i = end === -1 ? text.length : end + 3 + continue + } + if (text.startsWith('', 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 + 2) + i = end === -1 ? text.length : end + 2 + continue + } + if (text.startsWith('', lt + 2) + i = end === -1 ? text.length : end + 1 + continue + } + + const gt = findTagEnd(text, lt) + if (gt === -1) { + // Unterminated tag: nothing sane is left to read. + break + } + const inner = text.slice(lt + 1, gt) + + if (inner[0] === '/') { + const name = inner.slice(1).trim() + // Pop to the nearest matching open element. If there is no match the tag + // is stray and we drop it rather than unwinding the whole stack. + for (let depth = stack.length - 1; depth > 0; depth -= 1) { + if (stack[depth].name === name) { + stack.length = depth + break + } + } + i = gt + 1 + continue + } + + const selfClosing = inner.endsWith('/') + const body = selfClosing ? inner.slice(0, -1) : inner + const space = body.search(/\s/) + const name = (space === -1 ? body : body.slice(0, space)).trim() + const node = { + name, + attrs: space === -1 ? {} : parseAttrs(body.slice(space)), + children: [], + text: '', + } + stack[stack.length - 1].children.push(node) + if (!selfClosing) stack.push(node) + i = gt + 1 + } + + return root.children.length > 0 ? root.children[0] : null +} + +// `>` inside a quoted attribute value must not end the tag. +function findTagEnd(text, from) { + let quote = null + for (let i = from + 1; i < text.length; i += 1) { + const ch = text[i] + if (quote) { + if (ch === quote) quote = null + } else if (ch === '"' || ch === "'") { + quote = ch + } else if (ch === '>') { + return i + } + } + return -1 +} + +function appendText(node, chunk) { + if (chunk.trim() === '') return + appendRawText(node, decodeEntities(chunk)) +} + +function appendRawText(node, chunk) { + node.text = node.text ? `${node.text}${chunk}` : chunk +} + +function childrenNamed(node, name) { + if (!node || !node.children) return [] + return node.children.filter((child) => child.name === name) +} + +// ── Facet names ──────────────────────────────────────────────────────────── +// +// Facets are NOT a fixed list. A shard may add facets, replace them wholesale, +// or rename them when its maps are updated, so nothing here may name Felucca, +// Trammel or any other stock facet. The facet set is whatever the shard's own +// files say it is, discovered at parse time. +// +// The complication is that the sources disagree about spelling for the SAME +// facet and nothing in the files reconciles them: `Spawns/*.xml` `` and +// `Regions.xml` `` say `TerMur`, while `Data/Locations/*.xml` spells +// it `Ter Mur` and calls Tokuno `Tokuno Islands`. Left unreconciled this fails +// silently — the landmark bucket is keyed differently from the points looking it +// up, so the fallback never fires and every unregioned spawn on those facets +// reads "Wilderness". +// +// Reconciliation is therefore done by MATCHING, not by a lookup table: +// `facetKey()` collapses spelling differences, and `resolveFacetName()` matches +// a loosely-spelled name against the canonical set discovered from the shard's +// own data. A facet nobody else mentions keeps its own name rather than being +// dropped. + +/** + * Collapse a facet name to a comparison key: lowercase, alphanumerics only. + * `TerMur`, `Ter Mur` and `ter-mur` all key alike. + */ +function facetKey(value) { + return String(value ?? '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '') +} + +/** + * Build a key → canonical-spelling lookup from the authoritative facet names. + * + * The authority is what the spawn records and region definitions actually say, + * since those are the names the atlas keys everything on. Later names do not + * overwrite earlier ones, so the first source wins consistently. + */ +function buildFacetIndex(names) { + const index = new Map() + for (const name of names) { + const key = facetKey(name) + if (key !== '' && !index.has(key)) index.set(key, String(name).trim()) + } + return index +} + +/** + * Resolve a loosely-spelled facet name against the discovered canonical set. + * + * Tried in order: exact key match (`Ter Mur` → `TerMur`), then a prefix match in + * either direction (`Tokuno Islands` → `Tokuno`), longest candidate first so a + * more specific facet wins over a shorter one that merely prefixes it. + * + * A name matching nothing is returned trimmed rather than dropped — on a shard + * with a custom facet that is a real facet the atlas simply has no spawns for + * yet, and inventing a match would be worse than leaving it alone. + */ +function resolveFacetName(value, index) { + const raw = String(value ?? '').trim() + const key = facetKey(raw) + if (key === '') return '' + if (index.has(key)) return index.get(key) + + let best = null + for (const [candidateKey, canonical] of index) { + if (!key.startsWith(candidateKey) && !candidateKey.startsWith(key)) continue + if (best === null || candidateKey.length > facetKey(best).length) best = canonical + } + return best ?? raw +} + +// ── Small coercions ──────────────────────────────────────────────────────── + +function toInt(value, fallback = 0) { + const n = Number.parseInt(value, 10) + return Number.isFinite(n) ? n : fallback +} + +function toBool(value) { + return String(value).trim().toLowerCase() === 'true' +} + +/** + * URL-safe slug used as the creature primary key and in `/atlas/:slug`. + * Spawn type tokens are C# class names, so they are already ASCII-ish; this + * mainly lowercases and collapses punctuation. + */ +function slugify(value) { + return String(value) + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') +} + +// ── Objects2 ─────────────────────────────────────────────────────────────── + +/** + * Parse a `` 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 = /([\s\S]*?)<\/Points>/g + +function tagValue(block, name) { + const match = block.match(new RegExp(`<${name}>([\\s\\S]*?)`)) + return match ? decodeEntities(match[1]).trim() : '' +} + +/** + * Parse a `Spawns/.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 ``, never from the file + * name. `Eodon.xml`, `GravewaterLake.xml` and the other named-area files all + * carry TerMur/Trammel points, so there are 13 files but only 6 facets. + */ +function parsePoints(source) { + const text = String(source) + const points = [] + POINT_RE.lastIndex = 0 + let match + + while ((match = POINT_RE.exec(text)) !== null) { + const block = match[1] + // Reported exactly as written. `` is the authority the rest of the + // atlas keys on, so it is never rewritten. + const facet = tagValue(block, 'Map') + if (facet === '') continue + + points.push({ + name: tagValue(block, 'Name'), + facet, + x: toInt(tagValue(block, 'X')), + y: toInt(tagValue(block, 'Y')), + width: toInt(tagValue(block, 'Width')), + height: toInt(tagValue(block, 'Height')), + range: toInt(tagValue(block, 'Range')), + maxCount: toInt(tagValue(block, 'MaxCount')), + minDelay: toInt(tagValue(block, 'MinDelay')), + maxDelay: toInt(tagValue(block, 'MaxDelay')), + // Time-of-day gating: TODMode 0 means "always", in which case the start + // and end values are meaningless and the site must not render them. + todStart: toInt(tagValue(block, 'TODStart')), + todEnd: toInt(tagValue(block, 'TODEnd')), + todMode: toInt(tagValue(block, 'TODMode')), + // A spawner switched off in-world spawns nothing; the build filters these + // out so the atlas describes what actually appears, not what is merely + // configured. Parsed here so the decision stays in the build script. + running: toBool(tagValue(block, 'IsRunning')), + types: parseObjects2(tagValue(block, 'Objects2')), + }) + } + + return points +} + +// ── Data/Regions.xml ─────────────────────────────────────────────────────── + +/** + * Flatten `Data/Regions.xml` into `[{ facet, name, type, priority, parent, rects }]`. + * + * Regions nest: a `` may contain further `` elements, and the + * inner ones frequently omit `name` and `priority` (`` + * inside "Prism of Light"). Unnamed regions are skipped — they cannot label a + * spawn point — but their children are still walked, and a child that omits + * `priority` inherits its parent's rather than defaulting to 0, which would + * quietly sort it below every top-level region. + */ +function parseRegions(source) { + const root = parseXml(source) + const regions = [] + if (!root) return regions + + for (const facetNode of childrenNamed(root, 'Facet')) { + const facet = (facetNode.attrs.name || '').trim() + if (facet === '') continue + walkRegions(facetNode, facet, null, 0, regions) + } + return regions +} + +function walkRegions(node, facet, parentName, parentPriority, out) { + for (const regionNode of childrenNamed(node, 'region')) { + const name = regionNode.attrs.name || '' + const priority = Object.hasOwn(regionNode.attrs, 'priority') + ? toInt(regionNode.attrs.priority, parentPriority) + : parentPriority + + if (name !== '') { + const rects = childrenNamed(regionNode, 'rect').map((rect) => ({ + x: toInt(rect.attrs.x), + y: toInt(rect.attrs.y), + width: toInt(rect.attrs.width), + height: toInt(rect.attrs.height), + })) + // A named region with no rects (some exist purely to carry music or a + // `go` point) can never contain anything, so it is not worth indexing. + if (rects.length > 0) { + out.push({ + facet, + name, + type: regionNode.attrs.type || '', + priority, + parent: parentName, + rects, + }) + } + } + + walkRegions(regionNode, facet, name === '' ? parentName : name, priority, out) + } +} + +// ── Data/Locations/*.xml ─────────────────────────────────────────────────── + +/** + * Flatten a `Data/Locations/.xml` into landmark points. + * + * The file nests `` arbitrarily deep and puts coordinates only on + * ``: Trammel → Dungeons → Covetous → "Level 1". The outermost parent is + * the facet itself and is dropped from `path`; `group` is the innermost + * enclosing parent ("Covetous"), which is the label worth showing — "Covetous" + * reads better than "Level 1" when naming where a spawn is. + */ +function parseLocations(source, facetHint = '') { + const root = parseXml(source) + const landmarks = [] + if (!root) return landmarks + + for (const top of childrenNamed(root, 'parent')) { + // The file name (`Data/Locations/termur.xml`) is the more reliable signal + // and is preferred over the display label inside the file, which is where + // the `Ter Mur` / `Tokuno Islands` drift lives. Both are carried so the + // build can fall back to matching the label if the file name resolves to + // nothing — a shard may well name its files differently from its facets. + landmarks.push( + ...collectLocations(top, facetHint || top.attrs.name || '', top.attrs.name || ''), + ) + } + return landmarks +} + +function collectLocations(top, facet, label) { + const out = [] + walkLocations(top, facet, [], out) + for (const landmark of out) landmark.facetLabel = label + return out +} + +function walkLocations(node, facet, path, out) { + for (const child of childrenNamed(node, 'child')) { + const name = child.attrs.name || '' + if (name === '') continue + out.push({ + facet, + name, + group: path.length > 0 ? path[path.length - 1] : name, + path: [...path], + x: toInt(child.attrs.x), + y: toInt(child.attrs.y), + z: toInt(child.attrs.z), + }) + } + for (const parent of childrenNamed(node, 'parent')) { + const name = parent.attrs.name || '' + walkLocations(parent, facet, name === '' ? path : [...path, name], out) + } +} + +// ── Config/ChampionSpawns.xml ────────────────────────────────────────────── + +/** + * Parse `Config/ChampionSpawns.xml` into champion altar records. + * + * This is the shard's *configured* champion roster — which altars exist, where, + * and which type each is pinned to. It is static content and distinct from the + * live `champ.update` feed the bridge already carries: this says "there is an + * Unholy Terror altar in Deceit", the feed says "it is on level 3 right now". + * + * A spawn with no `type` is randomised on every activation, which the site must + * render as "random" rather than as an empty type. + */ +function parseChampions(source) { + const root = parseXml(source) + const champions = [] + if (!root) return champions + + for (const spawnNode of childrenNamed(root, 'spawn')) { + const location = childrenNamed(spawnNode, 'location')[0] + const attrs = location ? location.attrs : {} + champions.push({ + name: spawnNode.attrs.name || '', + group: spawnNode.attrs.group || '', + type: spawnNode.attrs.type || '', + randomType: !spawnNode.attrs.type, + facet: (attrs.map || '').trim(), + x: toInt(attrs.x), + y: toInt(attrs.y), + z: toInt(attrs.z), + radius: toInt(attrs.radius), + }) + } + return champions +} + +// ── Placement ────────────────────────────────────────────────────────────── + +const DEFAULT_LANDMARK_RADIUS = 200 + +function inRect(x, y, rect) { + return ( + x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height + ) +} + +function rectArea(rect) { + return Math.max(1, rect.width) * Math.max(1, rect.height) +} + +/** + * Group parsed regions and landmarks by facet once, so the per-point resolve + * below is a scan of one facet instead of the whole world. With ~6,500 points + * and a few thousand rects this stays comfortably sub-second; there is no need + * for a spatial index and none is worth the complexity. + */ +function buildPlacementIndex(regions, landmarks) { + const byFacet = new Map() + // Keyed on facetKey(), not the raw name, so two spellings of one facet cannot + // land in separate buckets — the failure that silently emptied the landmark + // bucket for Ter Mur and Tokuno. + const facet = (name) => { + const key = facetKey(name) + if (!byFacet.has(key)) byFacet.set(key, { regions: [], landmarks: [] }) + return byFacet.get(key) + } + for (const region of regions) facet(region.facet).regions.push(region) + for (const landmark of landmarks) facet(landmark.facet).landmarks.push(landmark) + return byFacet +} + +/** + * Turn a raw coordinate into a human place name. + * + * This is the transform the whole atlas exists for: it is what makes a row read + * "Lizardman — Despise, Felucca" instead of "Lizardman — 5411, 1234". + * + * Resolution order: + * 1. The highest-`priority` named region whose rect contains the point. Ties + * break toward the SMALLEST rect, so a specific room inside a dungeon wins + * over the dungeon-wide rect it sits in. + * 2. Otherwise the nearest landmark within `landmarkRadius` tiles, labelled by + * its group ("Covetous"), not the individual marker ("Level 1"). + * 3. Otherwise "Wilderness". The radius cap is what keeps step 3 reachable — + * without it the nearest landmark is always *some* landmark, however far, + * and open countryside would get labelled with a dungeon on the far side + * of the map. + */ +function resolveRegion(x, y, facetName, index, options = {}) { + const radius = options.landmarkRadius ?? DEFAULT_LANDMARK_RADIUS + const bucket = index.get(facetKey(facetName)) + const result = { region: null, landmark: null, label: 'Wilderness' } + if (!bucket) return result + + let best = null + let bestPriority = -Infinity + let bestArea = Infinity + for (const region of bucket.regions) { + for (const rect of region.rects) { + if (!inRect(x, y, rect)) continue + const area = rectArea(rect) + if (region.priority > bestPriority || (region.priority === bestPriority && area < bestArea)) { + best = region + bestPriority = region.priority + bestArea = area + } + } + } + if (best) { + result.region = best.name + result.label = best.name + return result + } + + let nearest = null + let nearestDistance = Infinity + const limit = radius * radius + for (const landmark of bucket.landmarks) { + const dx = landmark.x - x + const dy = landmark.y - y + const distance = dx * dx + dy * dy + if (distance < nearestDistance) { + nearest = landmark + nearestDistance = distance + } + } + if (nearest && nearestDistance <= limit) { + result.landmark = nearest.group || nearest.name + result.label = result.landmark + } + return result +} + +module.exports = { + parseXml, + parseObjects2, + parsePoints, + parseRegions, + parseLocations, + parseChampions, + buildPlacementIndex, + resolveRegion, + facetKey, + buildFacetIndex, + resolveFacetName, + slugify, + decodeEntities, + DEFAULT_LANDMARK_RADIUS, +} diff --git a/server/src/utils/spawnAtlasSource.js b/server/src/utils/spawnAtlasSource.js new file mode 100644 index 0000000..c66aa8f --- /dev/null +++ b/server/src/utils/spawnAtlasSource.js @@ -0,0 +1,319 @@ +// Spawn atlas — the filesystem layer over a ServUO tree. +// +// `spawnAtlasParse.js` holds the pure parsers; this module is the only thing +// that touches a ServUO tree on disk, and it is shared by both callers: +// +// - the server, which refreshes the atlas on boot (`shardAtlas.model.js`) +// - the CLI (`scripts/importSpawnAtlas.js`) +// +// The shard's own files are the single source of truth. Nothing is precomputed +// and committed, because a shard's maps change over its lifetime — facets get +// added, replaced or renamed — and a snapshot in the repo would silently go +// stale against the world players actually see. +// +// Reading and hashing the whole tree costs ~120 ms and a full parse ~400 ms, so +// the boot path hashes first and only parses when something actually changed. + +const crypto = require('crypto') +const fs = require('fs') +const path = require('path') + +const { + parsePoints, + parseRegions, + parseLocations, + parseChampions, + buildPlacementIndex, + buildFacetIndex, + resolveFacetName, + resolveRegion, + facetKey, + slugify, +} = require('./spawnAtlasParse') + +const REGIONS_FILE = path.join('Data', 'Regions.xml') +const LOCATIONS_DIR = path.join('Data', 'Locations') +const SPAWNS_DIR = 'Spawns' +const CHAMPIONS_FILE = path.join('Config', 'ChampionSpawns.xml') + +class AtlasSourceError extends Error { + constructor(message, code) { + super(message) + this.name = 'AtlasSourceError' + this.code = code + } +} + +// ── Reading ──────────────────────────────────────────────────────────────── + +function sha256(text) { + return crypto.createHash('sha256').update(text, 'utf8').digest('hex') +} + +function listXml(dir) { + try { + return fs + .readdirSync(dir) + .filter((name) => name.toLowerCase().endsWith('.xml')) + .sort() + } catch (err) { + if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return [] + throw err + } +} + +function readIfPresent(file) { + try { + return fs.readFileSync(file, 'utf8') + } catch (err) { + if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return null + throw err + } +} + +/** + * Read every atlas source file under `root`. + * + * Returns `{ files: [{ label, text, sha256, bytes }] }`, labels being + * tree-relative and forward-slashed so a hash map compares equal across + * platforms — the same tree read on Windows and Linux must produce the same + * fingerprint or every boot would look like a change. + */ +function readSources(root) { + if (!root || String(root).trim() === '') { + throw new AtlasSourceError('No ServUO path configured', 'NO_PATH') + } + if (!fs.existsSync(root)) { + throw new AtlasSourceError(`ServUO path does not exist: ${root}`, 'NOT_FOUND') + } + + const files = [] + const push = (label, file) => { + const text = readIfPresent(file) + if (text === null) return false + files.push({ label, text, sha256: sha256(text), bytes: Buffer.byteLength(text, 'utf8') }) + return true + } + + if (!push('Data/Regions.xml', path.join(root, REGIONS_FILE))) { + throw new AtlasSourceError(`Missing required file: ${REGIONS_FILE}`, 'NO_REGIONS') + } + + for (const name of listXml(path.join(root, LOCATIONS_DIR))) { + push(`Data/Locations/${name}`, path.join(root, LOCATIONS_DIR, name)) + } + + const spawnFiles = listXml(path.join(root, SPAWNS_DIR)) + if (spawnFiles.length === 0) { + throw new AtlasSourceError(`No spawn files found in ${SPAWNS_DIR}`, 'NO_SPAWNS') + } + for (const name of spawnFiles) push(`Spawns/${name}`, path.join(root, SPAWNS_DIR, name)) + + push('Config/ChampionSpawns.xml', path.join(root, CHAMPIONS_FILE)) + + return { files } +} + +/** + * A fingerprint of the tree: `{ "