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>
486 lines
16 KiB
JavaScript
486 lines
16 KiB
JavaScript
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,
|
|
}
|