feat(server): port the UO models, utils and schema fragment

The data half of the extraction: 8 model directories, 13 utils, the shard
stream catalog and the 27-table schema fragment with its purge.

server/core.js is what makes the port a one-line import change per file rather
than a signature change per function. Ported code requires its dependencies at
file scope -- `const { query } = require('../../core')` -- which runs before
register() has been called and before any ctx exists. So every member is a
stable function that resolves ctx when CALLED, and nothing may be destructured
off ctx at init either, because core is free to hand over a getter.

Two helpers are vendored rather than taken from ctx, and the line between them
is the point. utils/excerpt.js is core's deriveExcerpt -- nine lines of pure
text handling. Core's sanitiser next to it was NOT copied: a second copy of a
security control diverges silently the moment either is fixed. announceLinks.js
vendors legError and articleUrl the same way, but baseUrl could not be: core's
reads APP_BASE_URL, and §2.7 forbids a module reading core's environment, so it
comes off ctx.site.baseUrl.

The schema fragment is core's 27 shard_*/uo_link_* statements, verbs CREATE,
ALTER and UPDATE only, every CREATE TABLE guarded. Two of its tables carry a
foreign key INTO users, which is allowed and is why the replay order matters --
core's schema is in place before this runs. The reverse never occurs and must
not: it would make core unable to boot without a module installed.

One real port bug caught by the integration run, not by tests: the atlas art
map resolved `../../../db/data`, which pointed at core's tree when this file
lived there and points outside server/ now. A path that happens to resolve is
exactly what survives a green suite, because the absent-file branch returns {}
and looks like the normal case.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-11 12:06:26 -05:00
committed by Claude
parent 47809854ef
commit fe3251a543
40 changed files with 7967 additions and 3 deletions

View File

@@ -0,0 +1,491 @@
const fs = require('fs')
const path = require('path')
const db = require('./shardAtlas.db')
const core = require('../../core')
const { settings } = core
const { slugify } = require('../../utils/spawnAtlasParse')
const {
AtlasSourceError,
PARSER_VERSION,
buildAtlas,
hashSources,
sameSources,
} = require('../../utils/spawnAtlasSource')
const log = require('../../core').logger('shardAtlas')
// The spawn atlas, refreshed from the shard's own ServUO tree.
//
// The tree is the single source of truth. Nothing is precomputed and committed,
// because a shard's maps change over its lifetime — facets get added, replaced
// or renamed — and a snapshot in the repo would go stale against the world
// players actually see. So the atlas is re-derived on every boot.
//
// Two rules govern the boot path:
//
// 1. **It never blocks startup.** No configured path, an unreadable path, a
// malformed file, a database error — all of it is caught and logged. The
// site comes up either way, serving whatever atlas it already had.
// 2. **A facet disappearing is not applied automatically.** Losing a facet is
// the signature of a half-copied or mid-update tree as much as of a real
// map change, and the two are indistinguishable from here. The refresh is
// staged for a human instead, and an admin approves or rejects it.
//
// Everything else — new facets, renamed regions, changed spawns — applies
// straight away, because none of it can silently destroy data an operator would
// miss.
const SETTING_KEY = 'spawn_atlas_servuo_path'
/**
* Where the ServUO tree lives.
*
* The admin setting wins over the environment so an operator can point the
* atlas at a different tree without a redeploy, matching how the rest of the
* shard integration is admin-managed rather than env-configured. `SERVUO_PATH`
* remains as the deploy-time default, since the path usually describes a mount
* that the deployment sets up.
*/
async function getServuoPath() {
try {
const configured = await settings.get(SETTING_KEY)
if (configured && String(configured).trim() !== '') return String(configured).trim()
} catch {
// Settings unavailable is not fatal — fall through to the env default.
}
const fromEnv = process.env.SERVUO_PATH
return fromEnv && fromEnv.trim() !== '' ? fromEnv.trim() : ''
}
async function setServuoPath(value, updatedBy = null) {
return settings.set(SETTING_KEY, String(value ?? '').trim(), updatedBy)
}
/**
* Optional operator-supplied art map, `{ "<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.
*/
// Resolved from ctx.paths.moduleRoot rather than by walking up from __dirname.
// The ported default was `../../../db/data`, which pointed at core's tree when
// this file lived there and points OUTSIDE server/ now — a path that happens to
// resolve is exactly the kind of port bug that survives a green test suite,
// because the absent-file branch returns {} and looks like the normal case.
function loadArtMap(dir = path.join(core.moduleRoot, 'server', '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,
}