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, sameSources } = require('../../utils/spawnAtlasSource') // The two readers are reached through the namespace rather than destructured, // because a test stubs them ON the module object and a binding taken at require // time would keep calling the real one — quietly, and while reporting success. const spawnAtlasSource = require('../../utils/spawnAtlasSource') const { TreeBridgeError } = require('../../utils/treeBridge') const uoLinkConfig = require('../uoLinkConfig/uoLinkConfig.model') 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' /** * Is there a shard to ask? * * Both halves matter. `baseUrl` alone is an install that has been configured and * then switched off, and calling it would spend a 12 s timeout to learn what the * row already says. Never throws: an unreadable config means "no shard", and a * local tree is a working answer. */ async function shardLinked() { try { const config = await uoLinkConfig.getSafe() return Boolean(config?.enabled && config?.baseUrl) } catch { return false } } /** * Which end this atlas is built from (docs/link/v8.md §10, §17.7). * * **The bridge wins whenever uo-link is configured and enabled**, the same rule * the cliloc table follows and for the same reason: there is no version of "which * source?" an operator benefits from answering, so there is no setting asking it. * A local tree remains the source where there is no shard link — development, * same-host installs — plus the one-off explicit path an admin can type, which is * an instruction rather than a default and therefore overrules this. */ async function sourceFor(pathOverride = '') { const explicit = String(pathOverride || '').trim() if (explicit !== '') return { kind: 'fs', root: explicit } if (await shardLinked()) return { kind: 'bridge', root: '' } return { kind: 'fs', root: await getServuoPath() } } /** How a source reads in a log line or an admin panel. */ const describe = (source) => (source.kind === 'bridge' ? 'the shard bridge' : source.root) /** * 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, `{ "": "" }`. * * 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 } /** * The art each creature gets when the atlas is rebuilt. * * **`replaceAtlas` empties `shard_spawn_creatures` and refills it**, so anything * on that row is destroyed on every refresh — and a refresh happens on every * boot. Before protocol 8 that cost nothing: `art` came from a file on disk and * was simply re-read. As of phase 3 it can also come from an IMPORT, which is * expensive to obtain and whose gate (the shard's client-file hashes) would say * "unchanged" for weeks afterwards. So the imported values are re-derived here, * on the way past, rather than being restored by an import that has no reason to * run again. * * **The operator's map is spread last and therefore wins.** Someone who drew * their own creature portraits must not have them replaced by a sprite rip on the * next Update — the one property §12 states outright. * * Never throws: the asset tables are the newer half of this pair, and an atlas * refresh must not start failing because an asset query did. Losing the imported * art for one boot is recoverable by pressing Import; a boot that cannot rebuild * the atlas is not. */ async function artForAtlas() { const operator = loadArtMap() try { // eslint-disable-next-line global-require const assetsDb = require('../shardAssets/shardAssets.db') const derived = await assetsDb.artBySlug() return { ...derived, ...operator } } catch (err) { log.warn('imported creature art could not be read; using the operator map alone', { error: err.message, }) return operator } } async function applyAtlas(atlas) { return db.replaceAtlas({ ...atlas, pointTypes: pointTypeRows(atlas.points) }, await artForAtlas()) } /** * 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, nor by the bridge. const source = await sourceFor(pathOverride) const root = source.root const where = describe(source) if (source.kind === 'fs' && root === '') { return { status: 'skipped', reason: 'no ServUO path configured' } } let hashes try { hashes = await spawnAtlasSource.hashFrom(source) } catch (err) { if (err instanceof AtlasSourceError || err instanceof TreeBridgeError) { return { status: 'unavailable', source: source.kind, reason: err.message, code: err.code, path: where, } } return { status: 'failed', source: source.kind, reason: err.message, path: where } } 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', source: source.kind, path: where } } // 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', source: source.kind, path: where, reason: 'refresh previously rejected', } } let atlas try { atlas = await spawnAtlasSource.buildFrom(source) } catch (err) { if (err instanceof AtlasSourceError || err instanceof TreeBridgeError) { return { status: 'unavailable', source: source.kind, reason: err.message, code: err.code, path: where, } } return { status: 'failed', source: source.kind, reason: err.message, path: where } } 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, source: source.kind, path: where, 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', source: source.kind, path: where, counts, addedFacets, removedFacets, } } catch (err) { return { status: 'failed', source: source.kind, reason: err.message, path: where } } } /** 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 source = await sourceFor(pathOverride) const root = source.root const configured = source.kind === 'bridge' || root !== '' 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 (configured) { try { // On the bridge this is the MANIFEST, not the tree: 141 rows and ~32 KB, // with no file bytes crossing the wire to answer "has anything changed". // It is still a shard round trip on an admin page load, which is why it is // here and not on the boot path (§17.7). const hashes = await spawnAtlasSource.hashFrom(source) 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, source: source.kind, path: describe(source), 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 { // **On the bridge it imports nothing**, deliberately, and by the same // reasoning as the cliloc table (§17.7). A local tree hashes in ~120 ms and // skips; asking the shard would put a sidecar round trip in the boot sequence // to answer a question whose answer is "no" on every restart that did not // follow a map edit — and editing spawn files is an operator action, so // importing became one: Admin → Shard → Import. Whatever atlas is loaded // keeps serving until then. if ((await sourceFor()).kind === 'bridge') { log.info('spawn atlas comes from the shard; import is admin-triggered (Admin → Shard)') return { status: 'skipped', source: 'bridge', reason: 'the shard is the atlas source' } } 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, })), } } /** * Decoration types, shaped for a dropdown. * * `type` is both the value and the label: it is the ServUO class name and it is * what the plugin constructs from, so showing the author anything else would * put a name on the screen that does not appear in the refusal if the shard * declines it. */ async function listDecorTypes(opts = {}) { const rows = await db.listDecorTypes(opts) return rows.map((r) => ({ type: r.type, itemId: Number(r.item_id) || 0, uses: Number(r.uses) || 0, })) } /** * Spawners an author can name, searched (Phase 12b). * * The value is the `UniqueId` because that is what the shard resolves a target * by; the label is the spawner's own name, which is what an author recognises * ("fel bulbous putrification" is a place they know). A row with no name still * answers, labelled by its id, rather than being dropped: a nameless spawner is * still a spawner somebody may need to turn down. */ async function listSpawners(opts = {}) { const rows = await db.listSpawners(opts) return rows.map((r) => ({ uniqueId: r.unique_id, name: r.name || null, facet: r.facet, region: r.region || null, landmark: r.landmark || null, maxCount: Number(r.max_count) || 0, })) } /** * One decoration type, or null. * * The events decoration verb resolves through this rather than passing a type * name straight through, which does two things at once: it fetches the item id * the graphic-holder classes need, and it keeps the verb to the vocabulary this * shard's own decoration files use. A type the atlas has never seen is refused * here rather than constructed there. */ async function getDecorType(type) { const row = await db.getDecorType(String(type == null ? '' : type).trim()) if (!row) return null return { type: row.type, itemId: Number(row.item_id) || 0, uses: Number(row.uses) || 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, listDecorTypes, listSpawners, getDecorType, listChampions, listFacets, publicMeta, }