spike(modules): carry /public/atlas/* behind the proposed module surface
THROWAWAY BRANCH — evidence for the Phase 1 contract, never merged. See
modules/uo/SPIKE.md and docs/website/MODULE_API.md Part 7.
The six public spawn-atlas routes now live in modules/uo/, reached only through
the ctx/register surface, with the client half loading as a prebuilt ESM chunk.
All three exit criteria met:
• zero internal-file imports from the module into core; the built chunk has
zero bare import specifiers and bundles no React
• routes.manifest.json AND routes.guards.json are byte-identical
• /uo/atlas renders from /modules/uo/entry.js under script-src 'self' with
zero CSP violation reports
729 core tests and 81 module tests pass. Verified end to end against the real
database: the schema fragment replays after core's, onBoot runs the atlas
refresh, and the six API URLs answer unchanged.
Two things the spike changed in the contract:
• ctx.express / ctx.validator. A module lives outside server/, so Node never
reaches server/node_modules and require('express') fails outright — the
server-side twin of the one-React rule, which §2.6 had only for the client.
• window.__rg.jsxRuntime, so a module can build with the automatic JSX
runtime its tooling already assumes rather than being forced to classic.
And it confirmed §6.1 empirically: regenerating the OpenAPI spec silently
deleted all 361 lines of the atlas paths with "Swagger-autogen: Success", while
the route manifest kept all six in the same run. That is exactly the
static-analysis-vs-runtime split the fragment merge exists to prevent.
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
88
modules/uo/server/core.js
Normal file
88
modules/uo/server/core.js
Normal file
@@ -0,0 +1,88 @@
|
||||
// ── The module's single point of contact with core ─────────────────────────
|
||||
//
|
||||
// Every other file in this module imports THIS file instead of reaching into
|
||||
// the website's tree. That is the whole mechanical trick behind the
|
||||
// zero-internal-imports rule (docs/website/MODULE_API.md §5.1): the moved files
|
||||
// changed by one `require` line each, and a CI grep for a relative path
|
||||
// escaping the module root can then be an exact test rather than a heuristic.
|
||||
//
|
||||
// It exists because `ctx` arrives as an ARGUMENT to register(), while the files
|
||||
// that need it are plain CommonJS modules that were written against top-level
|
||||
// requires. Rather than thread ctx through nine constructors, register() parks
|
||||
// it here once and everything else reads it lazily.
|
||||
//
|
||||
// Lazily is load-bearing: this file is required at module-require time, which is
|
||||
// during app.js's own require, and reading `ctx.db` eagerly would rebuild the
|
||||
// startup-time database dependency the loader is careful not to have.
|
||||
|
||||
let ctx = null
|
||||
|
||||
/** Called exactly once, by server/index.js, at the top of register(). */
|
||||
function init(next) {
|
||||
if (ctx) throw new Error('module-uo: core.init() called twice')
|
||||
ctx = next
|
||||
}
|
||||
|
||||
function require_() {
|
||||
if (!ctx) throw new Error('module-uo: core used before register() ran')
|
||||
return ctx
|
||||
}
|
||||
|
||||
// Forwarders rather than re-exports: `const { query } = require('./core')`
|
||||
// destructures at require time, which is before init(), so a plain re-export
|
||||
// would capture undefined. Each of these resolves ctx at CALL time.
|
||||
const query = (sql, params) => require_().db.query(sql, params)
|
||||
|
||||
const logger = (namespace) => require_().log(namespace)
|
||||
|
||||
const settings = {
|
||||
get: (key) => require_().settings.get(key),
|
||||
// `updatedBy` is the third parameter core's settings.model.set carries — the
|
||||
// atlas path setter passes it (shardAtlas.model.js:60), so dropping it here
|
||||
// would silently lose the audit attribution rather than fail.
|
||||
set: (key, value, updatedBy) => require_().settings.set(key, value, updatedBy),
|
||||
getInstanceName: () => require_().settings.getInstanceName(),
|
||||
}
|
||||
|
||||
const auth = {
|
||||
getUserFromRequest: (req) => require_().auth.getUserFromRequest(req),
|
||||
}
|
||||
|
||||
const middleware = {
|
||||
siteMode: (req, res, next) => require_().middleware.siteMode(req, res, next),
|
||||
validate: (req, res, next) => require_().middleware.validate(req, res, next),
|
||||
requireAuth: (req, res, next) => require_().middleware.requireAuth(req, res, next),
|
||||
noindex: (req, res, next) => require_().middleware.noindex(req, res, next),
|
||||
requireRole: (...roles) => {
|
||||
// requireRole is a FACTORY, so it must be resolved at call time and the
|
||||
// resulting middleware kept — resolving it per request would build a new
|
||||
// closure on every hit.
|
||||
let built = null
|
||||
return (req, res, next) => {
|
||||
built = built || require_().middleware.requireRole(...roles)
|
||||
return built(req, res, next)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
init,
|
||||
// Shared server dependencies, taken from core rather than required directly.
|
||||
// A module lives outside server/, so `require('express')` from here does not
|
||||
// resolve at all — and even where it did, a second express in the process
|
||||
// would be a second Router prototype. Same rule as React on the client.
|
||||
get express() { return require_().express },
|
||||
get validator() { return require_().validator },
|
||||
query,
|
||||
logger,
|
||||
settings,
|
||||
auth,
|
||||
middleware,
|
||||
get pool() { return require_().db.pool },
|
||||
get secretBox() { return require_().secretBox },
|
||||
get push() { return require_().push },
|
||||
get uploads() { return require_().uploads },
|
||||
get posts() { return require_().posts },
|
||||
get paths() { return require_().paths },
|
||||
get moduleId() { return require_().moduleId },
|
||||
}
|
||||
21
modules/uo/server/db/purge.sql
Normal file
21
modules/uo/server/db/purge.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
-- ── module-uo · purge ──────────────────────────────────────────────────────
|
||||
--
|
||||
-- DESTRUCTIVE. Run ONLY by the explicit admin purge action, never by uninstall
|
||||
-- (docs/website/MODULE_API.md §2.6) — uninstalling a module removes its code and
|
||||
-- retains its data, and an operator who wants the data gone has to say so.
|
||||
--
|
||||
-- Required because this module declares a schema fragment: a module that can
|
||||
-- create tables and cannot drop them leaves an operator with orphaned data and
|
||||
-- no supported way to remove it.
|
||||
--
|
||||
-- Dropped children-first even though these tables carry no foreign keys, so the
|
||||
-- order stays correct if Phase 3 adds one.
|
||||
|
||||
DROP TABLE IF EXISTS shard_atlas_pending;
|
||||
DROP TABLE IF EXISTS shard_atlas_meta;
|
||||
DROP TABLE IF EXISTS shard_champion_spawns;
|
||||
DROP TABLE IF EXISTS shard_landmarks;
|
||||
DROP TABLE IF EXISTS shard_regions;
|
||||
DROP TABLE IF EXISTS shard_spawn_point_types;
|
||||
DROP TABLE IF EXISTS shard_spawn_points;
|
||||
DROP TABLE IF EXISTS shard_spawn_creatures;
|
||||
166
modules/uo/server/db/schema.sql
Normal file
166
modules/uo/server/db/schema.sql
Normal file
@@ -0,0 +1,166 @@
|
||||
-- ── module-uo · schema fragment ────────────────────────────────────────────
|
||||
--
|
||||
-- Replayed by core's ensureSchema() immediately after core's own schema.sql,
|
||||
-- statement by statement, split the same way (docs/website/MODULE_API.md §2.6).
|
||||
-- It inherits core's rules because it goes through core's splitter: idempotent
|
||||
-- CREATE/ALTER only, no DROP, and no `--` inside a string literal.
|
||||
--
|
||||
-- SPIKE SCOPE: the eight spawn-atlas tables, lifted verbatim out of
|
||||
-- server/db/schema.sql. Phase 3 brings the other nineteen.
|
||||
--
|
||||
-- These names are NOT `uo_`-prefixed, which the contract otherwise requires of a
|
||||
-- module's tables. module-uo is grandfathered by an explicit allowlist in the
|
||||
-- loader: renaming twenty-seven live tables is a data migration this workstream
|
||||
-- deliberately does not do, and the prefix rule holds for every module written
|
||||
-- after this one.
|
||||
|
||||
-- ── 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;
|
||||
59
modules/uo/server/index.js
Normal file
59
modules/uo/server/index.js
Normal file
@@ -0,0 +1,59 @@
|
||||
// ── module-uo · server entry point ─────────────────────────────────────────
|
||||
//
|
||||
// SPIKE SCOPE. Phase 1 carries only /api/v1/public/atlas/* out of core
|
||||
// (MODULE_SYSTEM.md §2.7): six routes, DB-backed, no sidecar, no SSE, one boot
|
||||
// hook. Phase 3 brings the rest — the other 12 router/controller files, the 7
|
||||
// remaining model directories, the notification-stream catalog and the
|
||||
// town-crier announce leg.
|
||||
//
|
||||
// Called ONCE, synchronously, during the website's app.js require. Everything
|
||||
// here must therefore be synchronous and must not touch the database: the route
|
||||
// manifest generator and the OpenAPI generator both require app.js with the
|
||||
// pool pointed at a dead port, and a module that queried here would hang both
|
||||
// (MODULE_API.md §2.2). Anything needing a live database goes in onBoot.
|
||||
|
||||
const core = require('./core')
|
||||
|
||||
module.exports = function register(ctx, api) {
|
||||
// Park ctx before requiring anything that reads it. The requires below pull in
|
||||
// the model layer, whose files resolve core lazily — but the ORDER still
|
||||
// matters for the router, which is constructed at require time.
|
||||
core.init(ctx)
|
||||
|
||||
/* eslint-disable global-require */
|
||||
const atlasRouter = require('./router/atlas.router')
|
||||
const atlas = require('./model/shardAtlas/shardAtlas.model')
|
||||
/* eslint-enable global-require */
|
||||
|
||||
const log = ctx.log('boot')
|
||||
|
||||
// The URL is unchanged from when this router lived in core's
|
||||
// router/v1/public/index.js — that is the point, and routes.manifest.json is
|
||||
// the proof (MODULE_API.md §5.3).
|
||||
api.registerRoutes({
|
||||
public: { '/atlas': atlasRouter },
|
||||
})
|
||||
|
||||
// Was server.js:92, an explicit call in core's start(). 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 write.
|
||||
//
|
||||
// Best-effort by contract: no configured path, an unreadable mount or a
|
||||
// malformed file must never stop the site coming up, and a refresh that would
|
||||
// REMOVE a facet is staged for admin approval instead of being applied. So it
|
||||
// is caught HERE rather than left to the loader — the loader's catch would be
|
||||
// correct about the failure but wrong about the severity, marking the module
|
||||
// startup_failed and 503-ing six routes that serve perfectly good stale data.
|
||||
api.onBoot(async () => {
|
||||
try {
|
||||
const result = await atlas.refreshOnBoot()
|
||||
log.info('spawn atlas refreshed', { status: result && result.status })
|
||||
} catch (err) {
|
||||
log.warn('spawn atlas refresh failed — serving whatever was last imported', {
|
||||
error: err.message,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
390
modules/uo/server/model/shardAtlas/shardAtlas.db.js
Normal file
390
modules/uo/server/model/shardAtlas/shardAtlas.db.js
Normal file
@@ -0,0 +1,390 @@
|
||||
const { pool, query } = require('../../core')
|
||||
|
||||
// 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')
|
||||
}
|
||||
|
||||
// ── Reads (the public /atlas surface) ──────────────────────────────────────
|
||||
//
|
||||
// Every read here is a plain indexed query over ~7k rows and is served entirely
|
||||
// from MariaDB: the atlas is static shard content, so nothing on this path
|
||||
// touches the sidecar and nothing degrades when the shard is down.
|
||||
//
|
||||
// A facet filter is expressed as EXISTS over the points, never as a JSON path
|
||||
// built from caller input. `shard_spawn_creatures.facets` is a JSON object keyed
|
||||
// by facet name, and matching a key means either concatenating the name into a
|
||||
// path or handing it to JSON_SEARCH — whose search string treats `%` and `_` as
|
||||
// wildcards, so `?facet=%` would quietly match everything. The join is exact and
|
||||
// uses the indexes that already exist.
|
||||
const CREATURE_FACET_EXISTS = `EXISTS (
|
||||
SELECT 1 FROM shard_spawn_point_types t
|
||||
JOIN shard_spawn_points p ON p.id = t.point_id
|
||||
WHERE t.slug = c.slug AND p.facet = ?
|
||||
)`
|
||||
|
||||
// Build the WHERE for a creature search. `q` is a substring match on the display
|
||||
// name — a LIKE scan, which is free at ~800 rows and, unlike FULLTEXT, has no
|
||||
// minimum token length to break a search for "orc".
|
||||
function creatureWhere({ q, facet }) {
|
||||
const where = []
|
||||
const params = []
|
||||
if (q) {
|
||||
where.push('c.name LIKE ?')
|
||||
params.push(`%${q}%`)
|
||||
}
|
||||
if (facet) {
|
||||
where.push(CREATURE_FACET_EXISTS)
|
||||
params.push(facet)
|
||||
}
|
||||
return { sql: where.length ? `WHERE ${where.join(' AND ')}` : '', params }
|
||||
}
|
||||
|
||||
async function countCreatures({ q = '', facet = '' } = {}) {
|
||||
const { sql, params } = creatureWhere({ q, facet })
|
||||
const rows = await query(`SELECT COUNT(*) AS n FROM shard_spawn_creatures c ${sql}`, params)
|
||||
return rows[0] ? Number(rows[0].n) : 0
|
||||
}
|
||||
|
||||
function listCreatures({ q = '', facet = '', limit = 50, offset = 0 } = {}) {
|
||||
const { sql, params } = creatureWhere({ q, facet })
|
||||
return query(
|
||||
`SELECT c.slug, c.name, c.total, c.points, c.facets, c.art
|
||||
FROM shard_spawn_creatures c
|
||||
${sql}
|
||||
ORDER BY c.total DESC, c.name ASC
|
||||
LIMIT ? OFFSET ?`,
|
||||
[...params, limit, offset],
|
||||
)
|
||||
}
|
||||
|
||||
async function getCreature(slug) {
|
||||
const rows = await query(
|
||||
'SELECT slug, name, total, points, facets, art FROM shard_spawn_creatures WHERE slug = ?',
|
||||
[slug],
|
||||
)
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a creature spawns, grouped by resolved place.
|
||||
*
|
||||
* This is the answer the atlas exists to give — "lizardman → Shrines,
|
||||
* Isamu-Jima, Yew" — so it is aggregated in SQL rather than by summing 6,455
|
||||
* point rows in Node.
|
||||
*/
|
||||
function listCreaturePlaces(slug, { facet = '' } = {}) {
|
||||
const params = [slug]
|
||||
let facetSql = ''
|
||||
if (facet) {
|
||||
facetSql = 'AND p.facet = ?'
|
||||
params.push(facet)
|
||||
}
|
||||
return query(
|
||||
`SELECT p.facet, p.label, COUNT(*) AS spawners, SUM(t.max_count) AS max_alive
|
||||
FROM shard_spawn_point_types t
|
||||
JOIN shard_spawn_points p ON p.id = t.point_id
|
||||
WHERE t.slug = ? ${facetSql}
|
||||
GROUP BY p.facet, p.label
|
||||
ORDER BY spawners DESC, p.facet ASC, p.label ASC`,
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
/** The individual spawners for a creature, newest-largest first. Bounded. */
|
||||
function listCreaturePoints(slug, { facet = '', limit = 200 } = {}) {
|
||||
const params = [slug]
|
||||
let facetSql = ''
|
||||
if (facet) {
|
||||
facetSql = 'AND p.facet = ?'
|
||||
params.push(facet)
|
||||
}
|
||||
params.push(limit)
|
||||
return query(
|
||||
`SELECT p.id, p.facet, p.name, p.x, p.y, p.width, p.height, p.spawn_range,
|
||||
p.min_delay, p.max_delay, p.tod_start, p.tod_end, p.tod_mode,
|
||||
p.region, p.landmark, p.label, t.max_count
|
||||
FROM shard_spawn_point_types t
|
||||
JOIN shard_spawn_points p ON p.id = t.point_id
|
||||
WHERE t.slug = ? ${facetSql}
|
||||
ORDER BY t.max_count DESC, p.facet ASC, p.label ASC, p.id ASC
|
||||
LIMIT ?`,
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
/** Every other creature sharing a spawner with this one. */
|
||||
function listCreatureCompanions(slug, { limit = 24 } = {}) {
|
||||
return query(
|
||||
`SELECT o.slug, c.name, COUNT(*) AS shared
|
||||
FROM shard_spawn_point_types t
|
||||
JOIN shard_spawn_point_types o ON o.point_id = t.point_id AND o.slug <> t.slug
|
||||
JOIN shard_spawn_creatures c ON c.slug = o.slug
|
||||
WHERE t.slug = ?
|
||||
GROUP BY o.slug, c.name
|
||||
ORDER BY shared DESC, c.name ASC
|
||||
LIMIT ?`,
|
||||
[slug, limit],
|
||||
)
|
||||
}
|
||||
|
||||
function listRegions({ facet = '', q = '' } = {}) {
|
||||
const where = []
|
||||
const params = []
|
||||
if (facet) {
|
||||
where.push('facet = ?')
|
||||
params.push(facet)
|
||||
}
|
||||
if (q) {
|
||||
where.push('name LIKE ?')
|
||||
params.push(`%${q}%`)
|
||||
}
|
||||
return query(
|
||||
`SELECT facet, name, type, priority, parent, rects
|
||||
FROM shard_regions
|
||||
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
|
||||
ORDER BY facet ASC, name ASC`,
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
function listLandmarks({ facet = '', q = '' } = {}) {
|
||||
const where = []
|
||||
const params = []
|
||||
if (facet) {
|
||||
where.push('facet = ?')
|
||||
params.push(facet)
|
||||
}
|
||||
if (q) {
|
||||
where.push('(name LIKE ? OR grp LIKE ?)')
|
||||
params.push(`%${q}%`, `%${q}%`)
|
||||
}
|
||||
return query(
|
||||
`SELECT facet, name, grp, x, y, z
|
||||
FROM shard_landmarks
|
||||
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
|
||||
ORDER BY facet ASC, grp ASC, name ASC`,
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
function listChampions({ facet = '' } = {}) {
|
||||
const params = []
|
||||
let where = ''
|
||||
if (facet) {
|
||||
where = 'WHERE facet = ?'
|
||||
params.push(facet)
|
||||
}
|
||||
return query(
|
||||
`SELECT slug, name, grp, type, random_type, facet, x, y, z, radius, label
|
||||
FROM shard_champion_spawns
|
||||
${where}
|
||||
ORDER BY facet ASC, name ASC`,
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
replaceAtlas,
|
||||
getMeta,
|
||||
getFacets,
|
||||
getPending,
|
||||
setPending,
|
||||
clearPending,
|
||||
countCreatures,
|
||||
listCreatures,
|
||||
getCreature,
|
||||
listCreaturePlaces,
|
||||
listCreaturePoints,
|
||||
listCreatureCompanions,
|
||||
listRegions,
|
||||
listLandmarks,
|
||||
listChampions,
|
||||
}
|
||||
485
modules/uo/server/model/shardAtlas/shardAtlas.model.js
Normal file
485
modules/uo/server/model/shardAtlas/shardAtlas.model.js
Normal file
@@ -0,0 +1,485 @@
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const db = require('./shardAtlas.db')
|
||||
const { settings } = require('../../core')
|
||||
const { slugify } = require('../../utils/spawnAtlasParse')
|
||||
const {
|
||||
AtlasSourceError,
|
||||
PARSER_VERSION,
|
||||
buildAtlas,
|
||||
hashSources,
|
||||
sameSources,
|
||||
} = require('../../utils/spawnAtlasSource')
|
||||
const log = require('../../core').logger('atlas')
|
||||
|
||||
// The spawn atlas, refreshed from the shard's own ServUO tree.
|
||||
//
|
||||
// The tree is the single source of truth. Nothing is precomputed and committed,
|
||||
// because a shard's maps change over its lifetime — facets get added, replaced
|
||||
// or renamed — and a snapshot in the repo would go stale against the world
|
||||
// players actually see. So the atlas is re-derived on every boot.
|
||||
//
|
||||
// Two rules govern the boot path:
|
||||
//
|
||||
// 1. **It never blocks startup.** No configured path, an unreadable path, a
|
||||
// malformed file, a database error — all of it is caught and logged. The
|
||||
// site comes up either way, serving whatever atlas it already had.
|
||||
// 2. **A facet disappearing is not applied automatically.** Losing a facet is
|
||||
// the signature of a half-copied or mid-update tree as much as of a real
|
||||
// map change, and the two are indistinguishable from here. The refresh is
|
||||
// staged for a human instead, and an admin approves or rejects it.
|
||||
//
|
||||
// Everything else — new facets, renamed regions, changed spawns — applies
|
||||
// straight away, because none of it can silently destroy data an operator would
|
||||
// miss.
|
||||
|
||||
const SETTING_KEY = 'spawn_atlas_servuo_path'
|
||||
|
||||
/**
|
||||
* Where the ServUO tree lives.
|
||||
*
|
||||
* The admin setting wins over the environment so an operator can point the
|
||||
* atlas at a different tree without a redeploy, matching how the rest of the
|
||||
* shard integration is admin-managed rather than env-configured. `SERVUO_PATH`
|
||||
* remains as the deploy-time default, since the path usually describes a mount
|
||||
* that the deployment sets up.
|
||||
*/
|
||||
async function getServuoPath() {
|
||||
try {
|
||||
const configured = await settings.get(SETTING_KEY)
|
||||
if (configured && String(configured).trim() !== '') return String(configured).trim()
|
||||
} catch {
|
||||
// Settings unavailable is not fatal — fall through to the env default.
|
||||
}
|
||||
const fromEnv = process.env.SERVUO_PATH
|
||||
return fromEnv && fromEnv.trim() !== '' ? fromEnv.trim() : ''
|
||||
}
|
||||
|
||||
async function setServuoPath(value, updatedBy = null) {
|
||||
return settings.set(SETTING_KEY, String(value ?? '').trim(), updatedBy)
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional operator-supplied art map, `{ "<slug>": "<file under uploads/atlas/>" }`.
|
||||
*
|
||||
* Never committed and never shipped — creature sprites come out of the
|
||||
* operator's own client `.mul`/`.uop` files, which are theirs, not ours to
|
||||
* redistribute. Absent (the normal case) every `art` stays NULL and the UI
|
||||
* renders text-only.
|
||||
*/
|
||||
function loadArtMap(dir = path.join(__dirname, '..', '..', '..', 'db', 'data')) {
|
||||
try {
|
||||
const file = path.join(dir, 'spawnAtlas.art.json')
|
||||
if (!fs.existsSync(file)) return {}
|
||||
const map = JSON.parse(fs.readFileSync(file, 'utf8'))
|
||||
return map && typeof map === 'object' ? map : {}
|
||||
} catch (err) {
|
||||
log.warn('spawn atlas art map could not be read', { error: err.message })
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten each point's types into `shard_spawn_point_types` rows.
|
||||
*
|
||||
* A spawner may legitimately list the same type twice, and the primary key is
|
||||
* (point_id, slug), so duplicates collapse to the larger max rather than
|
||||
* failing the insert.
|
||||
*/
|
||||
function pointTypeRows(points) {
|
||||
const rows = []
|
||||
points.forEach((point, i) => {
|
||||
const bySlug = new Map()
|
||||
for (const entry of point.types ?? []) {
|
||||
const slug = slugify(entry.type)
|
||||
if (slug === '') continue
|
||||
bySlug.set(slug, Math.max(bySlug.get(slug) ?? 0, entry.max ?? 1))
|
||||
}
|
||||
for (const [slug, max] of bySlug) rows.push([i + 1, slug, max])
|
||||
})
|
||||
return rows
|
||||
}
|
||||
|
||||
async function applyAtlas(atlas) {
|
||||
return db.replaceAtlas({ ...atlas, pointTypes: pointTypeRows(atlas.points) }, loadArtMap())
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the atlas from the configured ServUO tree.
|
||||
*
|
||||
* Returns a result describing what happened rather than throwing, so the caller
|
||||
* — including the boot path — can log it and move on:
|
||||
*
|
||||
* `skipped` no path configured
|
||||
* `unavailable` path configured but unreadable / missing required files
|
||||
* `unchanged` source hashes match the loaded atlas; nothing parsed
|
||||
* `imported` parsed and applied
|
||||
* `needsReview` parsed, but a facet would be lost; staged for an admin
|
||||
* `failed` parsed or applied and something went wrong
|
||||
*
|
||||
* `force` skips the hash check (an admin asking for a reimport) and `approve`
|
||||
* additionally accepts facet loss (an admin approving a staged refresh).
|
||||
*/
|
||||
/**
|
||||
* Was the loaded atlas built by THIS parser?
|
||||
*
|
||||
* An atlas imported before `parserVersion` existed reports undefined, which is
|
||||
* correctly "no" — those are exactly the ones carrying the old readings.
|
||||
*/
|
||||
const currentParser = (meta) => meta?.parserVersion === PARSER_VERSION
|
||||
|
||||
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
|
||||
|
||||
// Two things make a loaded atlas stale: the tree changed, or the PARSER did.
|
||||
// Only checking the tree would strand an install whose maps never change on
|
||||
// whatever an older build derived — a corrected parse would ship and never
|
||||
// reach the data.
|
||||
if (!force && sameSources(hashes, loaded) && currentParser(meta)) {
|
||||
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
|
||||
// Same question `refresh` asks: an import picks something up when either
|
||||
// the tree or the parser has moved on.
|
||||
drift = !sameSources(hashes, loaded) || !currentParser(meta)
|
||||
} 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 }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reads ──────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// The shapes the /public/atlas endpoints serve. Rows are camelCased here rather
|
||||
// than in the controller, for the same reason shardState does it: the column
|
||||
// names are an implementation detail of the import, and the browser contract
|
||||
// should not move when a column is renamed.
|
||||
|
||||
const jsonOr = (value, fallback) => {
|
||||
if (value == null) return fallback
|
||||
if (typeof value !== 'string') return value
|
||||
try {
|
||||
return JSON.parse(value)
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
const shapeCreature = (row) => ({
|
||||
slug: row.slug,
|
||||
name: row.name,
|
||||
// `total` is the summed MaxCount across every spawner (how many can be alive
|
||||
// at once); `points` is how many spawners mention it. They answer different
|
||||
// questions and the UI shows both.
|
||||
total: row.total,
|
||||
points: row.points,
|
||||
facets: jsonOr(row.facets, {}),
|
||||
art: row.art || null,
|
||||
})
|
||||
|
||||
const shapePlace = (row) => ({
|
||||
facet: row.facet,
|
||||
label: row.label,
|
||||
spawners: Number(row.spawners) || 0,
|
||||
maxAlive: Number(row.max_alive) || 0,
|
||||
})
|
||||
|
||||
const shapePoint = (row) => ({
|
||||
id: row.id,
|
||||
facet: row.facet,
|
||||
name: row.name || null,
|
||||
x: row.x,
|
||||
y: row.y,
|
||||
width: row.width,
|
||||
height: row.height,
|
||||
range: row.spawn_range,
|
||||
maxCount: row.max_count,
|
||||
minDelay: row.min_delay,
|
||||
maxDelay: row.max_delay,
|
||||
todStart: row.tod_start,
|
||||
todEnd: row.tod_end,
|
||||
todMode: row.tod_mode,
|
||||
region: row.region || null,
|
||||
landmark: row.landmark || null,
|
||||
label: row.label,
|
||||
})
|
||||
|
||||
/**
|
||||
* Paginated creature search. Returns the page plus the unpaginated total, so
|
||||
* the UI can say "showing 50 of 800" without a second round trip.
|
||||
*/
|
||||
async function searchCreatures({ q = '', facet = '', limit = 50, offset = 0 } = {}) {
|
||||
const [rows, total] = await Promise.all([
|
||||
db.listCreatures({ q, facet, limit, offset }),
|
||||
db.countCreatures({ q, facet }),
|
||||
])
|
||||
return { total, limit, offset, creatures: rows.map(shapeCreature) }
|
||||
}
|
||||
|
||||
/**
|
||||
* One creature: its totals, the places it spawns (the aggregate the atlas
|
||||
* exists for), the individual spawners, and what else shares those spawners.
|
||||
*
|
||||
* `null` when the slug is unknown — the controller turns that into a 404.
|
||||
*/
|
||||
async function getCreature(slug, { facet = '', points = 200 } = {}) {
|
||||
const row = await db.getCreature(slug)
|
||||
if (!row) return null
|
||||
const [places, pointRows, alsoHere] = await Promise.all([
|
||||
db.listCreaturePlaces(slug, { facet }),
|
||||
db.listCreaturePoints(slug, { facet, limit: points }),
|
||||
db.listCreatureCompanions(slug),
|
||||
])
|
||||
return {
|
||||
...shapeCreature(row),
|
||||
places: places.map(shapePlace),
|
||||
// `spawners`, not `points`: shapeCreature already uses `points` for the
|
||||
// COUNT of spawners, and reusing the key for the list of them would make the
|
||||
// same field a number on the search route and an array here.
|
||||
spawners: pointRows.map(shapePoint),
|
||||
// Bounded by the query, so a creature on hundreds of spawners returns a page
|
||||
// rather than the world.
|
||||
spawnersTruncated: pointRows.length >= points,
|
||||
alsoHere: alsoHere.map((r) => ({
|
||||
slug: r.slug,
|
||||
name: r.name,
|
||||
shared: Number(r.shared) || 0,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
async function listRegions(opts = {}) {
|
||||
const rows = await db.listRegions(opts)
|
||||
return rows.map((r) => ({
|
||||
facet: r.facet,
|
||||
name: r.name,
|
||||
type: r.type || null,
|
||||
priority: r.priority,
|
||||
parent: r.parent || null,
|
||||
rects: jsonOr(r.rects, []),
|
||||
}))
|
||||
}
|
||||
|
||||
async function listLandmarks(opts = {}) {
|
||||
const rows = await db.listLandmarks(opts)
|
||||
return rows.map((r) => ({
|
||||
facet: r.facet,
|
||||
name: r.name,
|
||||
group: r.grp || null,
|
||||
x: r.x,
|
||||
y: r.y,
|
||||
z: r.z,
|
||||
}))
|
||||
}
|
||||
|
||||
async function listChampions(opts = {}) {
|
||||
const rows = await db.listChampions(opts)
|
||||
return rows.map((r) => ({
|
||||
slug: r.slug,
|
||||
name: r.name,
|
||||
group: r.grp || null,
|
||||
// '' on the wire means "randomised at activation"; `randomType` says so
|
||||
// explicitly rather than making the client infer it from an empty string.
|
||||
type: r.type || null,
|
||||
randomType: !!r.random_type,
|
||||
facet: r.facet,
|
||||
x: r.x,
|
||||
y: r.y,
|
||||
z: r.z,
|
||||
radius: r.radius,
|
||||
label: r.label || null,
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* What is loaded: the facet list, the counts, and when it was imported.
|
||||
*
|
||||
* Deliberately does NOT report the source path, the per-file hashes or whether
|
||||
* a refresh is pending. Those describe the operator's filesystem, and this is a
|
||||
* public endpoint; the admin status route carries them instead.
|
||||
*/
|
||||
async function publicMeta() {
|
||||
const [meta, facets] = await Promise.all([
|
||||
db.getMeta().catch(() => null),
|
||||
db.getFacets().catch(() => []),
|
||||
])
|
||||
return {
|
||||
importedAt: meta?.importedAt ?? null,
|
||||
generatedAt: meta?.generatedAt ?? null,
|
||||
// The parse counts, not the row counts: `unresolvedPoints` is what lets the
|
||||
// page state its own placement accuracy instead of implying it is complete.
|
||||
counts: meta?.counts ?? null,
|
||||
facets,
|
||||
}
|
||||
}
|
||||
|
||||
const listFacets = () => db.getFacets()
|
||||
|
||||
module.exports = {
|
||||
refresh,
|
||||
refreshOnBoot,
|
||||
approvePending,
|
||||
rejectPending,
|
||||
status,
|
||||
getServuoPath,
|
||||
setServuoPath,
|
||||
pointTypeRows,
|
||||
loadArtMap,
|
||||
SETTING_KEY,
|
||||
searchCreatures,
|
||||
getCreature,
|
||||
listRegions,
|
||||
listLandmarks,
|
||||
listChampions,
|
||||
listFacets,
|
||||
publicMeta,
|
||||
}
|
||||
42
modules/uo/server/model/shardLinks/shardLinks.db.js
Normal file
42
modules/uo/server/model/shardLinks/shardLinks.db.js
Normal file
@@ -0,0 +1,42 @@
|
||||
const { query } = require('../../core')
|
||||
|
||||
const COLS = 'account, user_id, char_name, linked_at'
|
||||
|
||||
// Upsert a link. account is the PK, so a re-link moves the account to the new
|
||||
// user (the sidecar already treats /link/confirm as authoritative).
|
||||
async function upsert({ account, userId, charName }) {
|
||||
await query(
|
||||
`INSERT INTO shard_account_links (account, user_id, char_name)
|
||||
VALUES (?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE user_id = VALUES(user_id), char_name = VALUES(char_name)`,
|
||||
[account, userId, charName || null],
|
||||
)
|
||||
return getByAccount(account)
|
||||
}
|
||||
|
||||
async function getByAccount(account) {
|
||||
const rows = await query(`SELECT ${COLS} FROM shard_account_links WHERE account = ? LIMIT 1`, [account])
|
||||
return rows[0] || null
|
||||
}
|
||||
|
||||
const listByUser = (userId) =>
|
||||
query(`SELECT ${COLS} FROM shard_account_links WHERE user_id = ? ORDER BY linked_at DESC`, [userId])
|
||||
|
||||
async function isOwnedBy(account, userId) {
|
||||
const rows = await query(
|
||||
'SELECT 1 FROM shard_account_links WHERE account = ? AND user_id = ? LIMIT 1',
|
||||
[account, userId],
|
||||
)
|
||||
return rows.length > 0
|
||||
}
|
||||
|
||||
const remove = (account, userId) =>
|
||||
query('DELETE FROM shard_account_links WHERE account = ? AND user_id = ?', [account, userId])
|
||||
|
||||
// Drop the mirror for an account regardless of which user held it — used to
|
||||
// reconcile when the tie is severed at the source (an in-game [unlink →
|
||||
// account.unlinked event, or a site-side DELETE /link/{account}).
|
||||
const removeByAccount = (account) =>
|
||||
query('DELETE FROM shard_account_links WHERE account = ?', [account])
|
||||
|
||||
module.exports = { upsert, getByAccount, listByUser, isOwnedBy, remove, removeByAccount }
|
||||
37
modules/uo/server/model/shardLinks/shardLinks.model.js
Normal file
37
modules/uo/server/model/shardLinks/shardLinks.model.js
Normal file
@@ -0,0 +1,37 @@
|
||||
// Site-side mirror of in-game-account → website-user links. The sidecar owns the
|
||||
// authoritative link (it tags the game account on /link/confirm); this model
|
||||
// records it locally so the player portal can list links and enforce ownership.
|
||||
|
||||
const db = require('./shardLinks.db')
|
||||
|
||||
function toSafe(row) {
|
||||
if (!row) return null
|
||||
return {
|
||||
account: row.account,
|
||||
userId: row.user_id,
|
||||
charName: row.char_name || null,
|
||||
linkedAt: row.linked_at,
|
||||
}
|
||||
}
|
||||
|
||||
async function link({ account, userId, charName }) {
|
||||
return toSafe(await db.upsert({ account, userId, charName }))
|
||||
}
|
||||
|
||||
async function listForUser(userId) {
|
||||
const rows = await db.listByUser(userId)
|
||||
return rows.map(toSafe)
|
||||
}
|
||||
|
||||
const ownsAccount = (account, userId) => db.isOwnedBy(account, userId)
|
||||
|
||||
async function getByAccount(account) {
|
||||
return toSafe(await db.getByAccount(account))
|
||||
}
|
||||
|
||||
const unlink = (account, userId) => db.remove(account, userId)
|
||||
|
||||
// Drop the local mirror for an account (source-of-truth severed elsewhere).
|
||||
const removeByAccount = (account) => db.removeByAccount(account)
|
||||
|
||||
module.exports = { link, listForUser, ownsAccount, getByAccount, unlink, removeByAccount }
|
||||
@@ -0,0 +1,37 @@
|
||||
const { query } = require('../../core')
|
||||
|
||||
// One row per shard feature. Absent rows are fine — utils/shardVisibility.js
|
||||
// compiles a default for every known feature and merges stored rows over it, so
|
||||
// a fresh install with an empty table behaves exactly as the site did pre-v3.
|
||||
|
||||
const COLS = 'feature, enabled, audience, stream, field_rules, updated_by, updated_at'
|
||||
|
||||
const listAll = () => query(`SELECT ${COLS} FROM shard_feature_visibility`)
|
||||
|
||||
const getOne = (feature) =>
|
||||
query(`SELECT ${COLS} FROM shard_feature_visibility WHERE feature = ?`, [feature])
|
||||
|
||||
// Upsert one feature's settings. `fieldRules` is stored as a JSON object of
|
||||
// {field: rung}; the caller has already stripped locked fields and validated
|
||||
// every rung against the ladder.
|
||||
const upsert = ({ feature, enabled, audience, stream, fieldRules, updatedBy }) =>
|
||||
query(
|
||||
`INSERT INTO shard_feature_visibility (feature, enabled, audience, stream, field_rules, updated_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
enabled = VALUES(enabled),
|
||||
audience = VALUES(audience),
|
||||
stream = VALUES(stream),
|
||||
field_rules = VALUES(field_rules),
|
||||
updated_by = VALUES(updated_by)`,
|
||||
[
|
||||
feature,
|
||||
enabled ? 1 : 0,
|
||||
audience,
|
||||
stream ? 1 : 0,
|
||||
fieldRules == null ? null : JSON.stringify(fieldRules),
|
||||
updatedBy ?? null,
|
||||
],
|
||||
)
|
||||
|
||||
module.exports = { listAll, getOne, upsert }
|
||||
@@ -0,0 +1,44 @@
|
||||
// ── Shard feature visibility (model) ───────────────────────────────────────
|
||||
//
|
||||
// Thin row-shaping layer over shardVisibility.db. The policy — the ladder, the
|
||||
// feature catalog, the locked fields, the kind→feature map — lives in
|
||||
// utils/shardVisibility.js; this file only reads and writes rows.
|
||||
|
||||
const db = require('./shardVisibility.db')
|
||||
|
||||
// The `field_rules` JSON column comes back as a string on the mariadb driver.
|
||||
function parseRules(raw) {
|
||||
if (raw == null) return {}
|
||||
if (typeof raw === 'object') return raw
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
const toSafe = (row) =>
|
||||
row && {
|
||||
feature: row.feature,
|
||||
enabled: !!row.enabled,
|
||||
audience: row.audience,
|
||||
stream: row.stream == null ? null : !!row.stream,
|
||||
fieldRules: parseRules(row.field_rules),
|
||||
updatedBy: row.updated_by,
|
||||
updatedAt: row.updated_at,
|
||||
}
|
||||
|
||||
async function listAll() {
|
||||
const rows = await db.listAll()
|
||||
return rows.map(toSafe)
|
||||
}
|
||||
|
||||
async function getOne(feature) {
|
||||
const rows = await db.getOne(feature)
|
||||
return toSafe(rows[0])
|
||||
}
|
||||
|
||||
const upsert = (entry) => db.upsert(entry)
|
||||
|
||||
module.exports = { listAll, getOne, upsert }
|
||||
134
modules/uo/server/router/atlas.controller.js
Normal file
134
modules/uo/server/router/atlas.controller.js
Normal file
@@ -0,0 +1,134 @@
|
||||
// ── Public: the spawn atlas ────────────────────────────────────────────────
|
||||
//
|
||||
// A browsable catalogue of what the shard CONTAINS — which creatures spawn,
|
||||
// where, how many, and which champion altars are configured. Everything here is
|
||||
// a plain indexed read of the tables the boot-time import fills from the shard's
|
||||
// own ServUO tree (docs/website/SPAWN_ATLAS.md).
|
||||
//
|
||||
// Two properties separate this from /public/shard/*:
|
||||
//
|
||||
// • **Nothing touches the sidecar.** The atlas is static shard content, not
|
||||
// live shard state, so these pages stay fully populated while the shard is
|
||||
// down. That is why the routes are mounted at /public/atlas and are
|
||||
// siteMode-gated like /posts and /wiki, rather than under /shard.
|
||||
// • **The live champion feed is a different thing.** `/atlas/champions` is the
|
||||
// configured roster ("there is an Unholy Terror altar in Deceit");
|
||||
// `/shard/champs` is the running state ("it is on level 3 right now").
|
||||
//
|
||||
// Every response is still passed through `projectFeature` for the `atlas`
|
||||
// feature. It declares no sensitive fields today, so the projection is a
|
||||
// no-op — but v3.md §3.6.1's rule is that a read path returning shard data and
|
||||
// not projecting is a bug, and the cost of honouring it is one call per handler
|
||||
// rather than a retrofit the first time a field needs gating.
|
||||
|
||||
const atlas = require('../model/shardAtlas/shardAtlas.model')
|
||||
const visibility = require('../utils/visibility')
|
||||
|
||||
const log = require('../core').logger('public-atlas')
|
||||
|
||||
const FEATURE = 'atlas'
|
||||
|
||||
// Query params arrive as strings; express-validator has already bounded them.
|
||||
const int = (value, fallback) => {
|
||||
const n = Number.parseInt(value, 10)
|
||||
return Number.isFinite(n) ? n : fallback
|
||||
}
|
||||
|
||||
const str = (value) => (typeof value === 'string' ? value.trim() : '')
|
||||
|
||||
// GET /public/atlas/creatures?q=&facet=&limit=&offset=
|
||||
async function getCreatures(req, res) {
|
||||
try {
|
||||
const page = await atlas.searchCreatures({
|
||||
q: str(req.query.q),
|
||||
facet: str(req.query.facet),
|
||||
limit: int(req.query.limit, 50),
|
||||
offset: int(req.query.offset, 0),
|
||||
})
|
||||
return res.json(await visibility.project(FEATURE, page, req))
|
||||
} catch (err) {
|
||||
log.error('atlas.getCreatures', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/atlas/creatures/:slug — one creature, with the places it spawns.
|
||||
//
|
||||
// 404 means "no such creature in this atlas", which also covers "the atlas has
|
||||
// never been imported" — an empty atlas has no slugs, and there is nothing more
|
||||
// specific to say to an anonymous caller.
|
||||
async function getCreature(req, res) {
|
||||
try {
|
||||
const creature = await atlas.getCreature(req.params.slug, {
|
||||
facet: str(req.query.facet),
|
||||
points: int(req.query.points, 200),
|
||||
})
|
||||
if (!creature) return res.status(404).json({ message: 'Not Found' })
|
||||
return res.json(await visibility.project(FEATURE, creature, req))
|
||||
} catch (err) {
|
||||
log.error('atlas.getCreature', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/atlas/regions?facet=&q=
|
||||
async function getRegions(req, res) {
|
||||
try {
|
||||
const regions = await atlas.listRegions({
|
||||
facet: str(req.query.facet),
|
||||
q: str(req.query.q),
|
||||
})
|
||||
return res.json(await visibility.project(FEATURE, regions, req))
|
||||
} catch (err) {
|
||||
log.error('atlas.getRegions', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/atlas/landmarks?facet=&q=
|
||||
async function getLandmarks(req, res) {
|
||||
try {
|
||||
const landmarks = await atlas.listLandmarks({
|
||||
facet: str(req.query.facet),
|
||||
q: str(req.query.q),
|
||||
})
|
||||
return res.json(await visibility.project(FEATURE, landmarks, req))
|
||||
} catch (err) {
|
||||
log.error('atlas.getLandmarks', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/atlas/champions?facet= — the CONFIGURED altar roster.
|
||||
async function getChampions(req, res) {
|
||||
try {
|
||||
const champions = await atlas.listChampions({ facet: str(req.query.facet) })
|
||||
return res.json(await visibility.project(FEATURE, champions, req))
|
||||
} catch (err) {
|
||||
log.error('atlas.getChampions', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// GET /public/atlas/meta — what is loaded: facets, counts, when it was imported.
|
||||
//
|
||||
// Public-safe by construction: the model omits the ServUO path, the per-file
|
||||
// hashes and the pending-refresh state, all of which describe the operator's
|
||||
// filesystem rather than the game world. The admin status route carries those.
|
||||
async function getMeta(req, res) {
|
||||
try {
|
||||
return res.json(await visibility.project(FEATURE, await atlas.publicMeta(), req))
|
||||
} catch (err) {
|
||||
log.error('atlas.getMeta', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getCreatures,
|
||||
getCreature,
|
||||
getRegions,
|
||||
getLandmarks,
|
||||
getChampions,
|
||||
getMeta,
|
||||
}
|
||||
132
modules/uo/server/router/atlas.router.js
Normal file
132
modules/uo/server/router/atlas.router.js
Normal file
@@ -0,0 +1,132 @@
|
||||
// Public · Atlas — the spawn atlas / bestiary. Static shard CONTENT derived from
|
||||
// the shard's own ServUO tree, not live shard state.
|
||||
//
|
||||
// Mounted at /api/v1/public/atlas by public/index.js. Two deliberate differences
|
||||
// from the /public/shard routes next door (docs/link/v3.md §6):
|
||||
//
|
||||
// • **Not under /shard.** Nothing here round-trips the sidecar, and the pages
|
||||
// stay fully populated while the shard is down. Mounting it under /shard
|
||||
// would imply a dependency it does not have.
|
||||
// • **siteMode-gated, like /posts and /wiki.** The shard routes are exempt
|
||||
// because shard status is wanted *during* maintenance; a bestiary is site
|
||||
// content and follows site content's rules.
|
||||
//
|
||||
// Every route also carries `requireFeature('atlas')` — 404 when an admin has
|
||||
// disabled the feature, 403 when the caller sits below its configured audience.
|
||||
// The default audience is `anonymous`, so these gates are inert until an admin
|
||||
// changes something.
|
||||
|
||||
// express and express-validator come from core, never from a require here: this
|
||||
// file lives outside server/, so Node's resolver would not find them, and a
|
||||
// second express in the process would be a second Router prototype
|
||||
// (docs/website/MODULE_API.md §2.3).
|
||||
const core = require('../core')
|
||||
const atlas = require('./atlas.controller')
|
||||
const { requireFeature } = require('../utils/visibility')
|
||||
|
||||
const { express, validator, middleware } = core
|
||||
const { param, query } = validator
|
||||
const { siteMode, validate } = middleware
|
||||
|
||||
const atlasRouter = express.Router()
|
||||
|
||||
// Facet names come from the shard's own files and are never validated against a
|
||||
// list — nothing in the codebase names a facet (§6.1 R2). Only the length is
|
||||
// bounded, and the query matches exactly, so an unknown name returns an empty
|
||||
// result rather than an error.
|
||||
const facetParam = query('facet').optional({ values: 'falsy' }).isString().isLength({ max: 40 })
|
||||
|
||||
atlasRouter.get(
|
||||
'/creatures',
|
||||
requireFeature('atlas'),
|
||||
// #swagger.tags = ['Public · Atlas']
|
||||
// #swagger.summary = 'Search the bestiary (paginated)'
|
||||
// #swagger.description = 'Every creature the shard spawns, most numerous first. `total` is how many can be alive at once across all spawners; `points` is how many spawners mention it; `facets` maps facet name to that creature\'s share on it. Static content parsed from the shard\'s ServUO tree — unaffected by the shard being offline.'
|
||||
// #swagger.parameters['q'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Substring match on the creature name (max 60 chars).' }
|
||||
// #swagger.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to creatures spawning on this facet. Facet names come from the shard\'s own files; an unknown one returns an empty page.' }
|
||||
// #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Page size, 1..100 (default 50).' }
|
||||
// #swagger.parameters['offset'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Rows to skip (default 0).' }
|
||||
/* #swagger.responses[200] = { description: 'A page of creatures plus the unpaginated total', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasCreaturePage" } } } } */
|
||||
/* #swagger.responses[403] = { description: 'The atlas feature is gated above this caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'The atlas feature is disabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
query('q').optional({ values: 'falsy' }).isString().isLength({ max: 60 }),
|
||||
facetParam,
|
||||
query('limit').optional().isInt({ min: 1, max: 100 }),
|
||||
query('offset').optional().isInt({ min: 0, max: 100000 }),
|
||||
validate,
|
||||
siteMode,
|
||||
atlas.getCreatures,
|
||||
)
|
||||
atlasRouter.get(
|
||||
'/creatures/:slug',
|
||||
requireFeature('atlas'),
|
||||
// #swagger.tags = ['Public · Atlas']
|
||||
// #swagger.summary = 'One creature: where it spawns, and what spawns with it'
|
||||
// #swagger.description = 'The answer the atlas exists to give. `places` is the aggregate — "lizardman → Shrines, Isamu-Jima, Yew" — resolved by point-in-rect against the shard\'s own region rectangles, falling back to the nearest landmark, else "Wilderness". `spawners` lists the individual spawn points (bounded; `spawnersTruncated` says when the list was cut), and `alsoHere` is what shares those spawners.'
|
||||
// #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Creature slug, e.g. lizardman.' }
|
||||
// #swagger.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Restrict places and spawners to one facet.' }
|
||||
// #swagger.parameters['points'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Max spawners to return, 1..1000 (default 200).' }
|
||||
/* #swagger.responses[200] = { description: 'The creature', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasCreature" } } } } */
|
||||
/* #swagger.responses[404] = { description: 'No such creature in this atlas (or the feature is disabled)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||
param('slug').isString().isLength({ min: 1, max: 120 }),
|
||||
facetParam,
|
||||
query('points').optional().isInt({ min: 1, max: 1000 }),
|
||||
validate,
|
||||
siteMode,
|
||||
atlas.getCreature,
|
||||
)
|
||||
atlasRouter.get(
|
||||
'/regions',
|
||||
requireFeature('atlas'),
|
||||
// #swagger.tags = ['Public · Atlas']
|
||||
// #swagger.summary = 'Named regions and their rectangles'
|
||||
// #swagger.description = 'Flattened out of the shard\'s nested Regions.xml. `priority` and the rectangles are what placed each spawn point, kept so the placement can be re-derived rather than taken on trust.'
|
||||
// #swagger.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to one facet.' }
|
||||
// #swagger.parameters['q'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Substring match on the region name.' }
|
||||
/* #swagger.responses[200] = { description: 'Regions, by facet then name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/AtlasRegion" } } } } } */
|
||||
facetParam,
|
||||
query('q').optional({ values: 'falsy' }).isString().isLength({ max: 60 }),
|
||||
validate,
|
||||
siteMode,
|
||||
atlas.getRegions,
|
||||
)
|
||||
atlasRouter.get(
|
||||
'/landmarks',
|
||||
requireFeature('atlas'),
|
||||
// #swagger.tags = ['Public · Atlas']
|
||||
// #swagger.summary = 'Points of interest (dungeon levels, town markers)'
|
||||
// #swagger.description = 'From the shard\'s Data/Locations files. `group` is the innermost enclosing parent ("Covetous"), which is the label worth showing over the individual marker ("Level 1").'
|
||||
// #swagger.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to one facet.' }
|
||||
// #swagger.parameters['q'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Substring match on the landmark name or its group.' }
|
||||
/* #swagger.responses[200] = { description: 'Landmarks, by facet then group', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/AtlasLandmark" } } } } } */
|
||||
facetParam,
|
||||
query('q').optional({ values: 'falsy' }).isString().isLength({ max: 60 }),
|
||||
validate,
|
||||
siteMode,
|
||||
atlas.getLandmarks,
|
||||
)
|
||||
atlasRouter.get(
|
||||
'/champions',
|
||||
requireFeature('atlas'),
|
||||
// #swagger.tags = ['Public · Atlas']
|
||||
// #swagger.summary = 'Configured champion altars (the roster, not the live board)'
|
||||
// #swagger.description = 'Where the altars are and what each one summons — "there is an Unholy Terror altar in Deceit". `randomType` marks altars whose champion is drawn at activation. Do not conflate this with GET /public/shard/champs, which is the live sidecar-fed board ("it is on level 3 right now").'
|
||||
// #swagger.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to one facet.' }
|
||||
/* #swagger.responses[200] = { description: 'Altars, by facet then name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/AtlasChampion" } } } } } */
|
||||
facetParam,
|
||||
validate,
|
||||
siteMode,
|
||||
atlas.getChampions,
|
||||
)
|
||||
atlasRouter.get(
|
||||
'/meta',
|
||||
requireFeature('atlas'),
|
||||
// #swagger.tags = ['Public · Atlas']
|
||||
// #swagger.summary = 'What atlas is loaded: facets, counts, when it was imported'
|
||||
// #swagger.description = 'Drives the facet filter and the "parsed from the shard\'s own files on <date>" line. Reports the game world only — the ServUO path, the per-file hashes and any pending refresh are operator detail and live on the admin status route.'
|
||||
/* #swagger.responses[200] = { description: 'Atlas metadata', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasMeta" } } } } */
|
||||
siteMode,
|
||||
atlas.getMeta,
|
||||
)
|
||||
|
||||
module.exports = atlasRouter
|
||||
60
modules/uo/server/test/_ctx.js
Normal file
60
modules/uo/server/test/_ctx.js
Normal file
@@ -0,0 +1,60 @@
|
||||
// ── Test harness: a fake ctx ───────────────────────────────────────────────
|
||||
//
|
||||
// A module's tests cannot require core — that is the whole zero-internal-imports
|
||||
// rule (docs/website/MODULE_API.md §5.1), and it applies to test files too. So
|
||||
// instead of stubbing core's modules the way core's own tests do, a module test
|
||||
// hands `core.init()` a ctx it fabricated.
|
||||
//
|
||||
// That turns out to be the nicer story: the seam that exists so a module can be
|
||||
// swapped onto a different core is the same seam that lets its tests run with no
|
||||
// database, no express app and no settings table. Core's tests reach the same
|
||||
// place by pointing the mariadb pool at a dead port; a module does not have to.
|
||||
|
||||
const core = require('../core')
|
||||
|
||||
/**
|
||||
* Build and install a fake ctx. Every member is a stub the test can reassign.
|
||||
* @param {object} [over] members to override, deep-merged one level
|
||||
*/
|
||||
function installFakeCtx(over = {}) {
|
||||
const settings = new Map()
|
||||
|
||||
const ctx = {
|
||||
moduleId: 'uo',
|
||||
paths: { moduleRoot: require('path').join(__dirname, '..', '..') },
|
||||
// Null, not the real packages: a module cannot resolve express from outside
|
||||
// server/ (that is why ctx carries them at all), and these tests construct no
|
||||
// router. A test that needs one passes the real ones in `over`.
|
||||
express: null,
|
||||
validator: null,
|
||||
db: {
|
||||
// Every test that needs a query result reassigns this.
|
||||
query: async () => [],
|
||||
pool: { getConnection: async () => { throw new Error('no pool in tests') } },
|
||||
},
|
||||
log: () => ({ error() {}, warn() {}, info() {}, debug() {} }),
|
||||
settings: {
|
||||
get: async (key) => (settings.has(key) ? settings.get(key) : null),
|
||||
set: async (key, value) => { settings.set(key, value) },
|
||||
getInstanceName: async () => 'Test Shard',
|
||||
},
|
||||
auth: { getUserFromRequest: () => null },
|
||||
push: { publish: async () => {} },
|
||||
secretBox: { encrypt: (s) => s, decrypt: (s) => s },
|
||||
middleware: {
|
||||
requireAuth: (req, res, next) => next(),
|
||||
requireRole: () => (req, res, next) => next(),
|
||||
siteMode: (req, res, next) => next(),
|
||||
validate: (req, res, next) => next(),
|
||||
noindex: (req, res, next) => next(),
|
||||
},
|
||||
uploads: {},
|
||||
posts: {},
|
||||
...over,
|
||||
}
|
||||
|
||||
core.init(ctx)
|
||||
return ctx
|
||||
}
|
||||
|
||||
module.exports = { installFakeCtx }
|
||||
601
modules/uo/server/test/spawnAtlas.parse.test.js
Normal file
601
modules/uo/server/test/spawnAtlas.parse.test.js
Normal file
@@ -0,0 +1,601 @@
|
||||
const { test } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const {
|
||||
parseXml,
|
||||
parseObjects2,
|
||||
parsePoints,
|
||||
parseRegions,
|
||||
parseLocations,
|
||||
parseChampions,
|
||||
buildPlacementIndex,
|
||||
resolveRegion,
|
||||
facetKey,
|
||||
buildFacetIndex,
|
||||
resolveFacetName,
|
||||
slugify,
|
||||
decodeEntities,
|
||||
} = require('../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)
|
||||
// Delays are normalised to seconds; this record carries no DelayInSec, which
|
||||
// means minutes.
|
||||
assert.equal(covetous.minDelay, 300)
|
||||
assert.equal(covetous.maxDelay, 600)
|
||||
assert.deepEqual(covetous.types, [{ type: 'Lizardman', max: 3 }])
|
||||
// Dropped fields must not survive into the artifact — this is what keeps it
|
||||
// under 1 MB.
|
||||
assert.equal(covetous.uniqueId, undefined)
|
||||
assert.equal(covetous.proximityTriggerSound, undefined)
|
||||
})
|
||||
|
||||
test('parsePoints: IsRunning is parsed so the build can drop dead spawners', () => {
|
||||
const points = parsePoints(POINTS_XML)
|
||||
assert.equal(points[0].running, true)
|
||||
assert.equal(points[1].running, false)
|
||||
})
|
||||
|
||||
test('parsePoints: facet comes from <Map>, never the file name', () => {
|
||||
// Eodon.xml holds TerMur points; a file-name assumption would mislabel every
|
||||
// one of them.
|
||||
const points = parsePoints(
|
||||
'<Spawns><Points><Name>a</Name><Map>TerMur</Map><X>1</X><Y>2</Y></Points></Spawns>',
|
||||
)
|
||||
assert.equal(points[0].facet, 'TerMur')
|
||||
})
|
||||
|
||||
test('parsePoints: a record with no <Map> is skipped rather than misfiled', () => {
|
||||
const points = parsePoints('<Spawns><Points><Name>a</Name><X>1</X><Y>2</Y></Points></Spawns>')
|
||||
assert.deepEqual(points, [])
|
||||
})
|
||||
|
||||
test('parsePoints: empty document yields no points', () => {
|
||||
assert.deepEqual(parsePoints('<Spawns></Spawns>'), [])
|
||||
assert.deepEqual(parsePoints(''), [])
|
||||
})
|
||||
|
||||
// ── parseRegions ───────────────────────────────────────────────────────────
|
||||
|
||||
const REGIONS_XML = `<?xml version="1.0" encoding="utf-8"?>
|
||||
<ServerRegions>
|
||||
<Facet name="Felucca">
|
||||
<region type="GuardedRegion" priority="50" name="Moongates">
|
||||
<!-- britain -->
|
||||
<rect x="1330" y="1991" width="13" height="13" />
|
||||
<rect x="761" y="741" width="19" height="21" />
|
||||
</region>
|
||||
<region type="MondainRegion" priority="50" name="Prism of Light">
|
||||
<rect x="6400" y="0" width="221" height="255" />
|
||||
<go x="6474" y="188" z="0" />
|
||||
<music name="Dungeon9" />
|
||||
<region type="CrystalField" name="Crystal Field">
|
||||
<rect x="6506" y="83" width="7" height="7" />
|
||||
<zrange min="-4" />
|
||||
</region>
|
||||
<region type="IcyRiver">
|
||||
<rect x="6576" y="73" width="10" height="31" />
|
||||
</region>
|
||||
</region>
|
||||
<region type="TownRegion" priority="10" name="Music Only">
|
||||
<music name="Britain" />
|
||||
</region>
|
||||
</Facet>
|
||||
</ServerRegions>`
|
||||
|
||||
test('parseRegions: flattens nested regions and collects rects', () => {
|
||||
const regions = parseRegions(REGIONS_XML)
|
||||
const byName = new Map(regions.map((r) => [r.name, r]))
|
||||
assert.ok(byName.has('Moongates'))
|
||||
assert.ok(byName.has('Prism of Light'))
|
||||
assert.equal(byName.get('Moongates').rects.length, 2)
|
||||
assert.deepEqual(byName.get('Moongates').rects[0], {
|
||||
x: 1330,
|
||||
y: 1991,
|
||||
width: 13,
|
||||
height: 13,
|
||||
})
|
||||
assert.equal(byName.get('Moongates').facet, 'Felucca')
|
||||
assert.equal(byName.get('Moongates').type, 'GuardedRegion')
|
||||
})
|
||||
|
||||
test('parseRegions: a nested child records its parent', () => {
|
||||
const regions = parseRegions(REGIONS_XML)
|
||||
const crystal = regions.find((r) => r.name === 'Crystal Field')
|
||||
assert.ok(crystal, 'the nested named region should be indexed')
|
||||
assert.equal(crystal.parent, 'Prism of Light')
|
||||
assert.equal(crystal.facet, 'Felucca')
|
||||
})
|
||||
|
||||
test('parseRegions: a child with no priority inherits its parent', () => {
|
||||
// Defaulting to 0 instead would sort this specific room below every
|
||||
// top-level region that contains it.
|
||||
const crystal = parseRegions(REGIONS_XML).find((r) => r.name === 'Crystal Field')
|
||||
assert.equal(crystal.priority, 50)
|
||||
})
|
||||
|
||||
test('parseRegions: unnamed regions are skipped but still walked', () => {
|
||||
const regions = parseRegions(REGIONS_XML)
|
||||
// IcyRiver has a type but no name — it cannot label anything.
|
||||
assert.equal(regions.some((r) => r.type === 'IcyRiver'), false)
|
||||
})
|
||||
|
||||
test('parseRegions: a named region with no rects is not indexed', () => {
|
||||
// It can never contain a point, so indexing it only costs scan time.
|
||||
assert.equal(parseRegions(REGIONS_XML).some((r) => r.name === 'Music Only'), false)
|
||||
})
|
||||
|
||||
// ── parseLocations ─────────────────────────────────────────────────────────
|
||||
|
||||
const LOCATIONS_XML = `<?xml version="1.0" encoding="utf-8" standalone="yes" ?>
|
||||
<places>
|
||||
<parent name="Trammel">
|
||||
<parent name="Dungeons">
|
||||
<parent name="Covetous">
|
||||
<child name="Entrance" x="2499" y="919" z="0" />
|
||||
<child name="Level 1" x="5456" y="1863" z="0" />
|
||||
</parent>
|
||||
<parent name="Despise">
|
||||
<child name="Level 3" x="5407" y="859" z="45" />
|
||||
</parent>
|
||||
</parent>
|
||||
</parent>
|
||||
</places>`
|
||||
|
||||
test('parseLocations: flattens to points carrying their group', () => {
|
||||
const landmarks = parseLocations(LOCATIONS_XML)
|
||||
assert.equal(landmarks.length, 3)
|
||||
const level1 = landmarks.find((l) => l.name === 'Level 1')
|
||||
assert.equal(level1.x, 5456)
|
||||
assert.equal(level1.y, 1863)
|
||||
assert.equal(level1.z, 0)
|
||||
assert.equal(level1.facet, 'Trammel')
|
||||
// "Covetous" is the useful label, not "Level 1".
|
||||
assert.equal(level1.group, 'Covetous')
|
||||
// The facet-level parent is dropped from the path.
|
||||
assert.deepEqual(level1.path, ['Dungeons', 'Covetous'])
|
||||
})
|
||||
|
||||
// ── Facet canonicalisation ─────────────────────────────────────────────────
|
||||
|
||||
// Facets are NOT a fixed list — a shard may add, replace or rename them when its
|
||||
// maps are updated, so nothing may hardcode the stock six. Reconciliation is by
|
||||
// matching against whatever the shard's own files declare.
|
||||
|
||||
test('facetKey: collapses spelling differences to one key', () => {
|
||||
assert.equal(facetKey('Ter Mur'), facetKey('TerMur'))
|
||||
assert.equal(facetKey('ter-mur'), facetKey('TerMur'))
|
||||
assert.equal(facetKey('Felucca'), 'felucca')
|
||||
assert.equal(facetKey(''), '')
|
||||
assert.equal(facetKey(null), '')
|
||||
})
|
||||
|
||||
test('facetKey: distinct facets keep distinct keys', () => {
|
||||
assert.notEqual(facetKey('Felucca'), facetKey('Trammel'))
|
||||
})
|
||||
|
||||
test('resolveFacetName: matches a loose spelling to the discovered canonical', () => {
|
||||
// The canonical set comes from the shard's own spawn/region data, not a table.
|
||||
const index = buildFacetIndex(['TerMur', 'Tokuno', 'Felucca'])
|
||||
assert.equal(resolveFacetName('Ter Mur', index), 'TerMur')
|
||||
assert.equal(resolveFacetName('Tokuno Islands', index), 'Tokuno')
|
||||
assert.equal(resolveFacetName('felucca', index), 'Felucca')
|
||||
})
|
||||
|
||||
test('resolveFacetName: works for facets that do not exist in stock UO', () => {
|
||||
// The whole point: a shard running its own maps gets the same treatment as
|
||||
// the stock ones, with no entry anywhere naming them.
|
||||
const index = buildFacetIndex(['Sosaria', 'The Underdark'])
|
||||
assert.equal(resolveFacetName('sosaria', index), 'Sosaria')
|
||||
assert.equal(resolveFacetName('The Underdark', index), 'The Underdark')
|
||||
assert.equal(resolveFacetName('the-underdark', index), 'The Underdark')
|
||||
// Same shape as the real `Tokuno Islands` → `Tokuno` case.
|
||||
assert.equal(resolveFacetName('Sosaria Isles', index), 'Sosaria')
|
||||
})
|
||||
|
||||
test('resolveFacetName: a merely similar name is NOT forced to match', () => {
|
||||
// "Underdark Isles" is not a prefix of "The Underdark" in either direction.
|
||||
// Keeping its own name is right — a wrong match would silently file a real
|
||||
// custom facet's landmarks under the wrong facet.
|
||||
const index = buildFacetIndex(['The Underdark'])
|
||||
assert.equal(resolveFacetName('Underdark Isles', index), 'Underdark Isles')
|
||||
})
|
||||
|
||||
test('resolveFacetName: prefers the longer match when several could prefix', () => {
|
||||
const index = buildFacetIndex(['Tokuno', 'TokunoDeep'])
|
||||
assert.equal(resolveFacetName('TokunoDeep Reaches', index), 'TokunoDeep')
|
||||
})
|
||||
|
||||
test('resolveFacetName: an unmatched facet keeps its own name', () => {
|
||||
// Inventing a match would be worse than leaving a real custom facet alone.
|
||||
const index = buildFacetIndex(['Felucca'])
|
||||
assert.equal(resolveFacetName('Ilshenar', index), 'Ilshenar')
|
||||
assert.equal(resolveFacetName('', index), '')
|
||||
assert.equal(resolveFacetName(null, index), '')
|
||||
})
|
||||
|
||||
test('buildFacetIndex: first spelling wins and is stable', () => {
|
||||
const index = buildFacetIndex(['TerMur', 'Ter Mur', 'ter-mur'])
|
||||
assert.equal(index.size, 1)
|
||||
assert.equal(resolveFacetName('Ter Mur', index), 'TerMur')
|
||||
})
|
||||
|
||||
test('parsePoints and parseRegions report facet names verbatim', () => {
|
||||
// <Map> and <Facet name> are the authority; they are never rewritten.
|
||||
const points = parsePoints(
|
||||
'<Spawns><Points><Name>a</Name><Map>Sosaria</Map><X>1</X><Y>2</Y></Points></Spawns>',
|
||||
)
|
||||
assert.equal(points[0].facet, 'Sosaria')
|
||||
const regions = parseRegions(
|
||||
'<ServerRegions><Facet name="Sosaria"><region name="Town" priority="1">' +
|
||||
'<rect x="0" y="0" width="10" height="10"/></region></Facet></ServerRegions>',
|
||||
)
|
||||
assert.equal(regions[0].facet, 'Sosaria')
|
||||
})
|
||||
|
||||
test('placement index buckets two spellings of one facet together', () => {
|
||||
// This is the bug the key exists to prevent: unreconciled, the landmark bucket
|
||||
// is keyed apart from the points looking it up, the fallback never fires, and
|
||||
// every unregioned spawn on that facet silently reads "Wilderness".
|
||||
const index = buildPlacementIndex(
|
||||
[],
|
||||
[{ facet: 'Ter Mur', name: 'Bank', group: 'Holy City', path: [], x: 1000, y: 1000, z: 0 }],
|
||||
)
|
||||
assert.equal(resolveRegion(1000, 1000, 'TerMur', index).landmark, 'Holy City')
|
||||
})
|
||||
|
||||
// ── parseChampions ─────────────────────────────────────────────────────────
|
||||
|
||||
const CHAMPIONS_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<championSystem>
|
||||
<!-- comment describing the schema -->
|
||||
<spawn name="Deceit" group="FelDungeons" type="UnholyTerror">
|
||||
<location x="5178" y="708" z="20" map="Felucca" radius="60" />
|
||||
</spawn>
|
||||
<spawn name="Wandering" group="FelDungeons">
|
||||
<location x="100" y="200" z="0" map="Felucca" radius="40" />
|
||||
</spawn>
|
||||
</championSystem>`
|
||||
|
||||
test('parseChampions: reads altar name, type and location', () => {
|
||||
const champs = parseChampions(CHAMPIONS_XML)
|
||||
assert.equal(champs.length, 2)
|
||||
assert.deepEqual(champs[0], {
|
||||
name: 'Deceit',
|
||||
group: 'FelDungeons',
|
||||
type: 'UnholyTerror',
|
||||
randomType: false,
|
||||
facet: 'Felucca',
|
||||
x: 5178,
|
||||
y: 708,
|
||||
z: 20,
|
||||
radius: 60,
|
||||
})
|
||||
})
|
||||
|
||||
test('parseChampions: a spawn with no type is flagged random, not blank', () => {
|
||||
const champs = parseChampions(CHAMPIONS_XML)
|
||||
assert.equal(champs[1].randomType, true)
|
||||
assert.equal(champs[1].type, '')
|
||||
})
|
||||
|
||||
// ── resolveRegion ──────────────────────────────────────────────────────────
|
||||
|
||||
function fixtureIndex() {
|
||||
const regions = [
|
||||
{
|
||||
facet: 'Felucca',
|
||||
name: 'Britain',
|
||||
type: 'TownRegion',
|
||||
priority: 10,
|
||||
parent: null,
|
||||
rects: [{ x: 1000, y: 1000, width: 500, height: 500 }],
|
||||
},
|
||||
{
|
||||
facet: 'Felucca',
|
||||
name: 'Britain Bank',
|
||||
type: 'TownRegion',
|
||||
priority: 50,
|
||||
parent: 'Britain',
|
||||
rects: [{ x: 1400, y: 1400, width: 20, height: 20 }],
|
||||
},
|
||||
{
|
||||
facet: 'Felucca',
|
||||
name: 'Wide Low Priority',
|
||||
type: 'TownRegion',
|
||||
priority: 10,
|
||||
parent: null,
|
||||
rects: [{ x: 1000, y: 1000, width: 2000, height: 2000 }],
|
||||
},
|
||||
]
|
||||
const landmarks = [
|
||||
{ facet: 'Felucca', name: 'Level 1', group: 'Covetous', path: [], x: 5000, y: 5000, z: 0 },
|
||||
{ facet: 'Felucca', name: 'Far Away', group: 'Vesper', path: [], x: 9000, y: 9000, z: 0 },
|
||||
]
|
||||
return buildPlacementIndex(regions, landmarks)
|
||||
}
|
||||
|
||||
test('resolveRegion: a contained point takes the region name', () => {
|
||||
const result = resolveRegion(1100, 1100, 'Felucca', fixtureIndex())
|
||||
assert.equal(result.region, 'Britain')
|
||||
assert.equal(result.label, 'Britain')
|
||||
assert.equal(result.landmark, null)
|
||||
})
|
||||
|
||||
test('resolveRegion: higher priority wins over a containing region', () => {
|
||||
const result = resolveRegion(1410, 1410, 'Felucca', fixtureIndex())
|
||||
assert.equal(result.region, 'Britain Bank')
|
||||
})
|
||||
|
||||
test('resolveRegion: equal priority breaks toward the smaller rect', () => {
|
||||
// Both "Britain" (500x500) and "Wide Low Priority" (2000x2000) contain this
|
||||
// point at priority 10; the specific one must win.
|
||||
const result = resolveRegion(1200, 1200, 'Felucca', fixtureIndex())
|
||||
assert.equal(result.region, 'Britain')
|
||||
})
|
||||
|
||||
test('resolveRegion: rects are half-open — the far edge is outside', () => {
|
||||
const index = fixtureIndex()
|
||||
// Britain spans x 1000..1499. 1499 is in, 1500 belongs to the next region.
|
||||
assert.equal(resolveRegion(1499, 1499, 'Felucca', index).region, 'Britain')
|
||||
assert.equal(resolveRegion(1500, 1500, 'Felucca', index).region, 'Wide Low Priority')
|
||||
})
|
||||
|
||||
test('resolveRegion: falls back to the nearest landmark group', () => {
|
||||
const result = resolveRegion(5050, 5050, 'Felucca', fixtureIndex())
|
||||
assert.equal(result.region, null)
|
||||
assert.equal(result.landmark, 'Covetous')
|
||||
assert.equal(result.label, 'Covetous')
|
||||
})
|
||||
|
||||
test('resolveRegion: a landmark beyond the radius yields Wilderness', () => {
|
||||
// Without the radius cap the nearest landmark is always *some* landmark, and
|
||||
// open countryside would get labelled with a dungeon across the map.
|
||||
const result = resolveRegion(7000, 7000, 'Felucca', fixtureIndex())
|
||||
assert.equal(result.landmark, null)
|
||||
assert.equal(result.label, 'Wilderness')
|
||||
})
|
||||
|
||||
test('resolveRegion: the radius is configurable', () => {
|
||||
const wide = resolveRegion(7000, 7000, 'Felucca', fixtureIndex(), { landmarkRadius: 5000 })
|
||||
assert.equal(wide.label, 'Covetous')
|
||||
})
|
||||
|
||||
test('resolveRegion: an unknown facet degrades to Wilderness, not a throw', () => {
|
||||
const result = resolveRegion(1100, 1100, 'Malas', fixtureIndex())
|
||||
assert.equal(result.label, 'Wilderness')
|
||||
assert.equal(result.region, null)
|
||||
})
|
||||
|
||||
test('resolveRegion: does not leak across facets', () => {
|
||||
const index = buildPlacementIndex(
|
||||
[
|
||||
{
|
||||
facet: 'Trammel',
|
||||
name: 'Britain',
|
||||
type: 'TownRegion',
|
||||
priority: 10,
|
||||
parent: null,
|
||||
rects: [{ x: 1000, y: 1000, width: 500, height: 500 }],
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
assert.equal(resolveRegion(1100, 1100, 'Trammel', index).region, 'Britain')
|
||||
assert.equal(resolveRegion(1100, 1100, 'Felucca', index).region, null)
|
||||
})
|
||||
|
||||
// ── Tokenizer edge cases ───────────────────────────────────────────────────
|
||||
|
||||
test('parseXml: skips comments, declarations and DOCTYPE', () => {
|
||||
const root = parseXml(
|
||||
'<?xml version="1.0"?><!DOCTYPE r><r><!-- <fake a="b"/> --><a x="1"/></r>',
|
||||
)
|
||||
assert.equal(root.name, 'r')
|
||||
assert.equal(root.children.length, 1)
|
||||
assert.equal(root.children[0].name, 'a')
|
||||
assert.equal(root.children[0].attrs.x, '1')
|
||||
})
|
||||
|
||||
test('parseXml: a ">" inside an attribute value does not end the tag', () => {
|
||||
const root = parseXml('<r><a name="1 > 0" b="2"/></r>')
|
||||
assert.equal(root.children[0].attrs.name, '1 > 0')
|
||||
assert.equal(root.children[0].attrs.b, '2')
|
||||
})
|
||||
|
||||
test('parseXml: single-quoted attributes are read', () => {
|
||||
const root = parseXml("<r><a name='Mondain' /></r>")
|
||||
assert.equal(root.children[0].attrs.name, 'Mondain')
|
||||
})
|
||||
|
||||
test('parseXml: a stray closing tag is ignored, not fatal', () => {
|
||||
// Hand-maintained shard config: one malformed element should degrade to a
|
||||
// missing element, not abort an otherwise good build.
|
||||
const root = parseXml('<r><a/></b><c/></r>')
|
||||
assert.equal(root.name, 'r')
|
||||
assert.deepEqual(root.children.map((n) => n.name), ['a', 'c'])
|
||||
})
|
||||
|
||||
test('parseXml: empty or element-free input yields null', () => {
|
||||
assert.equal(parseXml(''), null)
|
||||
assert.equal(parseXml('<!-- only a comment -->'), null)
|
||||
})
|
||||
|
||||
test('decodeEntities: named, numeric and hex refs', () => {
|
||||
assert.equal(decodeEntities("Mondain's Legacy"), "Mondain's Legacy")
|
||||
assert.equal(decodeEntities('a & b'), 'a & b')
|
||||
assert.equal(decodeEntities('<tag>'), '<tag>')
|
||||
assert.equal(decodeEntities('AB'), 'AB')
|
||||
// An unknown entity is left alone rather than silently eaten.
|
||||
assert.equal(decodeEntities('&nosuch;'), '&nosuch;')
|
||||
})
|
||||
|
||||
test('parseXml: decodes entities in attribute values', () => {
|
||||
const root = parseXml('<r><region name="Mondain's Legacy" /></r>')
|
||||
assert.equal(root.children[0].attrs.name, "Mondain's Legacy")
|
||||
})
|
||||
|
||||
// ── slugify ────────────────────────────────────────────────────────────────
|
||||
|
||||
test('slugify: produces URL-safe keys', () => {
|
||||
assert.equal(slugify('Lizardman'), 'lizardman')
|
||||
assert.equal(slugify('Giant Spider'), 'giant-spider')
|
||||
assert.equal(slugify("Mondain's Legacy"), 'mondain-s-legacy')
|
||||
assert.equal(slugify(' Orc '), 'orc')
|
||||
})
|
||||
|
||||
// ── Respawn delays: the unit is per record ──────────────────────────────────
|
||||
// XmlSpawner writes minutes by default and switches to seconds only when a
|
||||
// delay does not divide into whole minutes, flagged by DelayInSec. `5` therefore
|
||||
// means five MINUTES on one spawner and five SECONDS on the next, and a reader
|
||||
// assuming either unit is wrong about the other — silently, since both are
|
||||
// plausible respawn times.
|
||||
const DELAY_XML = `<Spawns>
|
||||
<Points>
|
||||
<Name>Minutes</Name>
|
||||
<Map>Sosaria</Map>
|
||||
<X>1</X><Y>1</Y>
|
||||
<MinDelay>5</MinDelay>
|
||||
<MaxDelay>10</MaxDelay>
|
||||
<IsRunning>True</IsRunning>
|
||||
<Objects2>Orc:MX=1</Objects2>
|
||||
</Points>
|
||||
<Points>
|
||||
<Name>Seconds</Name>
|
||||
<Map>Sosaria</Map>
|
||||
<X>2</X><Y>2</Y>
|
||||
<DelayInSec>True</DelayInSec>
|
||||
<MinDelay>5</MinDelay>
|
||||
<MaxDelay>10</MaxDelay>
|
||||
<IsRunning>True</IsRunning>
|
||||
<Objects2>Orc:MX=1</Objects2>
|
||||
</Points>
|
||||
</Spawns>`
|
||||
|
||||
test('parsePoints: DelayInSec decides the unit, and both come out in seconds', () => {
|
||||
const [minutes, seconds] = parsePoints(DELAY_XML)
|
||||
assert.equal(minutes.minDelay, 300)
|
||||
assert.equal(minutes.maxDelay, 600)
|
||||
assert.equal(seconds.minDelay, 5)
|
||||
assert.equal(seconds.maxDelay, 10)
|
||||
})
|
||||
399
modules/uo/server/test/spawnAtlas.source.test.js
Normal file
399
modules/uo/server/test/spawnAtlas.source.test.js
Normal file
@@ -0,0 +1,399 @@
|
||||
// No dead-port pool trick here: a module test fabricates its ctx instead, so
|
||||
// there is no database to point anywhere (see test/_ctx.js).
|
||||
const fs = require('fs')
|
||||
const os = require('os')
|
||||
const path = require('path')
|
||||
|
||||
const { test, after, beforeEach } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const { installFakeCtx } = require('./_ctx')
|
||||
|
||||
installFakeCtx()
|
||||
|
||||
const {
|
||||
AtlasSourceError,
|
||||
aggregateCreatures,
|
||||
displayName,
|
||||
sameSources,
|
||||
hashSources,
|
||||
buildAtlas,
|
||||
PARSER_VERSION,
|
||||
} = require('../utils/spawnAtlasSource')
|
||||
const shardAtlas = require('../model/shardAtlas/shardAtlas.model')
|
||||
const atlasDb = require('../model/shardAtlas/shardAtlas.db')
|
||||
// The model captured this object at require time, so reassigning a method on it
|
||||
// is how a test stubs core — the module equivalent of core's own tests
|
||||
// monkey-patching a model.
|
||||
const { settings } = require('../core')
|
||||
|
||||
// ── A tiny synthetic ServUO tree ───────────────────────────────────────────
|
||||
//
|
||||
// Deliberately uses facets that do NOT exist in stock UO. The atlas must not
|
||||
// contain a built-in facet list anywhere: a shard may add facets, replace them
|
||||
// outright, or rename them when its maps are updated, and everything has to keep
|
||||
// working with no code change.
|
||||
|
||||
function writeTree(root, { facets = ['Sosaria'], includeChampions = true } = {}) {
|
||||
fs.mkdirSync(path.join(root, 'Spawns'), { recursive: true })
|
||||
fs.mkdirSync(path.join(root, 'Data', 'Locations'), { recursive: true })
|
||||
fs.mkdirSync(path.join(root, 'Config'), { recursive: true })
|
||||
|
||||
for (const facet of facets) {
|
||||
fs.writeFileSync(
|
||||
path.join(root, 'Spawns', `${facet}.xml`),
|
||||
`<Spawns>
|
||||
<Points><Name>${facet}A</Name><Map>${facet}</Map><X>1100</X><Y>1100</Y>
|
||||
<MaxCount>3</MaxCount><IsRunning>True</IsRunning>
|
||||
<Objects2>Lizardman:MX=3:SB=0:OBJ=Orc:MX=1:SB=0</Objects2></Points>
|
||||
<Points><Name>${facet}B</Name><Map>${facet}</Map><X>9000</X><Y>9000</Y>
|
||||
<MaxCount>1</MaxCount><IsRunning>True</IsRunning>
|
||||
<Objects2>lizardman:MX=2:SB=0</Objects2></Points>
|
||||
<Points><Name>${facet}Off</Name><Map>${facet}</Map><X>1</X><Y>1</Y>
|
||||
<MaxCount>1</MaxCount><IsRunning>False</IsRunning>
|
||||
<Objects2>Ghost:MX=1</Objects2></Points>
|
||||
</Spawns>`,
|
||||
'utf8',
|
||||
)
|
||||
// The location file names its facet differently from <Map>, the real
|
||||
// `Ter Mur` / `Tokuno Islands` drift.
|
||||
fs.writeFileSync(
|
||||
path.join(root, 'Data', 'Locations', `${facet.toLowerCase()}.xml`),
|
||||
`<places><parent name="${facet} Isles"><parent name="Deep Cave">
|
||||
<child name="Level 1" x="9010" y="9010" z="0" /></parent></parent></places>`,
|
||||
'utf8',
|
||||
)
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(root, 'Data', 'Regions.xml'),
|
||||
`<ServerRegions>${facets
|
||||
.map(
|
||||
(facet) => `<Facet name="${facet}">
|
||||
<region type="TownRegion" priority="10" name="${facet} City">
|
||||
<rect x="1000" y="1000" width="500" height="500" />
|
||||
</region></Facet>`,
|
||||
)
|
||||
.join('')}</ServerRegions>`,
|
||||
'utf8',
|
||||
)
|
||||
|
||||
if (includeChampions) {
|
||||
fs.writeFileSync(
|
||||
path.join(root, 'Config', 'ChampionSpawns.xml'),
|
||||
`<championSystem><spawn name="Deep" group="G" type="Terror">
|
||||
<location x="1100" y="1100" z="0" map="${facets[0]}" radius="40" />
|
||||
</spawn></championSystem>`,
|
||||
'utf8',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function tempTree(options) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'atlas-test-'))
|
||||
writeTree(root, options)
|
||||
return root
|
||||
}
|
||||
|
||||
// ── buildAtlas against a custom-facet tree ─────────────────────────────────
|
||||
|
||||
test('buildAtlas: works entirely on facets that do not exist in stock UO', () => {
|
||||
const root = tempTree({ facets: ['Sosaria', 'Underdark'] })
|
||||
const atlas = buildAtlas(root)
|
||||
assert.deepEqual(atlas.facets, ['Sosaria', 'Underdark'])
|
||||
assert.equal(atlas.meta.counts.facets, 2)
|
||||
})
|
||||
|
||||
test('buildAtlas: reconciles a location file that spells the facet differently', () => {
|
||||
// "Sosaria Isles" inside the file vs <Map>Sosaria</Map> — the same drift that
|
||||
// silently emptied the Ter Mur / Tokuno landmark buckets.
|
||||
const root = tempTree({ facets: ['Sosaria'] })
|
||||
const atlas = buildAtlas(root)
|
||||
assert.deepEqual([...new Set(atlas.landmarks.map((l) => l.facet))], ['Sosaria'])
|
||||
// And the fallback actually fires, rather than the point reading Wilderness.
|
||||
const far = atlas.points.find((p) => p.name === 'SosariaB')
|
||||
assert.equal(far.landmark, 'Deep Cave')
|
||||
assert.equal(far.label, 'Deep Cave')
|
||||
})
|
||||
|
||||
test('buildAtlas: resolves a contained point to its region', () => {
|
||||
const atlas = buildAtlas(tempTree({ facets: ['Sosaria'] }))
|
||||
const inCity = atlas.points.find((p) => p.name === 'SosariaA')
|
||||
assert.equal(inCity.region, 'Sosaria City')
|
||||
assert.equal(inCity.label, 'Sosaria City')
|
||||
})
|
||||
|
||||
test('buildAtlas: drops spawners that are switched off in-world', () => {
|
||||
const atlas = buildAtlas(tempTree({ facets: ['Sosaria'] }))
|
||||
assert.equal(atlas.points.some((p) => p.name === 'SosariaOff'), false)
|
||||
assert.equal(atlas.meta.counts.pointsDisabled, 1)
|
||||
})
|
||||
|
||||
test('buildAtlas: a champion altar resolves through the same placement index', () => {
|
||||
const atlas = buildAtlas(tempTree({ facets: ['Sosaria'] }))
|
||||
assert.equal(atlas.champions[0].label, 'Sosaria City')
|
||||
assert.equal(atlas.champions[0].facet, 'Sosaria')
|
||||
})
|
||||
|
||||
test('buildAtlas: a tree with no champion file still builds', () => {
|
||||
const atlas = buildAtlas(tempTree({ facets: ['Sosaria'], includeChampions: false }))
|
||||
assert.deepEqual(atlas.champions, [])
|
||||
})
|
||||
|
||||
test('buildAtlas: missing path and empty path raise typed errors', () => {
|
||||
assert.throws(() => buildAtlas(''), (err) => err instanceof AtlasSourceError && err.code === 'NO_PATH')
|
||||
assert.throws(
|
||||
() => buildAtlas(path.join(os.tmpdir(), 'definitely-not-a-servuo-tree-xyz')),
|
||||
(err) => err instanceof AtlasSourceError && err.code === 'NOT_FOUND',
|
||||
)
|
||||
})
|
||||
|
||||
test('buildAtlas: a directory with no spawn files raises rather than building empty', () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'atlas-empty-'))
|
||||
fs.mkdirSync(path.join(root, 'Data'), { recursive: true })
|
||||
fs.writeFileSync(path.join(root, 'Data', 'Regions.xml'), '<ServerRegions/>', 'utf8')
|
||||
assert.throws(() => buildAtlas(root), (err) => err.code === 'NO_SPAWNS')
|
||||
})
|
||||
|
||||
// ── Hashing ────────────────────────────────────────────────────────────────
|
||||
|
||||
test('hashSources: stable across reads, changes when a file changes', () => {
|
||||
const root = tempTree({ facets: ['Sosaria'] })
|
||||
const first = hashSources(root)
|
||||
assert.ok(sameSources(first, hashSources(root)))
|
||||
|
||||
fs.appendFileSync(path.join(root, 'Spawns', 'Sosaria.xml'), '<!-- edit -->', 'utf8')
|
||||
assert.equal(sameSources(first, hashSources(root)), false)
|
||||
})
|
||||
|
||||
test('sameSources: a missing or extra file is a difference', () => {
|
||||
assert.equal(sameSources({ a: '1' }, { a: '1', b: '2' }), false)
|
||||
assert.equal(sameSources({ a: '1' }, { a: '2' }), false)
|
||||
assert.equal(sameSources({ a: '1' }, { a: '1' }), true)
|
||||
assert.equal(sameSources(null, { a: '1' }), false)
|
||||
assert.equal(sameSources({ a: '1' }, null), false)
|
||||
})
|
||||
|
||||
// ── Aggregation ────────────────────────────────────────────────────────────
|
||||
|
||||
const POINTS = [
|
||||
{ facet: 'Sosaria', types: [{ type: 'Lizardman', max: 3 }, { type: 'Orc', max: 1 }] },
|
||||
{ facet: 'Sosaria', types: [{ type: 'Lizardman', max: 2 }] },
|
||||
{ facet: 'Underdark', types: [{ type: 'lizardman', max: 5 }] },
|
||||
]
|
||||
|
||||
test('aggregateCreatures: sums each type’s own max and counts per facet', () => {
|
||||
const lizardman = aggregateCreatures(POINTS).find((c) => c.slug === 'lizardman')
|
||||
assert.equal(lizardman.total, 10)
|
||||
assert.equal(lizardman.points, 3)
|
||||
assert.deepEqual(lizardman.facets, { Sosaria: 2, Underdark: 1 })
|
||||
})
|
||||
|
||||
test('aggregateCreatures: differing case collapses to one creature', () => {
|
||||
const creatures = aggregateCreatures(POINTS)
|
||||
assert.equal(creatures.filter((c) => c.slug === 'lizardman').length, 1)
|
||||
assert.deepEqual(creatures.map((c) => c.slug), ['lizardman', 'orc'])
|
||||
assert.equal(Object.hasOwn(creatures[0], 'spellings'), false)
|
||||
})
|
||||
|
||||
test('displayName: most common wins, ties break to the capitalised form', () => {
|
||||
assert.equal(displayName(new Map([['lizardman', 9], ['Lizardman', 2]])), 'lizardman')
|
||||
assert.equal(displayName(new Map([['lizardman', 5], ['Lizardman', 5]])), 'Lizardman')
|
||||
// Deterministic regardless of insertion order — a committed artifact is gone,
|
||||
// but a spurious diff in the DB on every restart would be just as wrong.
|
||||
assert.equal(
|
||||
displayName(new Map([['abc', 1], ['abd', 1]])),
|
||||
displayName(new Map([['abd', 1], ['abc', 1]])),
|
||||
)
|
||||
})
|
||||
|
||||
test('pointTypeRows: collapses a repeated type to the larger max', () => {
|
||||
// The primary key is (point_id, slug), so a duplicate would otherwise fail the
|
||||
// insert and take the whole transaction with it.
|
||||
const rows = shardAtlas.pointTypeRows([
|
||||
{ types: [{ type: 'Orc', max: 1 }, { type: 'orc', max: 4 }, { type: 'Rat', max: 2 }] },
|
||||
])
|
||||
assert.deepEqual(rows.sort(), [[1, 'orc', 4], [1, 'rat', 2]].sort())
|
||||
})
|
||||
|
||||
test('pointTypeRows: point ids are 1-based and line up with insert order', () => {
|
||||
const rows = shardAtlas.pointTypeRows([
|
||||
{ types: [{ type: 'A', max: 1 }] },
|
||||
{ types: [{ type: 'B', max: 1 }] },
|
||||
])
|
||||
assert.deepEqual(rows, [[1, 'a', 1], [2, 'b', 1]])
|
||||
})
|
||||
|
||||
// ── The refresh decision ───────────────────────────────────────────────────
|
||||
//
|
||||
// The boot path's two contracts: it never blocks startup, and it never applies a
|
||||
// facet removal on its own.
|
||||
|
||||
let applied
|
||||
let pendingRow
|
||||
let facetsInDb
|
||||
let metaRow
|
||||
|
||||
beforeEach(() => {
|
||||
applied = null
|
||||
pendingRow = null
|
||||
facetsInDb = []
|
||||
metaRow = null
|
||||
atlasDb.replaceAtlas = async (atlas) => {
|
||||
applied = atlas
|
||||
return { points: atlas.points.length, creatures: atlas.creatures.length }
|
||||
}
|
||||
atlasDb.getMeta = async () => metaRow
|
||||
atlasDb.getFacets = async () => facetsInDb
|
||||
atlasDb.getPending = async () => pendingRow
|
||||
atlasDb.setPending = async (payload, status) => {
|
||||
pendingRow = { ...payload, status }
|
||||
}
|
||||
atlasDb.clearPending = async () => {
|
||||
pendingRow = null
|
||||
}
|
||||
settings.get = async () => ''
|
||||
process.env.SERVUO_PATH = ''
|
||||
})
|
||||
|
||||
test('refresh: no configured path is skipped, not an error', async () => {
|
||||
const result = await shardAtlas.refresh()
|
||||
assert.equal(result.status, 'skipped')
|
||||
})
|
||||
|
||||
test('refresh: an unreadable tree reports unavailable rather than throwing', async () => {
|
||||
const result = await shardAtlas.refresh({ path: path.join(os.tmpdir(), 'no-such-tree-abc') })
|
||||
assert.equal(result.status, 'unavailable')
|
||||
assert.equal(result.code, 'NOT_FOUND')
|
||||
})
|
||||
|
||||
test('refresh: a fresh database imports', async () => {
|
||||
const root = tempTree({ facets: ['Sosaria'] })
|
||||
const result = await shardAtlas.refresh({ path: root })
|
||||
assert.equal(result.status, 'imported')
|
||||
assert.ok(applied)
|
||||
assert.deepEqual(result.addedFacets, ['Sosaria'])
|
||||
})
|
||||
|
||||
test('refresh: an unchanged tree parses nothing and writes nothing', async () => {
|
||||
const root = tempTree({ facets: ['Sosaria'] })
|
||||
metaRow = buildAtlas(root).meta
|
||||
const result = await shardAtlas.refresh({ path: root })
|
||||
assert.equal(result.status, 'unchanged')
|
||||
assert.equal(applied, null)
|
||||
})
|
||||
|
||||
// The hash gate alone would strand an install whose maps never change on
|
||||
// whatever an older build derived: a corrected parse would ship and never reach
|
||||
// the data, because the only thing compared is the tree.
|
||||
test('refresh: an unchanged tree is REIMPORTED when the parser has moved on', async () => {
|
||||
const root = tempTree({ facets: ['Sosaria'] })
|
||||
metaRow = { ...buildAtlas(root).meta, parserVersion: PARSER_VERSION - 1 }
|
||||
const result = await shardAtlas.refresh({ path: root })
|
||||
assert.equal(result.status, 'imported')
|
||||
assert.ok(applied)
|
||||
})
|
||||
|
||||
test('refresh: an atlas imported before parser versions existed is stale', async () => {
|
||||
const root = tempTree({ facets: ['Sosaria'] })
|
||||
const meta = buildAtlas(root).meta
|
||||
delete meta.parserVersion
|
||||
metaRow = meta
|
||||
const result = await shardAtlas.refresh({ path: root })
|
||||
assert.equal(result.status, 'imported')
|
||||
})
|
||||
|
||||
test('refresh: --force reimports an unchanged tree', async () => {
|
||||
const root = tempTree({ facets: ['Sosaria'] })
|
||||
metaRow = buildAtlas(root).meta
|
||||
const result = await shardAtlas.refresh({ path: root, force: true })
|
||||
assert.equal(result.status, 'imported')
|
||||
assert.ok(applied)
|
||||
})
|
||||
|
||||
test('refresh: a NEW facet applies straight away', async () => {
|
||||
// Additions cannot destroy anything an operator would miss.
|
||||
const root = tempTree({ facets: ['Sosaria', 'Underdark'] })
|
||||
facetsInDb = ['Sosaria']
|
||||
const result = await shardAtlas.refresh({ path: root })
|
||||
assert.equal(result.status, 'imported')
|
||||
assert.deepEqual(result.addedFacets, ['Underdark'])
|
||||
})
|
||||
|
||||
test('refresh: a REMOVED facet is staged, not applied', async () => {
|
||||
const root = tempTree({ facets: ['Sosaria'] })
|
||||
facetsInDb = ['Sosaria', 'Underdark']
|
||||
const result = await shardAtlas.refresh({ path: root })
|
||||
assert.equal(result.status, 'needsReview')
|
||||
assert.deepEqual(result.removedFacets, ['Underdark'])
|
||||
// The critical part: the existing atlas was left alone.
|
||||
assert.equal(applied, null)
|
||||
assert.equal(pendingRow.status, 'pending')
|
||||
})
|
||||
|
||||
test('refresh: approving applies the removal', async () => {
|
||||
const root = tempTree({ facets: ['Sosaria'] })
|
||||
facetsInDb = ['Sosaria', 'Underdark']
|
||||
await shardAtlas.refresh({ path: root })
|
||||
assert.equal(applied, null)
|
||||
|
||||
const result = await shardAtlas.approvePending({ path: root })
|
||||
assert.equal(result.status, 'imported')
|
||||
assert.ok(applied)
|
||||
assert.deepEqual(result.removedFacets, ['Underdark'])
|
||||
})
|
||||
|
||||
test('refresh: a rejected refresh does not re-prompt while the tree is unchanged', async () => {
|
||||
const root = tempTree({ facets: ['Sosaria'] })
|
||||
facetsInDb = ['Sosaria', 'Underdark']
|
||||
await shardAtlas.refresh({ path: root })
|
||||
await shardAtlas.rejectPending()
|
||||
assert.equal(pendingRow.status, 'rejected')
|
||||
|
||||
const again = await shardAtlas.refresh({ path: root })
|
||||
assert.equal(again.status, 'unchanged')
|
||||
assert.equal(applied, null)
|
||||
})
|
||||
|
||||
test('refresh: changing the tree asks again after a rejection', async () => {
|
||||
const root = tempTree({ facets: ['Sosaria'] })
|
||||
facetsInDb = ['Sosaria', 'Underdark']
|
||||
await shardAtlas.refresh({ path: root })
|
||||
await shardAtlas.rejectPending()
|
||||
|
||||
fs.appendFileSync(path.join(root, 'Spawns', 'Sosaria.xml'), '<!-- changed -->', 'utf8')
|
||||
const again = await shardAtlas.refresh({ path: root })
|
||||
assert.equal(again.status, 'needsReview')
|
||||
})
|
||||
|
||||
test('refreshOnBoot: never throws, whatever goes wrong', async () => {
|
||||
atlasDb.getMeta = async () => {
|
||||
throw new Error('database is on fire')
|
||||
}
|
||||
atlasDb.getFacets = async () => {
|
||||
throw new Error('still on fire')
|
||||
}
|
||||
atlasDb.replaceAtlas = async () => {
|
||||
throw new Error('and the import too')
|
||||
}
|
||||
process.env.SERVUO_PATH = tempTree({ facets: ['Sosaria'] })
|
||||
|
||||
const result = await shardAtlas.refreshOnBoot()
|
||||
assert.equal(result.status, 'failed')
|
||||
})
|
||||
|
||||
test('refreshOnBoot: a missing tree is survivable, not fatal', async () => {
|
||||
process.env.SERVUO_PATH = path.join(os.tmpdir(), 'nope-not-here-xyz')
|
||||
const result = await shardAtlas.refreshOnBoot()
|
||||
assert.equal(result.status, 'unavailable')
|
||||
})
|
||||
|
||||
test('refresh: an explicit path overrides the configured one', async () => {
|
||||
const configured = tempTree({ facets: ['Configured'] })
|
||||
const override = tempTree({ facets: ['Override'] })
|
||||
settings.get = async () => configured
|
||||
|
||||
const result = await shardAtlas.refresh({ path: override })
|
||||
assert.equal(result.status, 'imported')
|
||||
assert.deepEqual(result.addedFacets, ['Override'])
|
||||
})
|
||||
686
modules/uo/server/utils/spawnAtlasParse.js
Normal file
686
modules/uo/server/utils/spawnAtlasParse.js
Normal file
@@ -0,0 +1,686 @@
|
||||
// Spawn atlas parsers — pure functions over strings, no `fs`, no dependencies.
|
||||
//
|
||||
// These back the CLI build script (`scripts/buildSpawnAtlas.js`), which is the
|
||||
// only thing that reads a ServUO tree. Keeping every parser pure and fs-free is
|
||||
// what lets the test suite cover them in CI, where no ServUO tree exists: the
|
||||
// tests hand these functions literal XML strings.
|
||||
//
|
||||
// Four source shapes, two very different parsing strategies:
|
||||
//
|
||||
// Spawns/*.xml ~10.5 MB across 13 files, FLAT <Points> records
|
||||
// → streaming regex, never a DOM. See parsePoints().
|
||||
// Data/Regions.xml 129 KB, genuinely nested <region> inside <region>
|
||||
// Data/Locations/*.xml nested <parent>/<child>
|
||||
// Config/ChampionSpawns.xml 4.8 KB, <spawn>/<location>
|
||||
// → the small recursive tokenizer below.
|
||||
//
|
||||
// The server has zero XML dependencies and this adds none. The tokenizer is
|
||||
// deliberately a *subset* parser: it handles the constructs these four files
|
||||
// actually use (elements, attributes, self-closing tags, comments, the XML
|
||||
// declaration, CDATA, the five predefined entities plus numeric refs) and
|
||||
// nothing else. It is not a general-purpose XML parser and must not be reused
|
||||
// as one — no namespaces, no DTDs, no entity declarations.
|
||||
|
||||
// ── Entities ───────────────────────────────────────────────────────────────
|
||||
|
||||
const NAMED_ENTITIES = {
|
||||
amp: '&',
|
||||
lt: '<',
|
||||
gt: '>',
|
||||
quot: '"',
|
||||
apos: "'",
|
||||
}
|
||||
|
||||
// Region and location names carry apostrophes ("Mondain's Legacy", "Wrong's
|
||||
// Level 3"), so entity decoding is load-bearing here, not decorative.
|
||||
function decodeEntities(text) {
|
||||
if (!text.includes('&')) return text
|
||||
return text.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (match, body) => {
|
||||
if (body[0] === '#') {
|
||||
const code =
|
||||
body[1] === 'x' || body[1] === 'X'
|
||||
? Number.parseInt(body.slice(2), 16)
|
||||
: Number.parseInt(body.slice(1), 10)
|
||||
return Number.isFinite(code) ? String.fromCodePoint(code) : match
|
||||
}
|
||||
const named = NAMED_ENTITIES[body.toLowerCase()]
|
||||
return named === undefined ? match : named
|
||||
})
|
||||
}
|
||||
|
||||
// ── The tokenizer ──────────────────────────────────────────────────────────
|
||||
|
||||
const ATTR_RE = /([\w:.-]+)\s*=\s*("([^"]*)"|'([^']*)')/g
|
||||
|
||||
function parseAttrs(source) {
|
||||
const attrs = {}
|
||||
ATTR_RE.lastIndex = 0
|
||||
let match
|
||||
while ((match = ATTR_RE.exec(source)) !== null) {
|
||||
const raw = match[3] !== undefined ? match[3] : match[4]
|
||||
attrs[match[1]] = decodeEntities(raw)
|
||||
}
|
||||
return attrs
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a small nested XML document into `{ name, attrs, children, text }`.
|
||||
*
|
||||
* Intended for Regions.xml / Locations / ChampionSpawns.xml only — never for
|
||||
* the multi-megabyte Spawns files. Returns the root element, or `null` for a
|
||||
* document with no elements.
|
||||
*
|
||||
* Mismatched or stray closing tags are ignored rather than thrown on: these are
|
||||
* hand-maintained shard config files, and one malformed region should degrade
|
||||
* to a missing region, not abort a build that is otherwise fine.
|
||||
*/
|
||||
function parseXml(source) {
|
||||
const text = String(source)
|
||||
const root = { name: '#document', attrs: {}, children: [], text: '' }
|
||||
const stack = [root]
|
||||
let i = 0
|
||||
|
||||
while (i < text.length) {
|
||||
const lt = text.indexOf('<', i)
|
||||
if (lt === -1) {
|
||||
appendText(stack[stack.length - 1], text.slice(i))
|
||||
break
|
||||
}
|
||||
if (lt > i) appendText(stack[stack.length - 1], text.slice(i, lt))
|
||||
|
||||
// Comment, declaration/DOCTYPE, or CDATA — skipped wholesale.
|
||||
if (text.startsWith('<!--', lt)) {
|
||||
const end = text.indexOf('-->', lt + 4)
|
||||
i = end === -1 ? text.length : end + 3
|
||||
continue
|
||||
}
|
||||
if (text.startsWith('<![CDATA[', lt)) {
|
||||
const end = text.indexOf(']]>', lt + 9)
|
||||
const stop = end === -1 ? text.length : end
|
||||
appendRawText(stack[stack.length - 1], text.slice(lt + 9, stop))
|
||||
i = end === -1 ? text.length : end + 3
|
||||
continue
|
||||
}
|
||||
if (text.startsWith('<?', lt)) {
|
||||
const end = text.indexOf('?>', lt + 2)
|
||||
i = end === -1 ? text.length : end + 2
|
||||
continue
|
||||
}
|
||||
if (text.startsWith('<!', lt)) {
|
||||
const end = text.indexOf('>', lt + 2)
|
||||
i = end === -1 ? text.length : end + 1
|
||||
continue
|
||||
}
|
||||
|
||||
const gt = findTagEnd(text, lt)
|
||||
if (gt === -1) {
|
||||
// Unterminated tag: nothing sane is left to read.
|
||||
break
|
||||
}
|
||||
const inner = text.slice(lt + 1, gt)
|
||||
|
||||
if (inner[0] === '/') {
|
||||
const name = inner.slice(1).trim()
|
||||
// Pop to the nearest matching open element. If there is no match the tag
|
||||
// is stray and we drop it rather than unwinding the whole stack.
|
||||
for (let depth = stack.length - 1; depth > 0; depth -= 1) {
|
||||
if (stack[depth].name === name) {
|
||||
stack.length = depth
|
||||
break
|
||||
}
|
||||
}
|
||||
i = gt + 1
|
||||
continue
|
||||
}
|
||||
|
||||
const selfClosing = inner.endsWith('/')
|
||||
const body = selfClosing ? inner.slice(0, -1) : inner
|
||||
const space = body.search(/\s/)
|
||||
const name = (space === -1 ? body : body.slice(0, space)).trim()
|
||||
const node = {
|
||||
name,
|
||||
attrs: space === -1 ? {} : parseAttrs(body.slice(space)),
|
||||
children: [],
|
||||
text: '',
|
||||
}
|
||||
stack[stack.length - 1].children.push(node)
|
||||
if (!selfClosing) stack.push(node)
|
||||
i = gt + 1
|
||||
}
|
||||
|
||||
return root.children.length > 0 ? root.children[0] : null
|
||||
}
|
||||
|
||||
// `>` inside a quoted attribute value must not end the tag.
|
||||
function findTagEnd(text, from) {
|
||||
let quote = null
|
||||
for (let i = from + 1; i < text.length; i += 1) {
|
||||
const ch = text[i]
|
||||
if (quote) {
|
||||
if (ch === quote) quote = null
|
||||
} else if (ch === '"' || ch === "'") {
|
||||
quote = ch
|
||||
} else if (ch === '>') {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
function appendText(node, chunk) {
|
||||
if (chunk.trim() === '') return
|
||||
appendRawText(node, decodeEntities(chunk))
|
||||
}
|
||||
|
||||
function appendRawText(node, chunk) {
|
||||
node.text = node.text ? `${node.text}${chunk}` : chunk
|
||||
}
|
||||
|
||||
function childrenNamed(node, name) {
|
||||
if (!node || !node.children) return []
|
||||
return node.children.filter((child) => child.name === name)
|
||||
}
|
||||
|
||||
// ── Facet names ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Facets are NOT a fixed list. A shard may add facets, replace them wholesale,
|
||||
// or rename them when its maps are updated, so nothing here may name Felucca,
|
||||
// Trammel or any other stock facet. The facet set is whatever the shard's own
|
||||
// files say it is, discovered at parse time.
|
||||
//
|
||||
// The complication is that the sources disagree about spelling for the SAME
|
||||
// facet and nothing in the files reconciles them: `Spawns/*.xml` `<Map>` and
|
||||
// `Regions.xml` `<Facet name>` say `TerMur`, while `Data/Locations/*.xml` spells
|
||||
// it `Ter Mur` and calls Tokuno `Tokuno Islands`. Left unreconciled this fails
|
||||
// silently — the landmark bucket is keyed differently from the points looking it
|
||||
// up, so the fallback never fires and every unregioned spawn on those facets
|
||||
// reads "Wilderness".
|
||||
//
|
||||
// Reconciliation is therefore done by MATCHING, not by a lookup table:
|
||||
// `facetKey()` collapses spelling differences, and `resolveFacetName()` matches
|
||||
// a loosely-spelled name against the canonical set discovered from the shard's
|
||||
// own data. A facet nobody else mentions keeps its own name rather than being
|
||||
// dropped.
|
||||
|
||||
/**
|
||||
* Collapse a facet name to a comparison key: lowercase, alphanumerics only.
|
||||
* `TerMur`, `Ter Mur` and `ter-mur` all key alike.
|
||||
*/
|
||||
function facetKey(value) {
|
||||
return String(value ?? '')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a key → canonical-spelling lookup from the authoritative facet names.
|
||||
*
|
||||
* The authority is what the spawn records and region definitions actually say,
|
||||
* since those are the names the atlas keys everything on. Later names do not
|
||||
* overwrite earlier ones, so the first source wins consistently.
|
||||
*/
|
||||
function buildFacetIndex(names) {
|
||||
const index = new Map()
|
||||
for (const name of names) {
|
||||
const key = facetKey(name)
|
||||
if (key !== '' && !index.has(key)) index.set(key, String(name).trim())
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a loosely-spelled facet name against the discovered canonical set.
|
||||
*
|
||||
* Tried in order: exact key match (`Ter Mur` → `TerMur`), then a prefix match in
|
||||
* either direction (`Tokuno Islands` → `Tokuno`), longest candidate first so a
|
||||
* more specific facet wins over a shorter one that merely prefixes it.
|
||||
*
|
||||
* A name matching nothing is returned trimmed rather than dropped — on a shard
|
||||
* with a custom facet that is a real facet the atlas simply has no spawns for
|
||||
* yet, and inventing a match would be worse than leaving it alone.
|
||||
*/
|
||||
function resolveFacetName(value, index) {
|
||||
const raw = String(value ?? '').trim()
|
||||
const key = facetKey(raw)
|
||||
if (key === '') return ''
|
||||
if (index.has(key)) return index.get(key)
|
||||
|
||||
let best = null
|
||||
for (const [candidateKey, canonical] of index) {
|
||||
if (!key.startsWith(candidateKey) && !candidateKey.startsWith(key)) continue
|
||||
if (best === null || candidateKey.length > facetKey(best).length) best = canonical
|
||||
}
|
||||
return best ?? raw
|
||||
}
|
||||
|
||||
// ── Small coercions ────────────────────────────────────────────────────────
|
||||
|
||||
function toInt(value, fallback = 0) {
|
||||
const n = Number.parseInt(value, 10)
|
||||
return Number.isFinite(n) ? n : fallback
|
||||
}
|
||||
|
||||
function toBool(value) {
|
||||
return String(value).trim().toLowerCase() === 'true'
|
||||
}
|
||||
|
||||
/**
|
||||
* URL-safe slug used as the creature primary key and in `/atlas/:slug`.
|
||||
* Spawn type tokens are C# class names, so they are already ASCII-ish; this
|
||||
* mainly lowercases and collapses punctuation.
|
||||
*/
|
||||
function slugify(value) {
|
||||
return String(value)
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
}
|
||||
|
||||
// ── Objects2 ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse a `<Objects2>` value into `[{ type, max }]`.
|
||||
*
|
||||
* The format is one or more segments joined by `:OBJ=`, each segment being
|
||||
* `Type:MX=n:SB=0:RT=0:...` — the type is the token before the first `:`, and
|
||||
* every following token is a `KEY=value` pair. Verified against trammel.xml,
|
||||
* where a single point carries six types:
|
||||
*
|
||||
* Giantserpent:MX=1:...:OBJ=Giantspider:MX=1:...:OBJ=Boar:MX=1:...
|
||||
*
|
||||
* Splitting on `:` alone would shred this, which is why the `:OBJ=` split comes
|
||||
* first. `MX` is that type's own max count and is what the atlas displays;
|
||||
* every other flag (spawn/trigger/refractory bookkeeping) is dropped.
|
||||
*
|
||||
* The type token itself may carry XmlSpawner directives appended to the class
|
||||
* name — property assignments after `/` and an amount/argument list after `,`:
|
||||
*
|
||||
* Agralem/Name/Agralem alchemist/z/-50 Fairy,{RND,4,8}
|
||||
* GargishRefugee/hue/34532 greatape,true GargishRouser,1
|
||||
*
|
||||
* Taken literally these produce creatures that do not exist ("alchemist/z/-50")
|
||||
* AND split real ones in two, because `Fairy` and `Fairy,{RND,4,8}` slug apart —
|
||||
* 71 of 845 entries were affected before this was stripped. Only the leading
|
||||
* class name identifies the creature, so everything from the first `/` or `,`
|
||||
* is dropped.
|
||||
*/
|
||||
/** Reduce an XmlSpawner type token to the bare class name. */
|
||||
function stripSpawnerDirectives(token) {
|
||||
const cut = String(token).search(/[/,]/)
|
||||
return (cut === -1 ? String(token) : String(token).slice(0, cut)).trim()
|
||||
}
|
||||
|
||||
function parseObjects2(value) {
|
||||
const source = String(value ?? '').trim()
|
||||
if (source === '') return []
|
||||
|
||||
return source
|
||||
.split(':OBJ=')
|
||||
.map((segment) => {
|
||||
const tokens = segment.split(':')
|
||||
const type = stripSpawnerDirectives(tokens.shift() ?? '')
|
||||
if (type === '') return null
|
||||
let max = 1
|
||||
for (const token of tokens) {
|
||||
const eq = token.indexOf('=')
|
||||
if (eq === -1) continue
|
||||
if (token.slice(0, eq).trim().toUpperCase() === 'MX') {
|
||||
max = toInt(token.slice(eq + 1), 1)
|
||||
}
|
||||
}
|
||||
return { type, max }
|
||||
})
|
||||
.filter((entry) => entry !== null)
|
||||
}
|
||||
|
||||
// ── Spawns/*.xml ───────────────────────────────────────────────────────────
|
||||
|
||||
const POINT_RE = /<Points>([\s\S]*?)<\/Points>/g
|
||||
|
||||
function tagValue(block, name) {
|
||||
const match = block.match(new RegExp(`<${name}>([\\s\\S]*?)</${name}>`))
|
||||
return match ? decodeEntities(match[1]).trim() : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a `Spawns/<facet>.xml` file into spawn point records.
|
||||
*
|
||||
* Deliberately regex/streaming and NOT `parseXml` — these files total ~10.5 MB
|
||||
* and putting them through a DOM builder would allocate a node per element for
|
||||
* ~40 fields on every one of ~6,500 records to keep 14 of them. The records are
|
||||
* flat, so a per-record regex sweep is both correct and cheap.
|
||||
*
|
||||
* Only the fields the site can actually show are kept. Everything to do with
|
||||
* triggering, refractory windows, proximity, sequential spawning, sounds and
|
||||
* `UniqueId` is dropped here rather than downstream — that is what holds the
|
||||
* committed artifact under 1 MB.
|
||||
*
|
||||
* NOTE: the facet comes from each record's own `<Map>`, never from the file
|
||||
* name. `Eodon.xml`, `GravewaterLake.xml` and the other named-area files all
|
||||
* carry TerMur/Trammel points, so there are 13 files but only 6 facets.
|
||||
*/
|
||||
/**
|
||||
* A spawner's respawn window, in seconds.
|
||||
*
|
||||
* `DelayInSec` decides the unit of `MinDelay`/`MaxDelay`; absent (older files)
|
||||
* it is false, which is minutes — the same default XmlSpawner assumes.
|
||||
*/
|
||||
function delaySeconds(block) {
|
||||
const scale = toBool(tagValue(block, 'DelayInSec')) ? 1 : 60
|
||||
return {
|
||||
minDelay: toInt(tagValue(block, 'MinDelay')) * scale,
|
||||
maxDelay: toInt(tagValue(block, 'MaxDelay')) * scale,
|
||||
}
|
||||
}
|
||||
|
||||
function parsePoints(source) {
|
||||
const text = String(source)
|
||||
const points = []
|
||||
POINT_RE.lastIndex = 0
|
||||
let match
|
||||
|
||||
while ((match = POINT_RE.exec(text)) !== null) {
|
||||
const block = match[1]
|
||||
// Reported exactly as written. `<Map>` is the authority the rest of the
|
||||
// atlas keys on, so it is never rewritten.
|
||||
const facet = tagValue(block, 'Map')
|
||||
if (facet === '') continue
|
||||
|
||||
points.push({
|
||||
name: tagValue(block, 'Name'),
|
||||
facet,
|
||||
x: toInt(tagValue(block, 'X')),
|
||||
y: toInt(tagValue(block, 'Y')),
|
||||
width: toInt(tagValue(block, 'Width')),
|
||||
height: toInt(tagValue(block, 'Height')),
|
||||
range: toInt(tagValue(block, 'Range')),
|
||||
maxCount: toInt(tagValue(block, 'MaxCount')),
|
||||
// Normalised to SECONDS here, because the unit is per-record. XmlSpawner
|
||||
// writes minutes by default and switches to seconds only when a spawner's
|
||||
// delay does not divide into whole minutes, flagging that with
|
||||
// `DelayInSec` (XmlSpawner2.cs:7462-7480, read back at :6345-6358). Taken
|
||||
// literally the two are indistinguishable — a `5` means five minutes on
|
||||
// one spawner and five seconds on the next — so a consumer that assumed
|
||||
// either unit would be wrong about the other. Stock ServUO 57.4 has ~30
|
||||
// second-flagged spawners, few enough to look like noise and quietly
|
||||
// mislabel.
|
||||
...delaySeconds(block),
|
||||
// Time-of-day gating: TODMode 0 means "always", in which case the start
|
||||
// and end values are meaningless and the site must not render them.
|
||||
todStart: toInt(tagValue(block, 'TODStart')),
|
||||
todEnd: toInt(tagValue(block, 'TODEnd')),
|
||||
todMode: toInt(tagValue(block, 'TODMode')),
|
||||
// A spawner switched off in-world spawns nothing; the build filters these
|
||||
// out so the atlas describes what actually appears, not what is merely
|
||||
// configured. Parsed here so the decision stays in the build script.
|
||||
running: toBool(tagValue(block, 'IsRunning')),
|
||||
types: parseObjects2(tagValue(block, 'Objects2')),
|
||||
})
|
||||
}
|
||||
|
||||
return points
|
||||
}
|
||||
|
||||
// ── Data/Regions.xml ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Flatten `Data/Regions.xml` into `[{ facet, name, type, priority, parent, rects }]`.
|
||||
*
|
||||
* Regions nest: a `<region>` may contain further `<region>` elements, and the
|
||||
* inner ones frequently omit `name` and `priority` (`<region type="CrystalField">`
|
||||
* inside "Prism of Light"). Unnamed regions are skipped — they cannot label a
|
||||
* spawn point — but their children are still walked, and a child that omits
|
||||
* `priority` inherits its parent's rather than defaulting to 0, which would
|
||||
* quietly sort it below every top-level region.
|
||||
*/
|
||||
function parseRegions(source) {
|
||||
const root = parseXml(source)
|
||||
const regions = []
|
||||
if (!root) return regions
|
||||
|
||||
for (const facetNode of childrenNamed(root, 'Facet')) {
|
||||
const facet = (facetNode.attrs.name || '').trim()
|
||||
if (facet === '') continue
|
||||
walkRegions(facetNode, facet, null, 0, regions)
|
||||
}
|
||||
return regions
|
||||
}
|
||||
|
||||
function walkRegions(node, facet, parentName, parentPriority, out) {
|
||||
for (const regionNode of childrenNamed(node, 'region')) {
|
||||
const name = regionNode.attrs.name || ''
|
||||
const priority = Object.hasOwn(regionNode.attrs, 'priority')
|
||||
? toInt(regionNode.attrs.priority, parentPriority)
|
||||
: parentPriority
|
||||
|
||||
if (name !== '') {
|
||||
const rects = childrenNamed(regionNode, 'rect').map((rect) => ({
|
||||
x: toInt(rect.attrs.x),
|
||||
y: toInt(rect.attrs.y),
|
||||
width: toInt(rect.attrs.width),
|
||||
height: toInt(rect.attrs.height),
|
||||
}))
|
||||
// A named region with no rects (some exist purely to carry music or a
|
||||
// `go` point) can never contain anything, so it is not worth indexing.
|
||||
if (rects.length > 0) {
|
||||
out.push({
|
||||
facet,
|
||||
name,
|
||||
type: regionNode.attrs.type || '',
|
||||
priority,
|
||||
parent: parentName,
|
||||
rects,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
walkRegions(regionNode, facet, name === '' ? parentName : name, priority, out)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Data/Locations/*.xml ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Flatten a `Data/Locations/<facet>.xml` into landmark points.
|
||||
*
|
||||
* The file nests `<parent>` arbitrarily deep and puts coordinates only on
|
||||
* `<child>`: Trammel → Dungeons → Covetous → "Level 1". The outermost parent is
|
||||
* the facet itself and is dropped from `path`; `group` is the innermost
|
||||
* enclosing parent ("Covetous"), which is the label worth showing — "Covetous"
|
||||
* reads better than "Level 1" when naming where a spawn is.
|
||||
*/
|
||||
function parseLocations(source, facetHint = '') {
|
||||
const root = parseXml(source)
|
||||
const landmarks = []
|
||||
if (!root) return landmarks
|
||||
|
||||
for (const top of childrenNamed(root, 'parent')) {
|
||||
// The file name (`Data/Locations/termur.xml`) is the more reliable signal
|
||||
// and is preferred over the display label inside the file, which is where
|
||||
// the `Ter Mur` / `Tokuno Islands` drift lives. Both are carried so the
|
||||
// build can fall back to matching the label if the file name resolves to
|
||||
// nothing — a shard may well name its files differently from its facets.
|
||||
landmarks.push(
|
||||
...collectLocations(top, facetHint || top.attrs.name || '', top.attrs.name || ''),
|
||||
)
|
||||
}
|
||||
return landmarks
|
||||
}
|
||||
|
||||
function collectLocations(top, facet, label) {
|
||||
const out = []
|
||||
walkLocations(top, facet, [], out)
|
||||
for (const landmark of out) landmark.facetLabel = label
|
||||
return out
|
||||
}
|
||||
|
||||
function walkLocations(node, facet, path, out) {
|
||||
for (const child of childrenNamed(node, 'child')) {
|
||||
const name = child.attrs.name || ''
|
||||
if (name === '') continue
|
||||
out.push({
|
||||
facet,
|
||||
name,
|
||||
group: path.length > 0 ? path[path.length - 1] : name,
|
||||
path: [...path],
|
||||
x: toInt(child.attrs.x),
|
||||
y: toInt(child.attrs.y),
|
||||
z: toInt(child.attrs.z),
|
||||
})
|
||||
}
|
||||
for (const parent of childrenNamed(node, 'parent')) {
|
||||
const name = parent.attrs.name || ''
|
||||
walkLocations(parent, facet, name === '' ? path : [...path, name], out)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Config/ChampionSpawns.xml ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse `Config/ChampionSpawns.xml` into champion altar records.
|
||||
*
|
||||
* This is the shard's *configured* champion roster — which altars exist, where,
|
||||
* and which type each is pinned to. It is static content and distinct from the
|
||||
* live `champ.update` feed the bridge already carries: this says "there is an
|
||||
* Unholy Terror altar in Deceit", the feed says "it is on level 3 right now".
|
||||
*
|
||||
* A spawn with no `type` is randomised on every activation, which the site must
|
||||
* render as "random" rather than as an empty type.
|
||||
*/
|
||||
function parseChampions(source) {
|
||||
const root = parseXml(source)
|
||||
const champions = []
|
||||
if (!root) return champions
|
||||
|
||||
for (const spawnNode of childrenNamed(root, 'spawn')) {
|
||||
const location = childrenNamed(spawnNode, 'location')[0]
|
||||
const attrs = location ? location.attrs : {}
|
||||
champions.push({
|
||||
name: spawnNode.attrs.name || '',
|
||||
group: spawnNode.attrs.group || '',
|
||||
type: spawnNode.attrs.type || '',
|
||||
randomType: !spawnNode.attrs.type,
|
||||
facet: (attrs.map || '').trim(),
|
||||
x: toInt(attrs.x),
|
||||
y: toInt(attrs.y),
|
||||
z: toInt(attrs.z),
|
||||
radius: toInt(attrs.radius),
|
||||
})
|
||||
}
|
||||
return champions
|
||||
}
|
||||
|
||||
// ── Placement ──────────────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_LANDMARK_RADIUS = 200
|
||||
|
||||
function inRect(x, y, rect) {
|
||||
return (
|
||||
x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height
|
||||
)
|
||||
}
|
||||
|
||||
function rectArea(rect) {
|
||||
return Math.max(1, rect.width) * Math.max(1, rect.height)
|
||||
}
|
||||
|
||||
/**
|
||||
* Group parsed regions and landmarks by facet once, so the per-point resolve
|
||||
* below is a scan of one facet instead of the whole world. With ~6,500 points
|
||||
* and a few thousand rects this stays comfortably sub-second; there is no need
|
||||
* for a spatial index and none is worth the complexity.
|
||||
*/
|
||||
function buildPlacementIndex(regions, landmarks) {
|
||||
const byFacet = new Map()
|
||||
// Keyed on facetKey(), not the raw name, so two spellings of one facet cannot
|
||||
// land in separate buckets — the failure that silently emptied the landmark
|
||||
// bucket for Ter Mur and Tokuno.
|
||||
const facet = (name) => {
|
||||
const key = facetKey(name)
|
||||
if (!byFacet.has(key)) byFacet.set(key, { regions: [], landmarks: [] })
|
||||
return byFacet.get(key)
|
||||
}
|
||||
for (const region of regions) facet(region.facet).regions.push(region)
|
||||
for (const landmark of landmarks) facet(landmark.facet).landmarks.push(landmark)
|
||||
return byFacet
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a raw coordinate into a human place name.
|
||||
*
|
||||
* This is the transform the whole atlas exists for: it is what makes a row read
|
||||
* "Lizardman — Despise, Felucca" instead of "Lizardman — 5411, 1234".
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. The highest-`priority` named region whose rect contains the point. Ties
|
||||
* break toward the SMALLEST rect, so a specific room inside a dungeon wins
|
||||
* over the dungeon-wide rect it sits in.
|
||||
* 2. Otherwise the nearest landmark within `landmarkRadius` tiles, labelled by
|
||||
* its group ("Covetous"), not the individual marker ("Level 1").
|
||||
* 3. Otherwise "Wilderness". The radius cap is what keeps step 3 reachable —
|
||||
* without it the nearest landmark is always *some* landmark, however far,
|
||||
* and open countryside would get labelled with a dungeon on the far side
|
||||
* of the map.
|
||||
*/
|
||||
function resolveRegion(x, y, facetName, index, options = {}) {
|
||||
const radius = options.landmarkRadius ?? DEFAULT_LANDMARK_RADIUS
|
||||
const bucket = index.get(facetKey(facetName))
|
||||
const result = { region: null, landmark: null, label: 'Wilderness' }
|
||||
if (!bucket) return result
|
||||
|
||||
let best = null
|
||||
let bestPriority = -Infinity
|
||||
let bestArea = Infinity
|
||||
for (const region of bucket.regions) {
|
||||
for (const rect of region.rects) {
|
||||
if (!inRect(x, y, rect)) continue
|
||||
const area = rectArea(rect)
|
||||
if (region.priority > bestPriority || (region.priority === bestPriority && area < bestArea)) {
|
||||
best = region
|
||||
bestPriority = region.priority
|
||||
bestArea = area
|
||||
}
|
||||
}
|
||||
}
|
||||
if (best) {
|
||||
result.region = best.name
|
||||
result.label = best.name
|
||||
return result
|
||||
}
|
||||
|
||||
let nearest = null
|
||||
let nearestDistance = Infinity
|
||||
const limit = radius * radius
|
||||
for (const landmark of bucket.landmarks) {
|
||||
const dx = landmark.x - x
|
||||
const dy = landmark.y - y
|
||||
const distance = dx * dx + dy * dy
|
||||
if (distance < nearestDistance) {
|
||||
nearest = landmark
|
||||
nearestDistance = distance
|
||||
}
|
||||
}
|
||||
if (nearest && nearestDistance <= limit) {
|
||||
result.landmark = nearest.group || nearest.name
|
||||
result.label = result.landmark
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseXml,
|
||||
parseObjects2,
|
||||
parsePoints,
|
||||
parseRegions,
|
||||
parseLocations,
|
||||
parseChampions,
|
||||
buildPlacementIndex,
|
||||
resolveRegion,
|
||||
facetKey,
|
||||
buildFacetIndex,
|
||||
resolveFacetName,
|
||||
slugify,
|
||||
decodeEntities,
|
||||
DEFAULT_LANDMARK_RADIUS,
|
||||
}
|
||||
336
modules/uo/server/utils/spawnAtlasSource.js
Normal file
336
modules/uo/server/utils/spawnAtlasSource.js
Normal file
@@ -0,0 +1,336 @@
|
||||
// Spawn atlas — the filesystem layer over a ServUO tree.
|
||||
//
|
||||
// `spawnAtlasParse.js` holds the pure parsers; this module is the only thing
|
||||
// that touches a ServUO tree on disk, and it is shared by both callers:
|
||||
//
|
||||
// - the server, which refreshes the atlas on boot (`shardAtlas.model.js`)
|
||||
// - the CLI (`scripts/importSpawnAtlas.js`)
|
||||
//
|
||||
// The shard's own files are the single source of truth. Nothing is precomputed
|
||||
// and committed, because a shard's maps change over its lifetime — facets get
|
||||
// added, replaced or renamed — and a snapshot in the repo would silently go
|
||||
// stale against the world players actually see.
|
||||
//
|
||||
// Reading and hashing the whole tree costs ~120 ms and a full parse ~400 ms, so
|
||||
// the boot path hashes first and only parses when something actually changed.
|
||||
|
||||
const crypto = require('crypto')
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
|
||||
const {
|
||||
parsePoints,
|
||||
parseRegions,
|
||||
parseLocations,
|
||||
parseChampions,
|
||||
buildPlacementIndex,
|
||||
buildFacetIndex,
|
||||
resolveFacetName,
|
||||
resolveRegion,
|
||||
facetKey,
|
||||
slugify,
|
||||
} = require('./spawnAtlasParse')
|
||||
|
||||
const REGIONS_FILE = path.join('Data', 'Regions.xml')
|
||||
const LOCATIONS_DIR = path.join('Data', 'Locations')
|
||||
const SPAWNS_DIR = 'Spawns'
|
||||
const CHAMPIONS_FILE = path.join('Config', 'ChampionSpawns.xml')
|
||||
|
||||
class AtlasSourceError extends Error {
|
||||
constructor(message, code) {
|
||||
super(message)
|
||||
this.name = 'AtlasSourceError'
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reading ────────────────────────────────────────────────────────────────
|
||||
|
||||
function sha256(text) {
|
||||
return crypto.createHash('sha256').update(text, 'utf8').digest('hex')
|
||||
}
|
||||
|
||||
function listXml(dir) {
|
||||
try {
|
||||
return fs
|
||||
.readdirSync(dir)
|
||||
.filter((name) => name.toLowerCase().endsWith('.xml'))
|
||||
.sort()
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return []
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
function readIfPresent(file) {
|
||||
try {
|
||||
return fs.readFileSync(file, 'utf8')
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT' || err.code === 'ENOTDIR') return null
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read every atlas source file under `root`.
|
||||
*
|
||||
* Returns `{ files: [{ label, text, sha256, bytes }] }`, labels being
|
||||
* tree-relative and forward-slashed so a hash map compares equal across
|
||||
* platforms — the same tree read on Windows and Linux must produce the same
|
||||
* fingerprint or every boot would look like a change.
|
||||
*/
|
||||
function readSources(root) {
|
||||
if (!root || String(root).trim() === '') {
|
||||
throw new AtlasSourceError('No ServUO path configured', 'NO_PATH')
|
||||
}
|
||||
if (!fs.existsSync(root)) {
|
||||
throw new AtlasSourceError(`ServUO path does not exist: ${root}`, 'NOT_FOUND')
|
||||
}
|
||||
|
||||
const files = []
|
||||
const push = (label, file) => {
|
||||
const text = readIfPresent(file)
|
||||
if (text === null) return false
|
||||
files.push({ label, text, sha256: sha256(text), bytes: Buffer.byteLength(text, 'utf8') })
|
||||
return true
|
||||
}
|
||||
|
||||
if (!push('Data/Regions.xml', path.join(root, REGIONS_FILE))) {
|
||||
throw new AtlasSourceError(`Missing required file: ${REGIONS_FILE}`, 'NO_REGIONS')
|
||||
}
|
||||
|
||||
for (const name of listXml(path.join(root, LOCATIONS_DIR))) {
|
||||
push(`Data/Locations/${name}`, path.join(root, LOCATIONS_DIR, name))
|
||||
}
|
||||
|
||||
const spawnFiles = listXml(path.join(root, SPAWNS_DIR))
|
||||
if (spawnFiles.length === 0) {
|
||||
throw new AtlasSourceError(`No spawn files found in ${SPAWNS_DIR}`, 'NO_SPAWNS')
|
||||
}
|
||||
for (const name of spawnFiles) push(`Spawns/${name}`, path.join(root, SPAWNS_DIR, name))
|
||||
|
||||
push('Config/ChampionSpawns.xml', path.join(root, CHAMPIONS_FILE))
|
||||
|
||||
return { files }
|
||||
}
|
||||
|
||||
/**
|
||||
* A fingerprint of the tree: `{ "<label>": "<sha256>" }`.
|
||||
*
|
||||
* The boot path compares this against what was last imported and skips the
|
||||
* parse entirely when it matches, which is the normal case on every restart
|
||||
* that did not follow a map update.
|
||||
*/
|
||||
function hashSources(root) {
|
||||
const { files } = readSources(root)
|
||||
const hashes = {}
|
||||
for (const file of files) hashes[file.label] = file.sha256
|
||||
return hashes
|
||||
}
|
||||
|
||||
/**
|
||||
* Bumped whenever the parser produces DIFFERENT data from IDENTICAL source
|
||||
* files — a fixed misreading, a new field, a changed unit.
|
||||
*
|
||||
* Without it the hash gate is a trap: an install whose tree has not changed
|
||||
* would keep serving what an older parser derived, indefinitely, because the
|
||||
* only thing the boot path compares is the tree. The version is stored beside
|
||||
* the source hashes and a mismatch counts as drift, so a deploy that corrects
|
||||
* the parse actually reaches the data.
|
||||
*
|
||||
* 2 — respawn delays normalised to seconds (they are per-record minutes OR
|
||||
* seconds in the source, decided by `DelayInSec`).
|
||||
*/
|
||||
const PARSER_VERSION = 2
|
||||
|
||||
/** True when two source fingerprints describe the same tree. */
|
||||
function sameSources(a, b) {
|
||||
if (!a || !b) return false
|
||||
const aKeys = Object.keys(a).sort()
|
||||
const bKeys = Object.keys(b).sort()
|
||||
if (aKeys.length !== bKeys.length) return false
|
||||
return aKeys.every((key, i) => key === bKeys[i] && a[key] === b[key])
|
||||
}
|
||||
|
||||
// ── Aggregation ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Choose one display spelling for a creature.
|
||||
*
|
||||
* Spawn files are not consistent about case — the same creature is `Lizardman`
|
||||
* in one file and `lizardman` in another. Slugging collapses them correctly, but
|
||||
* the display name would otherwise depend on file read order. Most frequent
|
||||
* spelling wins; ties break toward more capitals, then alphabetically.
|
||||
*/
|
||||
function displayName(spellings) {
|
||||
const capitals = (value) => (value.match(/[A-Z]/g) || []).length
|
||||
return [...spellings.entries()].sort((a, b) => {
|
||||
if (b[1] !== a[1]) return b[1] - a[1]
|
||||
const caps = capitals(b[0]) - capitals(a[0])
|
||||
if (caps !== 0) return caps
|
||||
return a[0].localeCompare(b[0])
|
||||
})[0][0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll spawn points up into per-type creature rows.
|
||||
*
|
||||
* `total` is the sum of each type's own max across every point that spawns it —
|
||||
* how many of this creature the world holds at once. `facets` is a per-facet
|
||||
* point count, so "where does this live" answers without touching the points.
|
||||
*/
|
||||
function aggregateCreatures(points) {
|
||||
const creatures = new Map()
|
||||
for (const point of points) {
|
||||
for (const entry of point.types) {
|
||||
const slug = slugify(entry.type)
|
||||
if (slug === '') continue
|
||||
let creature = creatures.get(slug)
|
||||
if (!creature) {
|
||||
creature = { slug, name: '', total: 0, points: 0, facets: {}, spellings: new Map() }
|
||||
creatures.set(slug, creature)
|
||||
}
|
||||
creature.total += entry.max
|
||||
creature.points += 1
|
||||
creature.facets[point.facet] = (creature.facets[point.facet] || 0) + 1
|
||||
creature.spellings.set(entry.type, (creature.spellings.get(entry.type) || 0) + 1)
|
||||
}
|
||||
}
|
||||
|
||||
return [...creatures.values()]
|
||||
.map(({ spellings, ...creature }) => ({ ...creature, name: displayName(spellings) }))
|
||||
.sort((a, b) => a.slug.localeCompare(b.slug))
|
||||
}
|
||||
|
||||
// ── Build ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse a ServUO tree into the full atlas.
|
||||
*
|
||||
* Pure with respect to the database — it reads files and returns data; nothing
|
||||
* here writes. `shardAtlas.model.js` decides what to do with the result.
|
||||
*/
|
||||
function buildAtlas(root, options = {}) {
|
||||
const { files } = readSources(root)
|
||||
const byLabel = new Map(files.map((file) => [file.label, file]))
|
||||
const source = {}
|
||||
for (const file of files) source[file.label] = { bytes: file.bytes, sha256: file.sha256 }
|
||||
|
||||
const regions = parseRegions(byLabel.get('Data/Regions.xml').text)
|
||||
|
||||
const rawLandmarks = []
|
||||
for (const file of files) {
|
||||
if (!file.label.startsWith('Data/Locations/')) continue
|
||||
const basename = path.basename(file.label, '.xml')
|
||||
rawLandmarks.push(...parseLocations(file.text, basename))
|
||||
}
|
||||
|
||||
const rawPoints = []
|
||||
for (const file of files) {
|
||||
if (!file.label.startsWith('Spawns/')) continue
|
||||
rawPoints.push(...parsePoints(file.text))
|
||||
}
|
||||
|
||||
// The facet set is whatever THIS tree declares — never a built-in list. A
|
||||
// shard may add facets, replace them outright, or rename them when its maps
|
||||
// are updated, and the atlas has to follow without a code change. Spawn
|
||||
// records and region definitions are the authority, because those are the
|
||||
// names everything else is keyed on.
|
||||
const facetIndex = buildFacetIndex([
|
||||
...rawPoints.map((point) => point.facet),
|
||||
...regions.map((region) => region.facet),
|
||||
])
|
||||
|
||||
// Landmark facets are then matched against that set, which is what absorbs the
|
||||
// `Ter Mur` / `Tokuno Islands` spelling drift between Locations and <Map>.
|
||||
const landmarks = rawLandmarks.map(({ facetLabel, ...landmark }) => {
|
||||
const fromFile = resolveFacetName(landmark.facet, facetIndex)
|
||||
const matchedFile = facetIndex.has(facetKey(fromFile))
|
||||
const resolved = matchedFile ? fromFile : resolveFacetName(facetLabel, facetIndex)
|
||||
return { ...landmark, facet: resolved || landmark.facet }
|
||||
})
|
||||
|
||||
const placement = buildPlacementIndex(regions, landmarks)
|
||||
const resolveOpts = options.landmarkRadius ? { landmarkRadius: options.landmarkRadius } : {}
|
||||
|
||||
const disabled = rawPoints.filter((point) => !point.running).length
|
||||
const points = rawPoints
|
||||
// A spawner switched off in-world produces nothing; advertising it would be
|
||||
// a straight lie to a player planning a hunt.
|
||||
.filter((point) => point.running)
|
||||
// A spawner with no types is a placeholder — nothing to show.
|
||||
.filter((point) => point.types.length > 0)
|
||||
.map((point) => {
|
||||
const place = resolveRegion(point.x, point.y, point.facet, placement, resolveOpts)
|
||||
return {
|
||||
name: point.name,
|
||||
facet: point.facet,
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
width: point.width,
|
||||
height: point.height,
|
||||
range: point.range,
|
||||
maxCount: point.maxCount,
|
||||
minDelay: point.minDelay,
|
||||
maxDelay: point.maxDelay,
|
||||
todStart: point.todStart,
|
||||
todEnd: point.todEnd,
|
||||
todMode: point.todMode,
|
||||
region: place.region,
|
||||
landmark: place.landmark,
|
||||
label: place.label,
|
||||
types: point.types,
|
||||
}
|
||||
})
|
||||
|
||||
const championsFile = byLabel.get('Config/ChampionSpawns.xml')
|
||||
const champions = (championsFile ? parseChampions(championsFile.text) : []).map((champ) => {
|
||||
const facet = resolveFacetName(champ.facet, facetIndex) || champ.facet
|
||||
return {
|
||||
...champ,
|
||||
facet,
|
||||
slug: slugify(`${facet}-${champ.name}`),
|
||||
label: resolveRegion(champ.x, champ.y, facet, placement, resolveOpts).label,
|
||||
}
|
||||
})
|
||||
|
||||
const creatures = aggregateCreatures(points)
|
||||
const facets = [...new Set(points.map((point) => point.facet))].sort()
|
||||
const unresolved = points.filter((point) => !point.region && !point.landmark).length
|
||||
|
||||
return {
|
||||
meta: {
|
||||
generatedAt: new Date().toISOString(),
|
||||
parserVersion: PARSER_VERSION,
|
||||
landmarkRadius: options.landmarkRadius ?? undefined,
|
||||
counts: {
|
||||
facets: facets.length,
|
||||
points: points.length,
|
||||
pointsDisabled: disabled,
|
||||
creatures: creatures.length,
|
||||
regions: regions.length,
|
||||
landmarks: landmarks.length,
|
||||
champions: champions.length,
|
||||
unresolvedPoints: unresolved,
|
||||
},
|
||||
source,
|
||||
},
|
||||
facets,
|
||||
creatures,
|
||||
regions,
|
||||
landmarks,
|
||||
champions,
|
||||
points,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
AtlasSourceError,
|
||||
PARSER_VERSION,
|
||||
readSources,
|
||||
hashSources,
|
||||
sameSources,
|
||||
buildAtlas,
|
||||
aggregateCreatures,
|
||||
displayName,
|
||||
}
|
||||
435
modules/uo/server/utils/visibility.js
Normal file
435
modules/uo/server/utils/visibility.js
Normal file
@@ -0,0 +1,435 @@
|
||||
// ── Shard feature visibility ───────────────────────────────────────────────
|
||||
//
|
||||
// Admin-configurable, per-feature and per-field audience control over every
|
||||
// shard-derived surface on the site. Replaces the hardcoded split that used to
|
||||
// live in two places (the PUBLIC_KINDS allowlist in shardBroadcast.js, and the
|
||||
// ad-hoc `canSeeStaffLocation` style checks in the public controllers).
|
||||
//
|
||||
// Design rules (docs/link/v3.md §3):
|
||||
//
|
||||
// • Visibility lives HERE, on the website — never in the sidecar. The sidecar
|
||||
// is a dumb forwarder: it accepts frames, stores them, forwards them
|
||||
// verbatim, and serves store-backed reads. It defines no audiences.
|
||||
// • Every default reproduces the behavior that shipped before this module, so
|
||||
// installing it changes nothing until an admin edits the config.
|
||||
// • Two rules an admin CANNOT override:
|
||||
// 1. `acct` / `webId` are admin-only, always. They are not in-game
|
||||
// visible (unlike a character name) and are not configurable fields.
|
||||
// 2. A kind absent from KIND_FEATURE is never broadcast below `admin`.
|
||||
// Fail closed — this is what keeps the kind map a security boundary
|
||||
// rather than a convenience filter.
|
||||
//
|
||||
// The audience ladder is ordered; each rung implies the ones below it.
|
||||
|
||||
const db = require('../model/shardVisibility/shardVisibility.model')
|
||||
const shardLinks = require('../model/shardLinks/shardLinks.model')
|
||||
const { auth } = require('../core')
|
||||
const log = require('../core').logger('visibility')
|
||||
|
||||
// ── The ladder ─────────────────────────────────────────────────────────────
|
||||
|
||||
const LADDER = ['anonymous', 'logged_in', 'player', 'staff', 'admin']
|
||||
const RANK = new Map(LADDER.map((level, i) => [level, i]))
|
||||
|
||||
const isLevel = (level) => RANK.has(level)
|
||||
|
||||
// The two fallbacks are deliberately ASYMMETRIC, and the asymmetry is the whole
|
||||
// point: an unrecognised value must always lose. A single shared fallback cannot
|
||||
// do that — whichever direction it picks, it fails open on one side. So:
|
||||
//
|
||||
// • an unknown VIEWER level floors to the bottom rung (grants nothing), and
|
||||
// • an unknown REQUIREMENT ceils to the top rung (satisfied by nobody but admin).
|
||||
//
|
||||
// With one `rank()` defaulting to admin, a viewer level that fell through (a
|
||||
// typo, a future rung this build doesn't know, a value from a caller that
|
||||
// skipped viewerLevel) would have been treated as an ADMIN and passed every gate.
|
||||
const viewerRank = (level) => RANK.get(level) ?? 0
|
||||
const requiredRank = (level) => RANK.get(level) ?? RANK.get('admin')
|
||||
|
||||
// True when a viewer at `viewer` satisfies a requirement of `required`.
|
||||
const meets = (viewer, required) => viewerRank(viewer) >= requiredRank(required)
|
||||
|
||||
// Exported for tests/diagnostics; `meets` is what callers should use.
|
||||
const rank = viewerRank
|
||||
|
||||
// ── Features ───────────────────────────────────────────────────────────────
|
||||
//
|
||||
// All ten shard surfaces: the six that shipped before v3 plus the four v3 adds.
|
||||
// `fields` lists only the SENSITIVE fields — those an admin may re-gate. A field
|
||||
// not listed here is visible whenever the feature itself is.
|
||||
//
|
||||
// LOCKED_FIELDS are exempt from configuration entirely (rule 1 above).
|
||||
|
||||
const LOCKED_FIELDS = { acct: 'admin', webId: 'admin' }
|
||||
|
||||
// Rule 1 matches on the FIELD'S MEANING, not on one exact spelling. The wire
|
||||
// frames nest actors (`leader.acct`), but several read models flatten them
|
||||
// instead (`shapeHouse` emits `ownerAcct`, `shapeGuild`'s fallback emits
|
||||
// `leaderAcct`/`leaderWebId`), and an exact-key check silently missed every
|
||||
// flattened one — which is how `GET /public/shard/idoc` served `ownerAcct` to
|
||||
// anonymous callers while the same account name was correctly stripped from the
|
||||
// live `house.decay` frame.
|
||||
//
|
||||
// So a key is locked when it IS `acct`/`webId` or ENDS in one, case-insensitively
|
||||
// (`ownerAcct`, `leaderWebId`, `governorAcct`). Suffix matching is what makes this
|
||||
// fail closed for shapes nobody has written yet.
|
||||
const LOCKED_SUFFIXES = ['acct', 'webid']
|
||||
const isLockedField = (key) => {
|
||||
const k = String(key).toLowerCase()
|
||||
return LOCKED_SUFFIXES.some((suffix) => k === suffix || k.endsWith(suffix))
|
||||
}
|
||||
|
||||
const FEATURES = {
|
||||
// ── Shipped before v3. Defaults reproduce the previous hardcoded behavior. ──
|
||||
status: { audience: 'anonymous', fields: {} },
|
||||
activity: { audience: 'anonymous', fields: {} },
|
||||
champs: { audience: 'anonymous', fields: {} },
|
||||
guilds: { audience: 'anonymous', fields: {} },
|
||||
governors: { audience: 'anonymous', fields: {} },
|
||||
// The public Houses page showed IDOC location only; owner/price were staff.
|
||||
// `owner` is the actor object on the house.decay/house.update frames;
|
||||
// `ownerName`/`ownerSerial` are the flattened spellings shapeHouse emits on the
|
||||
// REST read models. Both are listed so one rule covers the wire and the read
|
||||
// model — the flattened `ownerAcct` needs no entry, being locked by rule 1.
|
||||
houses: {
|
||||
audience: 'anonymous',
|
||||
fields: { owner: 'staff', ownerName: 'staff', ownerSerial: 'staff', price: 'staff' },
|
||||
},
|
||||
// /public/shard/online listed linked staff to everyone but gated location to
|
||||
// admin+moderator — which is exactly the `staff` rung.
|
||||
presence: { audience: 'anonymous', fields: { location: 'staff' } },
|
||||
|
||||
// ── New in v3. ──
|
||||
ruleset: { audience: 'anonymous', fields: { connect: 'anonymous' } },
|
||||
atlas: { audience: 'anonymous', fields: {} },
|
||||
// `name` is the ranked character's name inside points.board's `top` entries, and
|
||||
// it is spelled the way the WIRE spells it, not the way v3.md §7.4 describes it
|
||||
// ("characterName"). projectValue matches on the literal JSON key, so a rule
|
||||
// named for the field's meaning rather than its key silently does nothing — the
|
||||
// same failure §3.6.1 records for the flattened `ownerAcct` spelling. Within a
|
||||
// leaderboards payload `name` can only be a character name: the board's own
|
||||
// display name arrives as `nameString`/`nameNumber`.
|
||||
leaderboards: { audience: 'anonymous', fields: { name: 'anonymous' } },
|
||||
// Shop name, owner character name and vendor location are already globally
|
||||
// visible in-game via the stock Vendor Search gump, so publishing them is not
|
||||
// a new disclosure — but they stay configurable so an admin can tighten them.
|
||||
//
|
||||
// `ownerName` and `location` were pre-wired here by Part A, before the frame
|
||||
// existed; both were re-checked against the real `vendor.listing` and both are
|
||||
// genuine keys on it (unlike leaderboards' `characterName`, which was inert).
|
||||
// `location` is a NESTED object on the wire and on the read model precisely so
|
||||
// that one rule hides map, coordinates, region and house together — five flat
|
||||
// keys would be five rules that drift apart.
|
||||
//
|
||||
// `ownerSerial` is listed alongside `ownerName` for the same reason `houses`
|
||||
// lists both: an admin who hides the owner's name and is left with a serial
|
||||
// that every other board resolves back to that name has not hidden anything.
|
||||
market: {
|
||||
audience: 'anonymous',
|
||||
fields: { ownerName: 'anonymous', ownerSerial: 'anonymous', location: 'anonymous' },
|
||||
},
|
||||
}
|
||||
|
||||
const FEATURE_NAMES = Object.keys(FEATURES)
|
||||
const isFeature = (name) => Object.hasOwn(FEATURES, name)
|
||||
|
||||
// ── Kind → feature ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// Every event kind that may ever leave the admin channel must appear here.
|
||||
// Anything else is admin-only by omission (rule 2). This map is seeded from
|
||||
// what PUBLIC_KINDS listed before v3, so the public stream carries exactly the
|
||||
// same kinds it did — now attributed to a feature that an admin can re-gate.
|
||||
|
||||
const KIND_FEATURE = new Map(
|
||||
Object.entries({
|
||||
// status / lifecycle
|
||||
'server.hello': 'status',
|
||||
'server.shutdown': 'status',
|
||||
'server.crashed': 'status',
|
||||
'economy.supply': 'status',
|
||||
// activity feed
|
||||
'player.death': 'activity',
|
||||
'player.murdered': 'activity',
|
||||
'mob.killed': 'activity',
|
||||
'quest.complete': 'activity',
|
||||
'skill.gain': 'activity',
|
||||
'fame.change': 'activity',
|
||||
'karma.change': 'activity',
|
||||
'mob.login': 'activity',
|
||||
'mob.logout': 'activity',
|
||||
// boards
|
||||
'champ.update': 'champs',
|
||||
'champ.remove': 'champs',
|
||||
'guild.update': 'guilds',
|
||||
'guild.remove': 'guilds',
|
||||
'guild.join': 'guilds',
|
||||
'city.update': 'governors',
|
||||
'presence.online': 'presence',
|
||||
'region.enter': 'presence',
|
||||
// house.decay is the IDOC signal the public Houses page renders. The full
|
||||
// registry (house.update / house.remove — owner, price, co-owners) stays
|
||||
// off the map deliberately, so it remains admin-only exactly as before.
|
||||
'house.decay': 'houses',
|
||||
// v3
|
||||
'world.ruleset': 'ruleset',
|
||||
'points.board': 'leaderboards',
|
||||
// vendor.listing IS mapped, but the market feature ships with its stream
|
||||
// disabled (see DEFAULT_STREAM_OFF): a live firehose of full vendor
|
||||
// inventories would be the site's biggest bandwidth consumer and no page
|
||||
// needs it live. An admin can turn it on.
|
||||
'vendor.listing': 'market',
|
||||
'vendor.listing.remove': 'market',
|
||||
}),
|
||||
)
|
||||
|
||||
// Features whose SSE fan-out is off unless an admin enables it. The REST reads
|
||||
// are unaffected; only the live stream is suppressed.
|
||||
const DEFAULT_STREAM_OFF = new Set(['market'])
|
||||
|
||||
// Back-compat: the set of kinds that reach an anonymous viewer under the default
|
||||
// config. shardEvents `/feed` filtering and notificationStreams.js both consume
|
||||
// this. Derived from the map above rather than hand-maintained, so the two can
|
||||
// no longer drift.
|
||||
const PUBLIC_KINDS = new Set(
|
||||
[...KIND_FEATURE.entries()]
|
||||
.filter(([, feature]) => {
|
||||
if (DEFAULT_STREAM_OFF.has(feature)) return false
|
||||
return FEATURES[feature].audience === 'anonymous'
|
||||
})
|
||||
.map(([kind]) => kind),
|
||||
)
|
||||
|
||||
// ── Config (DB-backed, cached) ─────────────────────────────────────────────
|
||||
|
||||
const CONFIG_TTL_MS = 5000
|
||||
let cache = null
|
||||
let cachedAt = 0
|
||||
|
||||
// Merge a stored row over its compiled default. Unknown feature names in the DB
|
||||
// are ignored (a stale row from a removed feature must not resurrect it), and an
|
||||
// invalid rung falls back to the default rather than failing open.
|
||||
function applyRow(name, row) {
|
||||
const base = FEATURES[name]
|
||||
const audience = isLevel(row?.audience) ? row.audience : base.audience
|
||||
const fields = { ...base.fields }
|
||||
for (const [field, level] of Object.entries(row?.fieldRules || {})) {
|
||||
if (isLockedField(field)) continue // rule 1: not configurable
|
||||
if (isLevel(level)) fields[field] = level
|
||||
}
|
||||
return {
|
||||
enabled: row ? !!row.enabled : true,
|
||||
audience,
|
||||
fields,
|
||||
stream: row?.stream == null ? !DEFAULT_STREAM_OFF.has(name) : !!row.stream,
|
||||
}
|
||||
}
|
||||
|
||||
function compileDefaults() {
|
||||
const out = {}
|
||||
for (const name of FEATURE_NAMES) out[name] = applyRow(name, null)
|
||||
return out
|
||||
}
|
||||
|
||||
// Read the config, cached briefly. Falls back to compiled defaults if the DB is
|
||||
// unreachable — the defaults reproduce pre-v3 behavior, so a DB blip degrades to
|
||||
// "what the site did before" rather than to "everything is public".
|
||||
async function getConfig() {
|
||||
const now = Date.now()
|
||||
if (cache && now - cachedAt < CONFIG_TTL_MS) return cache
|
||||
try {
|
||||
const rows = await db.listAll()
|
||||
const byName = new Map(rows.map((r) => [r.feature, r]))
|
||||
const out = {}
|
||||
for (const name of FEATURE_NAMES) out[name] = applyRow(name, byName.get(name))
|
||||
cache = out
|
||||
cachedAt = now
|
||||
} catch (err) {
|
||||
log.error('getConfig; falling back to defaults', err)
|
||||
cache = cache || compileDefaults()
|
||||
cachedAt = now
|
||||
}
|
||||
return cache
|
||||
}
|
||||
|
||||
const invalidate = () => {
|
||||
cache = null
|
||||
cachedAt = 0
|
||||
}
|
||||
|
||||
// ── Viewer level ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// anonymous no session
|
||||
// logged_in authenticated, no linked game account
|
||||
// player authenticated with a linked game account
|
||||
// staff admin | moderator — the same set as the existing `modAccess` gate.
|
||||
// `editor` is a CONTENT role with no shard privilege today, so it
|
||||
// resolves by link status like any other member; mapping it to staff
|
||||
// here would silently widen what editors can see.
|
||||
// admin admin
|
||||
//
|
||||
// Staff always satisfy the `player` rung (rank order guarantees it) even without
|
||||
// a linked account, matching the existing rule that /player/* is role-agnostic
|
||||
// self-service.
|
||||
|
||||
// Same TTL as the config cache: this decides a privilege rung, so an unlinked
|
||||
// (or newly relinked) account must not keep the old answer for long. Anonymous,
|
||||
// staff and admin callers short-circuit before this runs, so the lookup only
|
||||
// costs a query on the logged-in-member path.
|
||||
const LINK_TTL_MS = CONFIG_TTL_MS
|
||||
const linkCache = new Map() // userId → { hasLink, at }
|
||||
|
||||
async function hasLinkedAccount(userId) {
|
||||
const hit = linkCache.get(userId)
|
||||
const now = Date.now()
|
||||
if (hit && now - hit.at < LINK_TTL_MS) return hit.hasLink
|
||||
let hasLink = false
|
||||
try {
|
||||
const links = await shardLinks.listForUser(userId)
|
||||
hasLink = Array.isArray(links) && links.length > 0
|
||||
} catch (err) {
|
||||
log.warn('hasLinkedAccount failed; treating as unlinked', { message: err.message })
|
||||
}
|
||||
linkCache.set(userId, { hasLink, at: now })
|
||||
return hasLink
|
||||
}
|
||||
|
||||
// Drop a user's cached link status (called when a link is created or removed so
|
||||
// the rung takes effect immediately rather than up to LINK_TTL_MS later).
|
||||
const forgetUser = (userId) => linkCache.delete(userId)
|
||||
|
||||
async function viewerLevel(req) {
|
||||
const viewer = req.user || auth.getUserFromRequest(req)
|
||||
if (!viewer) return 'anonymous'
|
||||
if (viewer.role === 'admin') return 'admin'
|
||||
if (viewer.role === 'moderator') return 'staff'
|
||||
return (await hasLinkedAccount(viewer.id)) ? 'player' : 'logged_in'
|
||||
}
|
||||
|
||||
// ── Enforcement ────────────────────────────────────────────────────────────
|
||||
|
||||
// Route gate. 404 when the feature is disabled (do not leak that it exists);
|
||||
// 403 when it exists but the viewer sits below its audience. Stashes the
|
||||
// resolved level on the request so controllers can project without re-resolving.
|
||||
function requireFeature(name) {
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
const config = await getConfig()
|
||||
const feature = config[name]
|
||||
if (!feature || !feature.enabled) return res.status(404).json({ message: 'Not Found' })
|
||||
const level = await viewerLevel(req)
|
||||
req.viewerLevel = level
|
||||
if (!meets(level, feature.audience)) return res.status(403).json({ message: 'Forbidden' })
|
||||
return next()
|
||||
} catch (err) {
|
||||
log.error(`requireFeature(${name})`, err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strip the fields a viewer at `level` may not see. Applies the locked rules
|
||||
// first (so acct/webId can never survive below admin), then the feature's
|
||||
// configured field rules. Recurses into arrays and nested objects because the
|
||||
// sensitive fields sit inside actor sub-objects (guild.leader, city.governor).
|
||||
// Only ARRAYS and PLAIN objects are walked. A Date, Buffer or other class
|
||||
// instance is a value, not a bag of fields: rebuilding one key-by-key would
|
||||
// return `{}` (a Date has no enumerable own properties), which is how the DB-
|
||||
// backed read models — whose rows carry real Date columns — differ from the
|
||||
// pure-JSON wire frames the projection was first written against.
|
||||
const isPlainObject = (v) => {
|
||||
if (v === null || typeof v !== 'object') return false
|
||||
const proto = Object.getPrototypeOf(v)
|
||||
return proto === Object.prototype || proto === null
|
||||
}
|
||||
|
||||
function projectValue(value, rules, level) {
|
||||
if (Array.isArray(value)) return value.map((v) => projectValue(v, rules, level))
|
||||
if (!isPlainObject(value)) return value
|
||||
const out = {}
|
||||
for (const [key, v] of Object.entries(value)) {
|
||||
// Locked fields are checked by meaning first, so no configured rule (and no
|
||||
// flattened spelling) can widen them past `admin`.
|
||||
const required = isLockedField(key) ? 'admin' : rules[key]
|
||||
if (required && !meets(level, required)) continue
|
||||
out[key] = projectValue(v, rules, level)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Project a payload for one feature. `level` defaults to admin-equivalent only
|
||||
// when explicitly passed; callers should always pass a resolved level.
|
||||
function projectFeature(name, payload, level, config) {
|
||||
const feature = config?.[name]
|
||||
const rules = { ...LOCKED_FIELDS, ...(feature ? feature.fields : {}) }
|
||||
return projectValue(payload, rules, level)
|
||||
}
|
||||
|
||||
// Convenience for controllers: resolve config once, project, return.
|
||||
async function project(name, payload, req) {
|
||||
const config = await getConfig()
|
||||
const level = req.viewerLevel || (await viewerLevel(req))
|
||||
return projectFeature(name, payload, level, config)
|
||||
}
|
||||
|
||||
// Is this event kind allowed to reach a viewer at `level`? Fail closed on an
|
||||
// unmapped kind (rule 2), and honour both the feature gate and its stream flag.
|
||||
function kindVisibleTo(kind, level, config) {
|
||||
if (level === 'admin') return true
|
||||
const name = KIND_FEATURE.get(kind)
|
||||
if (!name) return false // rule 2: unmapped ⇒ admin-only
|
||||
const feature = config?.[name]
|
||||
if (!feature || !feature.enabled || !feature.stream) return false
|
||||
return meets(level, feature.audience)
|
||||
}
|
||||
|
||||
// The event kinds a viewer at `level` may read under the CURRENT config. This is
|
||||
// the live counterpart of PUBLIC_KINDS, which is a module-load constant derived
|
||||
// from the compiled DEFAULTS and therefore cannot answer "may THIS viewer see
|
||||
// this kind, given what the admin has configured?".
|
||||
//
|
||||
// Deliberately ignores the `stream` flag: that governs SSE fan-out only, so a
|
||||
// feature whose live firehose is off (market) is still readable from the stored
|
||||
// history. Unmapped kinds are absent by construction (rule 2).
|
||||
function visibleKinds(level, config) {
|
||||
return [...KIND_FEATURE.entries()]
|
||||
.filter(([, name]) => {
|
||||
const feature = config?.[name]
|
||||
return !!feature && feature.enabled && meets(level, feature.audience)
|
||||
})
|
||||
.map(([kind]) => kind)
|
||||
}
|
||||
|
||||
// The features a viewer at `level` can actually see — drives SPA nav so it never
|
||||
// renders a link that would 403.
|
||||
function visibleFeatures(level, config) {
|
||||
return FEATURE_NAMES.filter((name) => {
|
||||
const feature = config[name]
|
||||
return feature.enabled && meets(level, feature.audience)
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
LADDER,
|
||||
FEATURES,
|
||||
FEATURE_NAMES,
|
||||
LOCKED_FIELDS,
|
||||
KIND_FEATURE,
|
||||
PUBLIC_KINDS,
|
||||
DEFAULT_STREAM_OFF,
|
||||
isLevel,
|
||||
isFeature,
|
||||
isLockedField,
|
||||
rank,
|
||||
meets,
|
||||
getConfig,
|
||||
invalidate,
|
||||
compileDefaults,
|
||||
viewerLevel,
|
||||
forgetUser,
|
||||
requireFeature,
|
||||
projectFeature,
|
||||
project,
|
||||
kindVisibleTo,
|
||||
visibleKinds,
|
||||
visibleFeatures,
|
||||
}
|
||||
Reference in New Issue
Block a user