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

@@ -192,6 +192,185 @@ 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,
@@ -199,4 +378,13 @@ module.exports = {
getPending,
setPending,
clearPending,
countCreatures,
listCreatures,
getCreature,
listCreaturePlaces,
listCreaturePoints,
listCreatureCompanions,
listRegions,
listLandmarks,
listChampions,
}