feat(atlas): serve the spawn atlas and give operators a panel for it

Protocol 3.0 order 3 (Part C), second of two website PRs. #112 built the data
pipeline; this makes it reachable — six public routes, five admin ones, two
public pages and an admin panel. Still website-only: no plugin, no sidecar, no
new event kinds, no wire change.

The API sits at /api/v1/public/atlas, not under /public/shard. Nothing here
touches the sidecar, so the pages stay complete while the shard is down, and a
/shard prefix would imply a dependency the atlas does not have. Unlike /shard/*
it IS site-mode gated, like /posts and /wiki: a bestiary is site content.

Every route carries requireFeature('atlas') and projects its response. The atlas
feature declares no sensitive fields, so the projection is a no-op today — the
call is there because v3.md 3.6.1's rule is that the FIRST field needing a gate
should be covered by construction rather than by a retrofit.

Two bugs the UI surfaced, both fixed here:

Respawn delays were stored in the wrong unit, sometimes. XmlSpawner writes
MinDelay/MaxDelay in minutes and switches to seconds only when a delay does not
divide into whole minutes, flagging it per record with DelayInSec. A `5` means
five minutes on one spawner and five seconds on the next, both plausible, and
the pipeline stored the raw number. 170 of 6,455 stock spawners are second
flagged. The parser normalises to seconds; the API and UI carry seconds.

That exposed the hash gate as a trap. "Has the tree changed?" is the wrong
question on its own: an install whose maps never change would have kept serving
the old readings forever, because the only thing compared was the tree.
PARSER_VERSION is now stored beside the source hashes and a mismatch counts as
drift, so any future parse correction lands on the next boot.

Also renamed the detail route's spawn-point array to `spawners` — it was
`points`, which is the COUNT on the search route, so one key meant a number in
one place and an array in the other.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U7CBg11prhLimL9iHSX1bP
This commit is contained in:
2026-07-28 19:51:22 -05:00
parent f3d084e046
commit 7c769ea8fd
24 changed files with 4416 additions and 8 deletions

View 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/shardVisibility')
const log = require('../../../utils/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,
}

View File

@@ -0,0 +1,128 @@
// 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.
const express = require('express')
const { param, query } = require('express-validator')
const atlas = require('./atlas.controller')
const siteMode = require('../../../middleware/siteMode')
const validate = require('../../../middleware/validate')
const { requireFeature } = require('../../../utils/shardVisibility')
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

View File

@@ -21,6 +21,7 @@ const postsRouter = require('./posts.router')
const wikiRouter = require('./wiki.router')
const pagesRouter = require('./pages.router')
const shardRouter = require('./shard.router')
const atlasRouter = require('./atlas.router')
const siteRouter = require('./site.router')
const publicRouter = express.Router()
@@ -32,6 +33,11 @@ publicRouter.use('/wiki', wikiRouter)
publicRouter.use('/pages', pagesRouter)
// Live shard data, never site-mode gated.
publicRouter.use('/shard', shardRouter)
// The spawn atlas: static shard CONTENT, parsed from the shard's ServUO tree
// rather than fetched from the sidecar. Deliberately not under /shard — nothing
// here depends on the bridge — and site-mode gated per route like the content
// routers above, which is the other half of that distinction.
publicRouter.use('/atlas', atlasRouter)
// The four singletons that own no path segment of their own: /settings, /status,
// /version and /contact. Mounted at the group root, last — safe only because