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, `{ "": "" }`. * * 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, }