From 7c769ea8fdf78293504a9e4217a5a3b4b198267a Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 28 Jul 2026 19:51:22 -0500 Subject: [PATCH] feat(atlas): serve the spawn atlas and give operators a panel for it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01U7CBg11prhLimL9iHSX1bP --- client/src/App.jsx | 6 + client/src/api/client.js | 49 + client/src/components/SiteHeader.jsx | 1 + client/src/routes/admin/AdminLayout.jsx | 2 + client/src/routes/admin/views/SpawnAtlas.jsx | 285 +++ client/src/routes/public/Atlas.jsx | 310 +++ client/src/routes/public/AtlasCreature.jsx | 201 ++ client/test/apiClient.test.js | 41 + server/routes.guards.json | 107 + server/routes.manifest.json | 44 + server/src/model/shardAtlas/shardAtlas.db.js | 188 ++ .../src/model/shardAtlas/shardAtlas.model.js | 193 +- server/src/router/v1/admin/shard.router.js | 69 + .../router/v1/admin/shardAtlas.controller.js | 117 + .../src/router/v1/public/atlas.controller.js | 134 ++ server/src/router/v1/public/atlas.router.js | 128 ++ server/src/router/v1/public/index.js | 6 + server/src/utils/spawnAtlasParse.js | 26 +- server/src/utils/spawnAtlasSource.js | 17 + server/swagger/swagger-output.json | 2015 +++++++++++++++++ server/swagger/swagger.js | 180 ++ server/test/atlasController.test.js | 238 ++ server/test/spawnAtlas.parse.test.js | 42 +- server/test/spawnAtlas.source.test.js | 25 +- 24 files changed, 4416 insertions(+), 8 deletions(-) create mode 100644 client/src/routes/admin/views/SpawnAtlas.jsx create mode 100644 client/src/routes/public/Atlas.jsx create mode 100644 client/src/routes/public/AtlasCreature.jsx create mode 100644 server/src/router/v1/admin/shardAtlas.controller.js create mode 100644 server/src/router/v1/public/atlas.controller.js create mode 100644 server/src/router/v1/public/atlas.router.js create mode 100644 server/test/atlasController.test.js diff --git a/client/src/App.jsx b/client/src/App.jsx index 2e8b4fa..37af57c 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -23,6 +23,8 @@ import Guilds from './routes/public/Guilds.jsx' import Governors from './routes/public/Governors.jsx' import Houses from './routes/public/Houses.jsx' import Rules from './routes/public/Rules.jsx' +import Atlas from './routes/public/Atlas.jsx' +import AtlasCreature from './routes/public/AtlasCreature.jsx' import Wiki from './routes/wiki/Wiki.jsx' import WikiArticle from './routes/wiki/WikiArticle.jsx' import CmsPage from './routes/public/CmsPage.jsx' @@ -42,6 +44,7 @@ import BotActivityAdmin from './routes/admin/views/BotActivityAdmin.jsx' import DiscordBotAdmin from './routes/admin/views/DiscordBotAdmin.jsx' import ShardAdmin from './routes/admin/views/ShardAdmin.jsx' import ShardVisibility from './routes/admin/views/ShardVisibility.jsx' +import SpawnAtlasAdmin from './routes/admin/views/SpawnAtlas.jsx' import ShardOps from './routes/admin/views/ShardOps.jsx' import AdminCharacters from './routes/admin/views/AdminCharacters.jsx' import AdminCharacter from './routes/admin/views/AdminCharacter.jsx' @@ -100,6 +103,8 @@ export default function App() { } /> } /> } /> + } /> + } /> } /> } /> {/* CMS pages: top-level /:slug, matched only after the named routes @@ -146,6 +151,7 @@ export default function App() { } /> } /> } /> + } /> req('/public/shard/features'), }, + + // ----- spawn atlas (Protocol 3.0 Part C) ----- + // Static shard CONTENT, parsed from the shard's own ServUO tree — deliberately + // not under /shard, because nothing here depends on the sidecar and the pages + // stay populated while the shard is offline. + atlas: { + creatures: (opts = {}) => { + const qs = new URLSearchParams() + if (opts.q) qs.set('q', opts.q) + if (opts.facet) qs.set('facet', opts.facet) + if (opts.limit) qs.set('limit', opts.limit) + if (opts.offset) qs.set('offset', opts.offset) + return req(`/public/atlas/creatures${withQs(qs.toString())}`) + }, + creature: (slug, opts = {}) => { + const qs = new URLSearchParams() + if (opts.facet) qs.set('facet', opts.facet) + if (opts.points) qs.set('points', opts.points) + return req(`/public/atlas/creatures/${encodeURIComponent(slug)}${withQs(qs.toString())}`) + }, + regions: (opts = {}) => { + const qs = new URLSearchParams() + if (opts.facet) qs.set('facet', opts.facet) + if (opts.q) qs.set('q', opts.q) + return req(`/public/atlas/regions${withQs(qs.toString())}`) + }, + landmarks: (opts = {}) => { + const qs = new URLSearchParams() + if (opts.facet) qs.set('facet', opts.facet) + if (opts.q) qs.set('q', opts.q) + return req(`/public/atlas/landmarks${withQs(qs.toString())}`) + }, + // The CONFIGURED altar roster, not the live board — see shard.champs() for + // "which spawn is on level 3 right now". + champions: (facet) => req(`/public/atlas/champions${withQs(facet ? `facet=${encodeURIComponent(facet)}` : '')}`), + meta: () => req('/public/atlas/meta'), + }, // Full paths (incl. /api/v1) for the browser EventSource — the req() wrapper is // fetch-only, so SSE subscribers build the URL from here. The admin stream // carries every kind (incl. audit/cheat) and needs the staff session cookie. @@ -360,6 +397,18 @@ export const api = { saveShardVisibility: (features) => req('/admin/shard/visibility', { method: 'PUT', body: { features } }), + // ----- spawn atlas operation (admin only) ----- + // The atlas re-derives itself from the ServUO tree on every boot; these are + // for applying a map change without a restart, and for the approve/reject + // decision on a refresh that would remove a facet. + atlas: { + status: () => req('/admin/shard/atlas'), + import: (force = false) => req('/admin/shard/atlas/import', { method: 'POST', body: { force } }), + approve: () => req('/admin/shard/atlas/approve', { method: 'POST', body: {} }), + reject: () => req('/admin/shard/atlas/reject', { method: 'POST', body: {} }), + setPath: (path) => req('/admin/shard/atlas/path', { method: 'PUT', body: { path } }), + }, + // ----- in-game staff operations: write plane + support queue (admin/moderator) ----- // `actor` is stamped server-side from the session — never sent from here. shardOps: { diff --git a/client/src/components/SiteHeader.jsx b/client/src/components/SiteHeader.jsx index f3bf8eb..bcabfe1 100644 --- a/client/src/components/SiteHeader.jsx +++ b/client/src/components/SiteHeader.jsx @@ -24,6 +24,7 @@ const NAV = [ { label: 'Governors', to: '/site/governors', feature: 'governors' }, { label: 'Houses', to: '/site/houses', feature: 'houses' }, { label: 'Rules', to: '/site/rules', feature: 'ruleset' }, + { label: 'Atlas', to: '/site/atlas', feature: 'atlas' }, { label: 'About', to: '/site/about' }, ] diff --git a/client/src/routes/admin/AdminLayout.jsx b/client/src/routes/admin/AdminLayout.jsx index 5fe08b1..200476a 100644 --- a/client/src/routes/admin/AdminLayout.jsx +++ b/client/src/routes/admin/AdminLayout.jsx @@ -79,6 +79,7 @@ const NAV = [ { to: '/admin/discord-bot', label: 'Discord Bot', icon: IconBot, roles: ['admin'] }, { to: '/admin/shard', label: 'Shard (uo-link)', icon: IconShard, roles: ['admin'] }, { to: '/admin/shard-visibility', label: 'Shard Visibility', icon: IconShard, roles: ['admin'] }, + { to: '/admin/shard-atlas', label: 'Spawn Atlas', icon: IconShard, roles: ['admin'] }, { to: '/admin/bot-activity', label: 'Web Bot Activity', icon: IconPulse, roles: ['admin'] }, ], }, @@ -108,6 +109,7 @@ const TITLES = { '/admin/discord-bot': 'Discord Bot', '/admin/shard': 'Shard (uo-link)', '/admin/shard-visibility': 'Shard Visibility', + '/admin/shard-atlas': 'Spawn Atlas', '/admin/characters': 'My Characters', '/admin/auth-providers': 'Authentication', '/admin/users': 'Users', diff --git a/client/src/routes/admin/views/SpawnAtlas.jsx b/client/src/routes/admin/views/SpawnAtlas.jsx new file mode 100644 index 0000000..87bc852 --- /dev/null +++ b/client/src/routes/admin/views/SpawnAtlas.jsx @@ -0,0 +1,285 @@ +import { useCallback, useEffect, useState } from 'react' +import { Loading, ErrorState } from '../../../components/PageState.jsx' +import { api } from '../../../api/client.js' + +// ── Admin · Spawn atlas ───────────────────────────────────────────────────── +// +// The atlas re-derives itself from the shard's ServUO tree on every boot, so +// this panel exists for the three things a restart cannot do: +// +// • point it at a different tree, +// • apply a map change without restarting, and +// • answer a refresh that was parsed but deliberately NOT applied because it +// would remove a facet. +// +// That last one is the reason the panel is worth building. Losing a facet looks +// exactly like a half-copied or mid-update tree, and boot cannot tell them +// apart — so it stages the decision for a human instead of guessing. Until +// someone decides here, the site keeps serving the atlas it already had. + +// A refresh reports its outcome rather than throwing (the boot path must never +// be stopped by a bad tree), so these are answers, not errors — the panel says +// what happened in the shard's terms instead of showing a failure box. +const OUTCOME = { + imported: (r) => + `Imported — ${r.counts?.points?.toLocaleString() ?? '?'} spawners, ${r.counts?.creatures?.toLocaleString() ?? '?'} creatures.`, + unchanged: (r) => + r.reason === 'refresh previously rejected' + ? 'Unchanged — this exact tree was already reviewed and declined.' + : 'Unchanged — the tree matches what is already loaded.', + needsReview: () => 'Staged for review: this refresh would remove a facet, so it was not applied.', + unavailable: (r) => `The tree could not be read: ${r.reason || 'unknown reason'}`, + skipped: () => 'No ServUO path is configured, so there is nothing to import.', + failed: (r) => `Refresh failed: ${r.reason || 'unknown reason'}`, + rejected: () => 'Declined. It will not be offered again until the tree changes.', +} + +const describe = (result) => (OUTCOME[result?.status] || (() => `Result: ${result?.status}`))(result) + +function Row({ label, children }) { + return ( +
+ {label} + {children} +
+ ) +} + +function PendingReview({ pending, busy, onApprove, onReject }) { + const declined = pending.status === 'rejected' + return ( +
+

+ {declined ? 'A refresh was declined' : 'A refresh is waiting for you'} +

+

+ {declined ? ( + <> + This tree was reviewed and declined, so it is not offered again until the files change. + Approving now applies it anyway. + + ) : ( + <> + The tree parses cleanly but would remove {pending.removedFacets?.length || 0} facet + + {(pending.removedFacets?.length || 0) === 1 ? '' : 's'} the site is currently serving. That + is what a half-copied or mid-update tree looks like as well as a real map change, so it was + not applied. Approving re-parses the tree as it is right now — if you have since fixed the + mount, what lands is the corrected import. + + )} +

+ {(pending.removedFacets || []).join(', ') || '—'} + {(pending.addedFacets || []).join(', ') || '—'} + {pending.detectedAt ? new Date(pending.detectedAt).toLocaleString() : '—'} +
+ + {!declined && ( + + )} +
+
+ ) +} + +export default function SpawnAtlas() { + const [status, setStatus] = useState(null) + const [path, setPath] = useState('') + const [force, setForce] = useState(false) + const [loading, setLoading] = useState(true) + const [busy, setBusy] = useState(false) + const [error, setError] = useState('') + const [msg, setMsg] = useState('') + + const load = useCallback(async () => { + setLoading(true) + setError('') + try { + const data = await api.admin.atlas.status() + setStatus(data) + setPath(data.path || '') + } catch (err) { + setError(err.message || 'Could not load atlas status.') + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + load() + }, [load]) + + // Every mutating action shares this: run it, report what it said, then reload + // status so the panel reflects the world rather than what we assumed happened. + async function run(action, fn) { + setBusy(true) + setMsg('') + setError('') + try { + const result = await fn() + setMsg(describe(result)) + const fresh = await api.admin.atlas.status() + setStatus(fresh) + setPath(fresh.path || '') + } catch (err) { + setError(err.message || `Could not ${action}.`) + } finally { + setBusy(false) + } + } + + async function savePath() { + setBusy(true) + setMsg('') + setError('') + try { + const fresh = await api.admin.atlas.setPath(path.trim()) + setStatus(fresh) + setPath(fresh.path || '') + setMsg( + fresh.path === '' + ? 'Path cleared. The atlas will be skipped on the next boot; what is loaded keeps serving.' + : fresh.treeReadable + ? 'Saved. The tree is readable — import when you are ready.' + : 'Saved, but the tree could not be read from here. Check the mount and permissions.', + ) + } catch (err) { + setError(err.message || 'Could not save the path.') + } finally { + setBusy(false) + } + } + + if (loading) return + if (error && !status) return + + const counts = status?.counts || null + + return ( +
+
+

+ Spawn atlas +

+

+ The bestiary and spawn map on the public site, parsed from the shard’s own ServUO files. + It refreshes itself on every server start; everything here is for the times you don’t want + to wait for one. Nothing on this page touches the sidecar — the atlas is shard content, not + shard state, and stays complete while the shard is down. +

+
+ + {status?.pending && ( + run('approve the refresh', () => api.admin.atlas.approve())} + onReject={() => run('decline the refresh', () => api.admin.atlas.reject())} + /> + )} + +
+

+ What is loaded +

+ + {status?.importedAt ? new Date(status.importedAt).toLocaleString() : 'Never'} + + {status?.facets?.length ? status.facets.join(', ') : '—'} + {counts && ( + <> + {counts.points?.toLocaleString() ?? '—'} + {counts.creatures?.toLocaleString() ?? '—'} + + {`${counts.regions?.toLocaleString() ?? '—'} / ${counts.landmarks?.toLocaleString() ?? '—'}`} + + {counts.champions?.toLocaleString() ?? '—'} + + )} + + {!status?.configured ? 'No path set' : status.treeReadable ? 'Yes' : 'No'} + + + {status?.drift == null ? '—' : status.drift ? 'Yes — an import would pick it up' : 'No'} + +
+ +
+

+ ServUO tree +

+

+ Where the website reads the shard’s spawn files from — the same host, a bind mount or a + shared volume. This setting wins over the SERVUO_PATH deploy default, so the + mount can move without a redeploy. Leave it blank to turn the atlas off. +

+
+ setPath(e.target.value)} + placeholder="/srv/servuo" + style={{ flex: '1 1 320px', minWidth: 0 }} + /> + +
+
+ +
+

+ Re-import +

+

+ Applies a map change without restarting. An unchanged tree costs nothing — the source files + are hashed first and skipped when they match. A refresh that would remove a facet still + comes back here for approval rather than being applied. +

+
+ + +
+
+ + {(msg || error) && ( +
+ {msg && {msg}} + {error && {error}} +
+ )} +
+ ) +} diff --git a/client/src/routes/public/Atlas.jsx b/client/src/routes/public/Atlas.jsx new file mode 100644 index 0000000..049e5af --- /dev/null +++ b/client/src/routes/public/Atlas.jsx @@ -0,0 +1,310 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { Link } from 'react-router-dom' +import PublicLayout from '../../components/PublicLayout.jsx' +import PageHeader from '../../components/PageHeader.jsx' +import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx' +import { useAsync } from '../../lib/useAsync.js' +import { api } from '../../api/client.js' + +// ── The spawn atlas ───────────────────────────────────────────────────────── +// +// What the shard CONTAINS, as opposed to what it is doing: which creatures +// spawn, where, and which champion altars are configured. There is no live feed +// here and no `connected` indicator, deliberately — this is parsed from the +// shard's own files and stays complete while the shard is down. +// +// Facet names come from the shard's data, never from a list in this file. A +// shard running custom maps gets its own names in the filter with no code +// change (docs/link/v3.md §6.1 R2). + +const PAGE = 50 + +const num = (v) => (Number.isFinite(v) ? v.toLocaleString() : '—') + +const TABS = [ + { key: 'creatures', label: 'Creatures' }, + { key: 'champions', label: 'Champion altars' }, + { key: 'places', label: 'Places' }, +] + +function Chip({ active, onClick, children }) { + return ( + + ) +} + +function CreatureCard({ creature }) { + const facets = Object.entries(creature.facets || {}).sort((a, b) => b[1] - a[1]) + return ( + +
+
+ {creature.name} +
+
+ {facets.length === 0 + ? '—' + : facets.map(([facet, n]) => `${facet} (${n})`).join(' · ')} +
+
+
+
{num(creature.total)}
+
+ {num(creature.points)} spawners +
+
+ + ) +} + +// The creature list owns its own paging rather than going through useAsync: a +// "load more" appends to what is already on screen, which a hook that resets to +// `{ loading: true, data: null }` on every dependency change cannot express. +function Creatures({ q, facet }) { + const [state, setState] = useState({ loading: true, error: null, items: [], total: 0 }) + const [more, setMore] = useState(false) + + const load = useCallback( + async (offset) => { + const page = await api.atlas.creatures({ q, facet, limit: PAGE, offset }) + return page + }, + [q, facet], + ) + + useEffect(() => { + let alive = true + setState({ loading: true, error: null, items: [], total: 0 }) + load(0) + .then((page) => { + if (alive) setState({ loading: false, error: null, items: page.creatures || [], total: page.total || 0 }) + }) + .catch((error) => alive && setState({ loading: false, error, items: [], total: 0 })) + return () => { + alive = false + } + }, [load]) + + const loadMore = async () => { + setMore(true) + try { + const page = await load(state.items.length) + setState((s) => ({ ...s, items: [...s.items, ...(page.creatures || [])], total: page.total ?? s.total })) + } catch { + // A failed "load more" leaves what is already on screen alone; the button + // simply stays available to retry. + } finally { + setMore(false) + } + } + + if (state.loading) return + if (state.error) return + if (state.items.length === 0) { + return Nothing in the atlas matches that. + } + + return ( + <> +

+ Showing {num(state.items.length)} of {num(state.total)} +

+
+ {state.items.map((c) => ( + + ))} +
+ {state.items.length < state.total && ( +
+ +
+ )} + + ) +} + +// The CONFIGURED altar roster — where the altars are and what each summons. The +// live board ("it is on level 3 right now") is a different page, /site/champs, +// fed by the sidecar. Both exist; they are not the same thing. +function Champions({ facet }) { + const { loading, error, data } = useAsync(() => api.atlas.champions(facet), [facet]) + if (loading) return + if (error) return + if (!data || data.length === 0) return No champion altars are configured. + return ( +
+ {data.map((champ) => ( +
+
+
+ {champ.label || champ.name} +
+
+ {champ.facet} + {champ.group ? ` · ${champ.group}` : ''} · {champ.x}, {champ.y} +
+
+ + {champ.randomType ? 'Random champion' : champ.type || '—'} + +
+ ))} +
+ ) +} + +// Regions and landmarks together: both answer "where is that?", and splitting +// them into two tabs would make the visitor guess which list a name lives in. +function Places({ q, facet }) { + const { loading, error, data } = useAsync( + () => Promise.all([api.atlas.regions({ q, facet }), api.atlas.landmarks({ q, facet })]), + [q, facet], + ) + const rows = useMemo(() => { + if (!data) return [] + const [regions, landmarks] = data + return [ + ...regions.map((r) => ({ key: `r:${r.facet}:${r.name}`, name: r.name, facet: r.facet, detail: r.parent || r.type || 'Region', kind: 'Region' })), + ...landmarks.map((l) => ({ key: `l:${l.facet}:${l.group || ''}:${l.name}:${l.x}:${l.y}`, name: l.group ? `${l.group} — ${l.name}` : l.name, facet: l.facet, detail: `${l.x}, ${l.y}`, kind: 'Landmark' })), + ].sort((a, b) => a.name.localeCompare(b.name)) + }, [data]) + + if (loading) return + if (error) return + if (rows.length === 0) return No regions or landmarks match that. + return ( +
+ {rows.map((row) => ( +
+ {row.name} + {row.facet} · {row.detail} + {row.kind} +
+ ))} +
+ ) +} + +export default function Atlas() { + const [tab, setTab] = useState('creatures') + const [input, setInput] = useState('') + const [q, setQ] = useState('') + const [facet, setFacet] = useState('') + const meta = useAsync(() => api.atlas.meta()) + + // Debounced: typing "lizardman" should be one request, not nine. + useEffect(() => { + const timer = setTimeout(() => setQ(input.trim()), 250) + return () => clearTimeout(timer) + }, [input]) + + const facets = meta.data?.facets || [] + const counts = meta.data?.counts || null + const imported = meta.data?.importedAt ? new Date(meta.data.importedAt) : null + + return ( + +
+ + + {/* The atlas is only as good as its placement rate, so the page states + it rather than implying every spawner resolved to a named place. */} + {counts && ( +

+ {num(counts.creatures)} creatures across {num(counts.points)} spawners + {Number.isFinite(counts.unresolvedPoints) && counts.points + ? ` · ${Math.round(((counts.points - counts.unresolvedPoints) / counts.points) * 100)}% placed to a named region or landmark` + : ''} + {imported ? ` · parsed ${imported.toLocaleDateString()}` : ''} +

+ )} + +
+ {TABS.map((t) => ( + setTab(t.key)}> + {t.label} + + ))} +
+ + {tab !== 'champions' && ( + setInput(e.target.value)} + placeholder={tab === 'creatures' ? 'Search creatures…' : 'Search regions and landmarks…'} + style={{ width: '100%', marginBottom: 12 }} + /> + )} + + {facets.length > 0 && ( +
+ setFacet('')}> + All facets + + {facets.map((f) => ( + setFacet(f)}> + {f} + + ))} +
+ )} + + {meta.error && } + {!meta.error && !meta.loading && !imported && ( + The spawn atlas has not been imported yet. + )} + + {!meta.error && imported && ( + <> + {tab === 'creatures' && } + {tab === 'champions' && } + {tab === 'places' && } + + )} +
+
+ ) +} diff --git a/client/src/routes/public/AtlasCreature.jsx b/client/src/routes/public/AtlasCreature.jsx new file mode 100644 index 0000000..d479067 --- /dev/null +++ b/client/src/routes/public/AtlasCreature.jsx @@ -0,0 +1,201 @@ +import { useMemo, useState } from 'react' +import { Link, useParams } from 'react-router-dom' +import PublicLayout from '../../components/PublicLayout.jsx' +import PageHeader from '../../components/PageHeader.jsx' +import { Loading, ErrorState, EmptyState } from '../../components/PageState.jsx' +import { useAsync } from '../../lib/useAsync.js' +import { api } from '../../api/client.js' + +// One creature: where it spawns, and what spawns alongside it. +// +// `places` is the point of the page — the aggregate that turns 62 raw +// coordinates into "Shrines, Isamu-Jima, Yew". The individual spawners are +// available underneath for the reader who actually wants a coordinate, but they +// are secondary and collapsed by default. + +const num = (v) => (Number.isFinite(v) ? v.toLocaleString() : '—') + +// Spawn delays are stored in seconds. A raw "1200" tells the reader nothing. +function delay(min, max) { + const fmt = (s) => (s >= 60 ? `${Math.round(s / 60)}m` : `${s}s`) + if (!Number.isFinite(min) || !Number.isFinite(max)) return null + if (min === max) return fmt(min) + return `${fmt(min)}–${fmt(max)}` +} + +function Panel({ title, right, children }) { + return ( +
+
+

+ {title} +

+ {right} +
+ {children} +
+ ) +} + +function Places({ places }) { + if (places.length === 0) { + return

No placed spawners.

+ } + return ( +
+ {places.map((place) => ( +
+ {place.label} + + {place.facet} · {num(place.spawners)} spawner{place.spawners === 1 ? '' : 's'} · up to{' '} + {num(place.maxAlive)} at once + +
+ ))} +
+ ) +} + +function Spawners({ spawners, truncated }) { + const [open, setOpen] = useState(false) + if (spawners.length === 0) return null + return ( + setOpen((v) => !v)} + style={{ background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer', fontSize: '0.78rem' }} + > + {open ? 'Hide' : `Show ${num(spawners.length)}`} + + } + > + {open && ( +
+ + + + + + + + + + + + {spawners.map((s) => ( + + + + + + + + ))} + +
PlaceFacetCoordsMaxRespawn
{s.label}{s.facet}{s.x}, {s.y}{num(s.maxCount)}{delay(s.minDelay, s.maxDelay) || '—'}
+ {truncated && ( +

+ Only the largest spawners are listed. +

+ )} +
+ )} +
+ ) +} + +export default function AtlasCreature() { + const { slug } = useParams() + const { loading, error, data } = useAsync(() => api.atlas.creature(slug), [slug]) + + // A 404 here means "no such creature in this atlas", which is a real answer + // and not a failure — a visitor following a stale link deserves to be told + // that plainly rather than shown a generic error box. + const missing = error?.status === 404 || error?.message === 'Not Found' + + const facets = useMemo( + () => Object.entries(data?.facets || {}).sort((a, b) => b[1] - a[1]), + [data], + ) + + return ( + +
+

+ + ← Spawn atlas + +

+ + {loading && } + {error && !missing && } + {missing && Nothing by that name spawns on this shard.} + + {!loading && !error && data && ( + <> + + +
+ + {facets.map(([facet, n]) => `${facet} (${n})`).join(' · ')} + + } + > + + + + + + {data.alsoHere?.length > 0 && ( + +
+ {data.alsoHere.map((other) => ( + + {other.name} ×{num(other.shared)} + + ))} +
+
+ )} +
+ + )} +
+
+ ) +} diff --git a/client/test/apiClient.test.js b/client/test/apiClient.test.js index a5fda7c..03ea7dd 100644 --- a/client/test/apiClient.test.js +++ b/client/test/apiClient.test.js @@ -140,3 +140,44 @@ test('DELETE self-service session revoke encodes the id and uses the DELETE meth assert.equal(calls[0].opts.method, 'DELETE') assert.match(calls[0].url, /\/auth\/me\/sessions\/a%20b%2Fc$/) }) + +// ── spawn atlas (Protocol 3.0 Part C) ─────────────────────────────────── +// The atlas lives at /public/atlas, NOT under /public/shard: it is static shard +// content parsed from the shard's own files, so it must not look sidecar-backed. +// Asserted here because the split is a design decision, not an accident of +// spelling. +test('atlas reads hit /public/atlas, not /public/shard', async () => { + willReply({ body: { creatures: [] } }) + await api.atlas.creatures() + assert.equal(calls[0].url, '/api/v1/public/atlas/creatures') +}) + +test('atlas.creatures() sends only the filters that are set', async () => { + willReply({ body: { creatures: [] } }) + await api.atlas.creatures({ q: 'lizard man', facet: 'Ter Mur', limit: 25 }) + const url = new URL(calls[0].url, 'http://x') + assert.equal(url.pathname, '/api/v1/public/atlas/creatures') + assert.equal(url.searchParams.get('q'), 'lizard man') + assert.equal(url.searchParams.get('facet'), 'Ter Mur') + assert.equal(url.searchParams.get('limit'), '25') + assert.equal(url.searchParams.get('offset'), null) // 0 is not sent +}) + +test('atlas.creature() encodes the slug and carries the facet filter through', async () => { + willReply({ body: {} }) + await api.atlas.creature('lizardman/rare', { facet: 'Felucca' }) + assert.match(calls[0].url, /\/public\/atlas\/creatures\/lizardman%2Frare\?facet=Felucca$/) +}) + +test('admin atlas actions use the right methods and bodies', async () => { + willReply({ body: {} }) + await api.admin.atlas.import(true) + assert.equal(calls[0].url, '/api/v1/admin/shard/atlas/import') + assert.equal(calls[0].opts.method, 'POST') + assert.equal(calls[0].opts.body, JSON.stringify({ force: true })) + + willReply({ body: {} }) + await api.admin.atlas.setPath('/srv/servuo') + assert.equal(calls[1].opts.method, 'PUT') + assert.equal(calls[1].opts.body, JSON.stringify({ path: '/srv/servuo' })) +}) diff --git a/server/routes.guards.json b/server/routes.guards.json index 3be5846..e9ba237 100644 --- a/server/routes.guards.json +++ b/server/routes.guards.json @@ -636,6 +636,55 @@ "requireAuth" ] }, + { + "method": "GET", + "path": "/api/v1/admin/shard/atlas", + "handlers": 2, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/shard/atlas/approve", + "handlers": 2, + "gates": [ + "noindex", + "requireAuth" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/shard/atlas/import", + "handlers": 4, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "PUT", + "path": "/api/v1/admin/shard/atlas/path", + "handlers": 4, + "gates": [ + "noindex", + "requireAuth", + "middleware", + "validate" + ] + }, + { + "method": "POST", + "path": "/api/v1/admin/shard/atlas/reject", + "handlers": 2, + "gates": [ + "noindex", + "requireAuth" + ] + }, { "method": "GET", "path": "/api/v1/admin/shard/audit", @@ -1781,6 +1830,64 @@ "validate" ] }, + { + "method": "GET", + "path": "/api/v1/public/atlas/champions", + "handlers": 5, + "gates": [ + "middleware", + "validate", + "siteMode" + ] + }, + { + "method": "GET", + "path": "/api/v1/public/atlas/creatures", + "handlers": 8, + "gates": [ + "middleware", + "validate", + "siteMode" + ] + }, + { + "method": "GET", + "path": "/api/v1/public/atlas/creatures/:slug", + "handlers": 7, + "gates": [ + "middleware", + "validate", + "siteMode" + ] + }, + { + "method": "GET", + "path": "/api/v1/public/atlas/landmarks", + "handlers": 6, + "gates": [ + "middleware", + "validate", + "siteMode" + ] + }, + { + "method": "GET", + "path": "/api/v1/public/atlas/meta", + "handlers": 3, + "gates": [ + "siteMode" + ] + }, + { + "method": "GET", + "path": "/api/v1/public/atlas/regions", + "handlers": 6, + "gates": [ + "middleware", + "validate", + "siteMode" + ] + }, { "method": "POST", "path": "/api/v1/public/contact", diff --git a/server/routes.manifest.json b/server/routes.manifest.json index 0ce4477..f42fcb4 100644 --- a/server/routes.manifest.json +++ b/server/routes.manifest.json @@ -257,6 +257,26 @@ "method": "GET", "path": "/api/v1/admin/shard/accounts" }, + { + "method": "GET", + "path": "/api/v1/admin/shard/atlas" + }, + { + "method": "POST", + "path": "/api/v1/admin/shard/atlas/approve" + }, + { + "method": "POST", + "path": "/api/v1/admin/shard/atlas/import" + }, + { + "method": "PUT", + "path": "/api/v1/admin/shard/atlas/path" + }, + { + "method": "POST", + "path": "/api/v1/admin/shard/atlas/reject" + }, { "method": "GET", "path": "/api/v1/admin/shard/audit" @@ -713,6 +733,30 @@ "method": "GET", "path": "/api/v1/player/shard/vendors/:account" }, + { + "method": "GET", + "path": "/api/v1/public/atlas/champions" + }, + { + "method": "GET", + "path": "/api/v1/public/atlas/creatures" + }, + { + "method": "GET", + "path": "/api/v1/public/atlas/creatures/:slug" + }, + { + "method": "GET", + "path": "/api/v1/public/atlas/landmarks" + }, + { + "method": "GET", + "path": "/api/v1/public/atlas/meta" + }, + { + "method": "GET", + "path": "/api/v1/public/atlas/regions" + }, { "method": "POST", "path": "/api/v1/public/contact" diff --git a/server/src/model/shardAtlas/shardAtlas.db.js b/server/src/model/shardAtlas/shardAtlas.db.js index 14e9e0a..ae05106 100644 --- a/server/src/model/shardAtlas/shardAtlas.db.js +++ b/server/src/model/shardAtlas/shardAtlas.db.js @@ -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, } diff --git a/server/src/model/shardAtlas/shardAtlas.model.js b/server/src/model/shardAtlas/shardAtlas.model.js index 054a982..73e5a26 100644 --- a/server/src/model/shardAtlas/shardAtlas.model.js +++ b/server/src/model/shardAtlas/shardAtlas.model.js @@ -6,6 +6,7 @@ const settings = require('../settings/settings.model') const { slugify } = require('../../utils/spawnAtlasParse') const { AtlasSourceError, + PARSER_VERSION, buildAtlas, hashSources, sameSources, @@ -120,6 +121,14 @@ async function applyAtlas(atlas) { * `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 @@ -142,7 +151,11 @@ async function refresh({ force = false, approve = false, path: pathOverride = '' ? Object.fromEntries(Object.entries(meta.source).map(([label, v]) => [label, v.sha256])) : null - if (!force && sameSources(hashes, loaded)) { + // 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 } } @@ -227,7 +240,9 @@ async function status({ path: pathOverride = '' } = {}) { const loaded = meta?.source ? Object.fromEntries(Object.entries(meta.source).map(([l, v]) => [l, v.sha256])) : null - drift = !sameSources(hashes, loaded) + // 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 } @@ -282,6 +297,173 @@ async function refreshOnBoot() { } } +// ── 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, @@ -293,4 +475,11 @@ module.exports = { pointTypeRows, loadArtMap, SETTING_KEY, + searchCreatures, + getCreature, + listRegions, + listLandmarks, + listChampions, + listFacets, + publicMeta, } diff --git a/server/src/router/v1/admin/shard.router.js b/server/src/router/v1/admin/shard.router.js index 1a2d6c6..ed03ff7 100644 --- a/server/src/router/v1/admin/shard.router.js +++ b/server/src/router/v1/admin/shard.router.js @@ -24,6 +24,7 @@ const { body, param } = require('express-validator') const shardOps = require('./shardOps.controller') const shardVisibility = require('./shardVisibility.controller') +const shardAtlas = require('./shardAtlas.controller') const selfShard = require('../player/shard.controller') const { requireRole } = require('../../../utils/auth') const validate = require('../../../middleware/validate') @@ -235,6 +236,74 @@ shardRouter.get( shardOps.listHouses, ) +// ── Spawn atlas (admin only) ────────────────────────────────────────── +// Operating the atlas import. Admin-only rather than moderator: it reads a path +// on the server's filesystem and replaces every atlas table, which is closer to +// a deploy action than to moderation. +// +// These routes sit under /admin/shard even though the public ones deliberately +// do NOT sit under /public/shard. That is not an inconsistency: the public split +// says "this data does not come from the sidecar", while the admin panel is +// simply part of shard administration and belongs beside the rest of it. +shardRouter.get( + '/atlas', + // #swagger.tags = ['Admin · Shard'] + // #swagger.summary = 'Spawn atlas status: path, drift, counts, pending review (admin only)' + // #swagger.description = 'Where the ServUO tree is, whether it can be read, whether its source files have drifted from the loaded atlas, and any refresh staged for approval. The public /atlas/meta route reports the game world only; the filesystem detail is here.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Atlas status', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasStatus" } } } } */ + /* #swagger.responses[403] = { description: 'Admin role required', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + adminOnly, + shardAtlas.getStatus, +) +shardRouter.post( + '/atlas/import', + // #swagger.tags = ['Admin · Shard'] + // #swagger.summary = 'Re-import the spawn atlas from the ServUO tree (admin only)' + // #swagger.description = 'Applies a map change without a restart. `force` reimports even when the source hashes match what is loaded. A refresh that would REMOVE a facet is still staged for approval rather than applied — that decision is never taken implicitly. An unreadable tree answers 200 with status "unavailable" rather than 500: the refresh contract reports outcomes instead of throwing, and the admin needs to be told what is wrong with the path.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: false, content: { "application/json": { schema: { type: "object", properties: { force: { type: "boolean", description: "Reimport even if the tree is unchanged." } } } } } } */ + /* #swagger.responses[200] = { description: 'What happened', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasRefreshResult" } } } } */ + adminOnly, + body('force').optional().isBoolean(), + validate, + shardAtlas.importAtlas, +) +shardRouter.post( + '/atlas/approve', + // #swagger.tags = ['Admin · Shard'] + // #swagger.summary = 'Approve a staged atlas refresh that removes a facet (admin only)' + // #swagger.description = 'Re-parses the tree and applies it, facet loss included. Only the decision was stored, never the parsed world, so what lands matches the tree at approval time — an operator who has since fixed a half-copied mount gets the corrected import.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'What happened', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasRefreshResult" } } } } */ + adminOnly, + shardAtlas.approve, +) +shardRouter.post( + '/atlas/reject', + // #swagger.tags = ['Admin · Shard'] + // #swagger.summary = 'Reject a staged atlas refresh (admin only)' + // #swagger.description = 'Keeps the current atlas and remembers the decision against those exact source hashes, so a declined refresh does not re-prompt on every restart. Changing the tree asks again.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.responses[200] = { description: 'Rejected', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasRefreshResult" } } } } */ + /* #swagger.responses[404] = { description: 'Nothing is awaiting review', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + adminOnly, + shardAtlas.reject, +) +shardRouter.put( + '/atlas/path', + // #swagger.tags = ['Admin · Shard'] + // #swagger.summary = 'Set the ServUO tree the atlas reads from (admin only)' + // #swagger.description = 'Persisted as a setting, which wins over the SERVUO_PATH deploy default so the mount can move without a redeploy. Blank clears it and the atlas is simply skipped on the next boot. Deliberately does not import as a side effect — the response carries the refreshed status so the panel can offer that as the next step.' + // #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }] + /* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", required: ["path"], properties: { path: { type: "string", description: "Absolute path to the ServUO server root. Blank disables the atlas." } } } } } } */ + /* #swagger.responses[200] = { description: 'Atlas status after the change', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasStatus" } } } } */ + adminOnly, + body('path').isString().isLength({ max: 512 }), + validate, + shardAtlas.setPath, +) + // ── Feature visibility (admin only) ─────────────────────────────────── // Who can see which shard surface, and which sensitive fields within it. This // decides what ANONYMOUS visitors get, so it sits above the moderator tier. diff --git a/server/src/router/v1/admin/shardAtlas.controller.js b/server/src/router/v1/admin/shardAtlas.controller.js new file mode 100644 index 0000000..b674673 --- /dev/null +++ b/server/src/router/v1/admin/shardAtlas.controller.js @@ -0,0 +1,117 @@ +// ── Admin · Spawn atlas ──────────────────────────────────────────────────── +// +// Operating the atlas import: where the ServUO tree is, whether it has drifted +// from what is loaded, and the approve/reject decision for a refresh that would +// remove a facet (docs/website/SPAWN_ATLAS.md). +// +// The policy lives in the model. This controller does three things and no more: +// it validates input, it maps a refresh RESULT onto an HTTP status, and it +// records the action in the admin activity log. +// +// **A refresh result is not an exception.** `shardAtlas.refresh()` reports +// `unavailable` / `failed` / `needsReview` rather than throwing, because the boot +// path must never be stopped by a bad tree. That contract is preserved here: an +// unreadable mount is a 200 carrying `status: 'unavailable'`, not a 500. The +// admin needs to be told what is wrong with their path, and a 500 says only +// "something broke". + +const atlas = require('../../../model/shardAtlas/shardAtlas.model') +const activity = require('../../../model/activity/activity.model') + +const log = require('../../../utils/logger')('admin-shard-atlas') + +// GET /admin/shard/atlas — what is loaded, what the tree looks like, what is +// staged. Unlike the public /atlas/meta route this DOES carry the filesystem +// path and the drift flag: that is the whole point of the panel. +async function getStatus(req, res) { + try { + return res.json(await atlas.status()) + } catch (err) { + log.error('getStatus', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// POST /admin/shard/atlas/import — apply a map change without a restart. +// +// `force` reimports even when the source hashes match what is loaded (the escape +// hatch for "the database is wrong but the tree is not"). Facet loss is still +// staged rather than applied — approving is a separate, explicit act. +async function importAtlas(req, res) { + try { + const force = !!req.body?.force + const result = await atlas.refresh({ force }) + await activity.log({ + req, + action: 'shard.atlas.import', + detail: { force, status: result.status, counts: result.counts ?? null }, + }) + return res.json(result) + } catch (err) { + log.error('importAtlas', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// POST /admin/shard/atlas/approve — apply a staged refresh, facet loss and all. +// +// Re-parses the tree rather than applying something captured at boot: only the +// DECISION was stored, so what lands matches the tree as it is now. If the +// operator has since fixed a half-copied mount, the approved import is simply +// the corrected one — which is the desired outcome, not a surprise. +async function approve(req, res) { + try { + const result = await atlas.approvePending() + await activity.log({ + req, + action: 'shard.atlas.approve', + detail: { status: result.status, removed: result.removedFacets ?? null }, + }) + return res.json(result) + } catch (err) { + log.error('approveAtlas', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// POST /admin/shard/atlas/reject — keep the current atlas and remember the +// decision against those exact source hashes, so a declined refresh does not +// re-prompt on every restart. Changing the tree asks again. +async function reject(req, res) { + try { + const result = await atlas.rejectPending() + if (result.status === 'none') { + return res.status(404).json({ message: 'No refresh is awaiting review.' }) + } + await activity.log({ req, action: 'shard.atlas.reject', detail: {} }) + return res.json(result) + } catch (err) { + log.error('rejectAtlas', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// PUT /admin/shard/atlas/path — point the atlas at a different ServUO tree. +// +// Persisted as a setting, which wins over the SERVUO_PATH env default so an +// operator can move the mount without a redeploy. Blank clears it, which turns +// the atlas off (boot skips, the loaded atlas keeps serving) — that is a +// legitimate thing to want, so it is allowed rather than validated away. +// +// Deliberately does NOT import as a side effect: changing where the atlas reads +// from and reloading it are separate decisions, and an operator fixing a typo +// should not have a multi-thousand-row replace happen under them. The response +// carries the refreshed status so the panel can offer the import immediately. +async function setPath(req, res) { + try { + const value = String(req.body?.path ?? '').trim() + await atlas.setServuoPath(value, req.user?.id ?? null) + await activity.log({ req, action: 'shard.atlas.path', detail: { path: value } }) + return res.json(await atlas.status()) + } catch (err) { + log.error('setAtlasPath', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +module.exports = { getStatus, importAtlas, approve, reject, setPath } diff --git a/server/src/router/v1/public/atlas.controller.js b/server/src/router/v1/public/atlas.controller.js new file mode 100644 index 0000000..b002519 --- /dev/null +++ b/server/src/router/v1/public/atlas.controller.js @@ -0,0 +1,134 @@ +// ── Public: the spawn atlas ──────────────────────────────────────────────── +// +// A browsable catalogue of what the shard CONTAINS — which creatures spawn, +// where, how many, and which champion altars are configured. Everything here is +// a plain indexed read of the tables the boot-time import fills from the shard's +// own ServUO tree (docs/website/SPAWN_ATLAS.md). +// +// Two properties separate this from /public/shard/*: +// +// • **Nothing touches the sidecar.** The atlas is static shard content, not +// live shard state, so these pages stay fully populated while the shard is +// down. That is why the routes are mounted at /public/atlas and are +// siteMode-gated like /posts and /wiki, rather than under /shard. +// • **The live champion feed is a different thing.** `/atlas/champions` is the +// configured roster ("there is an Unholy Terror altar in Deceit"); +// `/shard/champs` is the running state ("it is on level 3 right now"). +// +// Every response is still passed through `projectFeature` for the `atlas` +// feature. It declares no sensitive fields today, so the projection is a +// no-op — but v3.md §3.6.1's rule is that a read path returning shard data and +// not projecting is a bug, and the cost of honouring it is one call per handler +// rather than a retrofit the first time a field needs gating. + +const atlas = require('../../../model/shardAtlas/shardAtlas.model') +const visibility = require('../../../utils/shardVisibility') + +const log = require('../../../utils/logger')('public-atlas') + +const FEATURE = 'atlas' + +// Query params arrive as strings; express-validator has already bounded them. +const int = (value, fallback) => { + const n = Number.parseInt(value, 10) + return Number.isFinite(n) ? n : fallback +} + +const str = (value) => (typeof value === 'string' ? value.trim() : '') + +// GET /public/atlas/creatures?q=&facet=&limit=&offset= +async function getCreatures(req, res) { + try { + const page = await atlas.searchCreatures({ + q: str(req.query.q), + facet: str(req.query.facet), + limit: int(req.query.limit, 50), + offset: int(req.query.offset, 0), + }) + return res.json(await visibility.project(FEATURE, page, req)) + } catch (err) { + log.error('atlas.getCreatures', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// GET /public/atlas/creatures/:slug — one creature, with the places it spawns. +// +// 404 means "no such creature in this atlas", which also covers "the atlas has +// never been imported" — an empty atlas has no slugs, and there is nothing more +// specific to say to an anonymous caller. +async function getCreature(req, res) { + try { + const creature = await atlas.getCreature(req.params.slug, { + facet: str(req.query.facet), + points: int(req.query.points, 200), + }) + if (!creature) return res.status(404).json({ message: 'Not Found' }) + return res.json(await visibility.project(FEATURE, creature, req)) + } catch (err) { + log.error('atlas.getCreature', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// GET /public/atlas/regions?facet=&q= +async function getRegions(req, res) { + try { + const regions = await atlas.listRegions({ + facet: str(req.query.facet), + q: str(req.query.q), + }) + return res.json(await visibility.project(FEATURE, regions, req)) + } catch (err) { + log.error('atlas.getRegions', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// GET /public/atlas/landmarks?facet=&q= +async function getLandmarks(req, res) { + try { + const landmarks = await atlas.listLandmarks({ + facet: str(req.query.facet), + q: str(req.query.q), + }) + return res.json(await visibility.project(FEATURE, landmarks, req)) + } catch (err) { + log.error('atlas.getLandmarks', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// GET /public/atlas/champions?facet= — the CONFIGURED altar roster. +async function getChampions(req, res) { + try { + const champions = await atlas.listChampions({ facet: str(req.query.facet) }) + return res.json(await visibility.project(FEATURE, champions, req)) + } catch (err) { + log.error('atlas.getChampions', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +// GET /public/atlas/meta — what is loaded: facets, counts, when it was imported. +// +// Public-safe by construction: the model omits the ServUO path, the per-file +// hashes and the pending-refresh state, all of which describe the operator's +// filesystem rather than the game world. The admin status route carries those. +async function getMeta(req, res) { + try { + return res.json(await visibility.project(FEATURE, await atlas.publicMeta(), req)) + } catch (err) { + log.error('atlas.getMeta', err) + return res.status(500).json({ message: 'Internal Server Error' }) + } +} + +module.exports = { + getCreatures, + getCreature, + getRegions, + getLandmarks, + getChampions, + getMeta, +} diff --git a/server/src/router/v1/public/atlas.router.js b/server/src/router/v1/public/atlas.router.js new file mode 100644 index 0000000..97498ec --- /dev/null +++ b/server/src/router/v1/public/atlas.router.js @@ -0,0 +1,128 @@ +// Public · Atlas — the spawn atlas / bestiary. Static shard CONTENT derived from +// the shard's own ServUO tree, not live shard state. +// +// Mounted at /api/v1/public/atlas by public/index.js. Two deliberate differences +// from the /public/shard routes next door (docs/link/v3.md §6): +// +// • **Not under /shard.** Nothing here round-trips the sidecar, and the pages +// stay fully populated while the shard is down. Mounting it under /shard +// would imply a dependency it does not have. +// • **siteMode-gated, like /posts and /wiki.** The shard routes are exempt +// because shard status is wanted *during* maintenance; a bestiary is site +// content and follows site content's rules. +// +// Every route also carries `requireFeature('atlas')` — 404 when an admin has +// disabled the feature, 403 when the caller sits below its configured audience. +// The default audience is `anonymous`, so these gates are inert until an admin +// changes something. + +const express = require('express') +const { param, query } = require('express-validator') + +const atlas = require('./atlas.controller') +const siteMode = require('../../../middleware/siteMode') +const validate = require('../../../middleware/validate') +const { requireFeature } = require('../../../utils/shardVisibility') + +const atlasRouter = express.Router() + +// Facet names come from the shard's own files and are never validated against a +// list — nothing in the codebase names a facet (§6.1 R2). Only the length is +// bounded, and the query matches exactly, so an unknown name returns an empty +// result rather than an error. +const facetParam = query('facet').optional({ values: 'falsy' }).isString().isLength({ max: 40 }) + +atlasRouter.get( + '/creatures', + requireFeature('atlas'), + // #swagger.tags = ['Public · Atlas'] + // #swagger.summary = 'Search the bestiary (paginated)' + // #swagger.description = 'Every creature the shard spawns, most numerous first. `total` is how many can be alive at once across all spawners; `points` is how many spawners mention it; `facets` maps facet name to that creature\'s share on it. Static content parsed from the shard\'s ServUO tree — unaffected by the shard being offline.' + // #swagger.parameters['q'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Substring match on the creature name (max 60 chars).' } + // #swagger.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to creatures spawning on this facet. Facet names come from the shard\'s own files; an unknown one returns an empty page.' } + // #swagger.parameters['limit'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Page size, 1..100 (default 50).' } + // #swagger.parameters['offset'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Rows to skip (default 0).' } + /* #swagger.responses[200] = { description: 'A page of creatures plus the unpaginated total', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasCreaturePage" } } } } */ + /* #swagger.responses[403] = { description: 'The atlas feature is gated above this caller', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + /* #swagger.responses[404] = { description: 'The atlas feature is disabled', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + query('q').optional({ values: 'falsy' }).isString().isLength({ max: 60 }), + facetParam, + query('limit').optional().isInt({ min: 1, max: 100 }), + query('offset').optional().isInt({ min: 0, max: 100000 }), + validate, + siteMode, + atlas.getCreatures, +) +atlasRouter.get( + '/creatures/:slug', + requireFeature('atlas'), + // #swagger.tags = ['Public · Atlas'] + // #swagger.summary = 'One creature: where it spawns, and what spawns with it' + // #swagger.description = 'The answer the atlas exists to give. `places` is the aggregate — "lizardman → Shrines, Isamu-Jima, Yew" — resolved by point-in-rect against the shard\'s own region rectangles, falling back to the nearest landmark, else "Wilderness". `spawners` lists the individual spawn points (bounded; `spawnersTruncated` says when the list was cut), and `alsoHere` is what shares those spawners.' + // #swagger.parameters['slug'] = { in: 'path', required: true, schema: { type: 'string' }, description: 'Creature slug, e.g. lizardman.' } + // #swagger.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Restrict places and spawners to one facet.' } + // #swagger.parameters['points'] = { in: 'query', required: false, schema: { type: 'integer' }, description: 'Max spawners to return, 1..1000 (default 200).' } + /* #swagger.responses[200] = { description: 'The creature', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasCreature" } } } } */ + /* #swagger.responses[404] = { description: 'No such creature in this atlas (or the feature is disabled)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */ + param('slug').isString().isLength({ min: 1, max: 120 }), + facetParam, + query('points').optional().isInt({ min: 1, max: 1000 }), + validate, + siteMode, + atlas.getCreature, +) +atlasRouter.get( + '/regions', + requireFeature('atlas'), + // #swagger.tags = ['Public · Atlas'] + // #swagger.summary = 'Named regions and their rectangles' + // #swagger.description = 'Flattened out of the shard\'s nested Regions.xml. `priority` and the rectangles are what placed each spawn point, kept so the placement can be re-derived rather than taken on trust.' + // #swagger.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to one facet.' } + // #swagger.parameters['q'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Substring match on the region name.' } + /* #swagger.responses[200] = { description: 'Regions, by facet then name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/AtlasRegion" } } } } } */ + facetParam, + query('q').optional({ values: 'falsy' }).isString().isLength({ max: 60 }), + validate, + siteMode, + atlas.getRegions, +) +atlasRouter.get( + '/landmarks', + requireFeature('atlas'), + // #swagger.tags = ['Public · Atlas'] + // #swagger.summary = 'Points of interest (dungeon levels, town markers)' + // #swagger.description = 'From the shard\'s Data/Locations files. `group` is the innermost enclosing parent ("Covetous"), which is the label worth showing over the individual marker ("Level 1").' + // #swagger.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to one facet.' } + // #swagger.parameters['q'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Substring match on the landmark name or its group.' } + /* #swagger.responses[200] = { description: 'Landmarks, by facet then group', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/AtlasLandmark" } } } } } */ + facetParam, + query('q').optional({ values: 'falsy' }).isString().isLength({ max: 60 }), + validate, + siteMode, + atlas.getLandmarks, +) +atlasRouter.get( + '/champions', + requireFeature('atlas'), + // #swagger.tags = ['Public · Atlas'] + // #swagger.summary = 'Configured champion altars (the roster, not the live board)' + // #swagger.description = 'Where the altars are and what each one summons — "there is an Unholy Terror altar in Deceit". `randomType` marks altars whose champion is drawn at activation. Do not conflate this with GET /public/shard/champs, which is the live sidecar-fed board ("it is on level 3 right now").' + // #swagger.parameters['facet'] = { in: 'query', required: false, schema: { type: 'string' }, description: 'Limit to one facet.' } + /* #swagger.responses[200] = { description: 'Altars, by facet then name', content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/AtlasChampion" } } } } } */ + facetParam, + validate, + siteMode, + atlas.getChampions, +) +atlasRouter.get( + '/meta', + requireFeature('atlas'), + // #swagger.tags = ['Public · Atlas'] + // #swagger.summary = 'What atlas is loaded: facets, counts, when it was imported' + // #swagger.description = 'Drives the facet filter and the "parsed from the shard\'s own files on " line. Reports the game world only — the ServUO path, the per-file hashes and any pending refresh are operator detail and live on the admin status route.' + /* #swagger.responses[200] = { description: 'Atlas metadata', content: { "application/json": { schema: { $ref: "#/components/schemas/AtlasMeta" } } } } */ + siteMode, + atlas.getMeta, +) + +module.exports = atlasRouter diff --git a/server/src/router/v1/public/index.js b/server/src/router/v1/public/index.js index 3f90671..4de3329 100644 --- a/server/src/router/v1/public/index.js +++ b/server/src/router/v1/public/index.js @@ -21,6 +21,7 @@ const postsRouter = require('./posts.router') const wikiRouter = require('./wiki.router') const pagesRouter = require('./pages.router') const shardRouter = require('./shard.router') +const atlasRouter = require('./atlas.router') const siteRouter = require('./site.router') const publicRouter = express.Router() @@ -32,6 +33,11 @@ publicRouter.use('/wiki', wikiRouter) publicRouter.use('/pages', pagesRouter) // Live shard data, never site-mode gated. publicRouter.use('/shard', shardRouter) +// The spawn atlas: static shard CONTENT, parsed from the shard's ServUO tree +// rather than fetched from the sidecar. Deliberately not under /shard — nothing +// here depends on the bridge — and site-mode gated per route like the content +// routers above, which is the other half of that distinction. +publicRouter.use('/atlas', atlasRouter) // The four singletons that own no path segment of their own: /settings, /status, // /version and /contact. Mounted at the group root, last — safe only because diff --git a/server/src/utils/spawnAtlasParse.js b/server/src/utils/spawnAtlasParse.js index 3b414b5..5d909f5 100644 --- a/server/src/utils/spawnAtlasParse.js +++ b/server/src/utils/spawnAtlasParse.js @@ -360,6 +360,20 @@ function tagValue(block, name) { * name. `Eodon.xml`, `GravewaterLake.xml` and the other named-area files all * carry TerMur/Trammel points, so there are 13 files but only 6 facets. */ +/** + * A spawner's respawn window, in seconds. + * + * `DelayInSec` decides the unit of `MinDelay`/`MaxDelay`; absent (older files) + * it is false, which is minutes — the same default XmlSpawner assumes. + */ +function delaySeconds(block) { + const scale = toBool(tagValue(block, 'DelayInSec')) ? 1 : 60 + return { + minDelay: toInt(tagValue(block, 'MinDelay')) * scale, + maxDelay: toInt(tagValue(block, 'MaxDelay')) * scale, + } +} + function parsePoints(source) { const text = String(source) const points = [] @@ -382,8 +396,16 @@ function parsePoints(source) { height: toInt(tagValue(block, 'Height')), range: toInt(tagValue(block, 'Range')), maxCount: toInt(tagValue(block, 'MaxCount')), - minDelay: toInt(tagValue(block, 'MinDelay')), - maxDelay: toInt(tagValue(block, 'MaxDelay')), + // Normalised to SECONDS here, because the unit is per-record. XmlSpawner + // writes minutes by default and switches to seconds only when a spawner's + // delay does not divide into whole minutes, flagging that with + // `DelayInSec` (XmlSpawner2.cs:7462-7480, read back at :6345-6358). Taken + // literally the two are indistinguishable — a `5` means five minutes on + // one spawner and five seconds on the next — so a consumer that assumed + // either unit would be wrong about the other. Stock ServUO 57.4 has ~30 + // second-flagged spawners, few enough to look like noise and quietly + // mislabel. + ...delaySeconds(block), // Time-of-day gating: TODMode 0 means "always", in which case the start // and end values are meaningless and the site must not render them. todStart: toInt(tagValue(block, 'TODStart')), diff --git a/server/src/utils/spawnAtlasSource.js b/server/src/utils/spawnAtlasSource.js index c66aa8f..10d307b 100644 --- a/server/src/utils/spawnAtlasSource.js +++ b/server/src/utils/spawnAtlasSource.js @@ -128,6 +128,21 @@ function hashSources(root) { return hashes } +/** + * Bumped whenever the parser produces DIFFERENT data from IDENTICAL source + * files — a fixed misreading, a new field, a changed unit. + * + * Without it the hash gate is a trap: an install whose tree has not changed + * would keep serving what an older parser derived, indefinitely, because the + * only thing the boot path compares is the tree. The version is stored beside + * the source hashes and a mismatch counts as drift, so a deploy that corrects + * the parse actually reaches the data. + * + * 2 — respawn delays normalised to seconds (they are per-record minutes OR + * seconds in the source, decided by `DelayInSec`). + */ +const PARSER_VERSION = 2 + /** True when two source fingerprints describe the same tree. */ function sameSources(a, b) { if (!a || !b) return false @@ -286,6 +301,7 @@ function buildAtlas(root, options = {}) { return { meta: { generatedAt: new Date().toISOString(), + parserVersion: PARSER_VERSION, landmarkRadius: options.landmarkRadius ?? undefined, counts: { facets: facets.length, @@ -310,6 +326,7 @@ function buildAtlas(root, options = {}) { module.exports = { AtlasSourceError, + PARSER_VERSION, readSources, hashSources, sameSources, diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index b98da8a..95f0632 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -40,6 +40,10 @@ "name": "Public · Shard", "description": "Live shard data ingested from the uo-link sidecar (status, feed, economy, IDOC, characters)" }, + { + "name": "Public · Atlas", + "description": "Spawn atlas / bestiary — static shard content parsed from the shard's own ServUO tree, independent of the sidecar" + }, { "name": "Admin · Account", "description": "Self-service account security (2FA, linked identities)" @@ -3616,6 +3620,227 @@ ] } }, + "/api/v1/admin/shard/atlas": { + "get": { + "tags": [ + "Admin · Shard" + ], + "summary": "Spawn atlas status: path, drift, counts, pending review (admin only)", + "description": "Where the ServUO tree is, whether it can be read, whether its source files have drifted from the loaded atlas, and any refresh staged for approval. The public /atlas/meta route reports the game world only; the filesystem detail is here.", + "responses": { + "200": { + "description": "Atlas status", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AtlasStatus" + } + } + } + }, + "403": { + "description": "Admin role required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/shard/atlas/approve": { + "post": { + "tags": [ + "Admin · Shard" + ], + "summary": "Approve a staged atlas refresh that removes a facet (admin only)", + "description": "Re-parses the tree and applies it, facet loss included. Only the decision was stored, never the parsed world, so what lands matches the tree at approval time — an operator who has since fixed a half-copied mount gets the corrected import.", + "responses": { + "200": { + "description": "What happened", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AtlasRefreshResult" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, + "/api/v1/admin/shard/atlas/import": { + "post": { + "tags": [ + "Admin · Shard" + ], + "summary": "Re-import the spawn atlas from the ServUO tree (admin only)", + "description": "Applies a map change without a restart. `force` reimports even when the source hashes match what is loaded. A refresh that would REMOVE a facet is still staged for approval rather than applied — that decision is never taken implicitly. An unreadable tree answers 200 with status \"unavailable\" rather than 500: the refresh contract reports outcomes instead of throwing, and the admin needs to be told what is wrong with the path.", + "responses": { + "200": { + "description": "What happened", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AtlasRefreshResult" + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "force": { + "type": "boolean", + "description": "Reimport even if the tree is unchanged." + } + } + } + } + } + } + } + }, + "/api/v1/admin/shard/atlas/path": { + "put": { + "tags": [ + "Admin · Shard" + ], + "summary": "Set the ServUO tree the atlas reads from (admin only)", + "description": "Persisted as a setting, which wins over the SERVUO_PATH deploy default so the mount can move without a redeploy. Blank clears it and the atlas is simply skipped on the next boot. Deliberately does not import as a side effect — the response carries the refreshed status so the panel can offer that as the next step.", + "responses": { + "200": { + "description": "Atlas status after the change", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AtlasStatus" + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "path" + ], + "properties": { + "path": { + "type": "string", + "description": "Absolute path to the ServUO server root. Blank disables the atlas." + } + } + } + } + } + } + } + }, + "/api/v1/admin/shard/atlas/reject": { + "post": { + "tags": [ + "Admin · Shard" + ], + "summary": "Reject a staged atlas refresh (admin only)", + "description": "Keeps the current atlas and remembers the decision against those exact source hashes, so a declined refresh does not re-prompt on every restart. Changing the tree asks again.", + "responses": { + "200": { + "description": "Rejected", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AtlasRefreshResult" + } + } + } + }, + "404": { + "description": "Nothing is awaiting review", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + } + }, + "security": [ + { + "cookieAuth": [] + }, + { + "bearerAuth": [] + } + ] + } + }, "/api/v1/admin/shard/audit": { "get": { "tags": [ @@ -10622,6 +10847,367 @@ ] } }, + "/api/v1/public/atlas/champions": { + "get": { + "tags": [ + "Public · Atlas" + ], + "summary": "Configured champion altars (the roster, not the live board)", + "description": "Where the altars are and what each one summons — \"there is an Unholy Terror altar in Deceit\". `randomType` marks altars whose champion is drawn at activation. Do not conflate this with GET /public/shard/champs, which is the live sidecar-fed board (\"it is on level 3 right now\").", + "parameters": [ + { + "name": "facet", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Limit to one facet." + } + ], + "responses": { + "200": { + "description": "Altars, by facet then name", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AtlasChampion" + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not Found" + }, + "500": { + "description": "Internal Server Error" + }, + "503": { + "description": "Service Unavailable" + } + } + } + }, + "/api/v1/public/atlas/creatures": { + "get": { + "tags": [ + "Public · Atlas" + ], + "summary": "Search the bestiary (paginated)", + "description": "Every creature the shard spawns, most numerous first. `total` is how many can be alive at once across all spawners; `points` is how many spawners mention it; `facets` maps facet name to that creature\\'s share on it. Static content parsed from the shard\\'s ServUO tree — unaffected by the shard being offline.", + "parameters": [ + { + "name": "q", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Substring match on the creature name (max 60 chars)." + }, + { + "name": "facet", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Limit to creatures spawning on this facet. Facet names come from the shard's own files; an unknown one returns an empty page." + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Page size, 1..100 (default 50)." + }, + { + "name": "offset", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Rows to skip (default 0)." + } + ], + "responses": { + "200": { + "description": "A page of creatures plus the unpaginated total", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AtlasCreaturePage" + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "403": { + "description": "The atlas feature is gated above this caller", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "The atlas feature is disabled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + }, + "503": { + "description": "Service Unavailable" + } + } + } + }, + "/api/v1/public/atlas/creatures/{slug}": { + "get": { + "tags": [ + "Public · Atlas" + ], + "summary": "One creature: where it spawns, and what spawns with it", + "description": "The answer the atlas exists to give. `places` is the aggregate — \"lizardman → Shrines, Isamu-Jima, Yew\" — resolved by point-in-rect against the shard\\'s own region rectangles, falling back to the nearest landmark, else \"Wilderness\". `spawners` lists the individual spawn points (bounded; `spawnersTruncated` says when the list was cut), and `alsoHere` is what shares those spawners.", + "parameters": [ + { + "name": "slug", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "Creature slug, e.g. lizardman." + }, + { + "name": "facet", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Restrict places and spawners to one facet." + }, + { + "name": "points", + "in": "query", + "required": false, + "schema": { + "type": "integer" + }, + "description": "Max spawners to return, 1..1000 (default 200)." + } + ], + "responses": { + "200": { + "description": "The creature", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AtlasCreature" + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "No such creature in this atlas (or the feature is disabled)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "500": { + "description": "Internal Server Error" + }, + "503": { + "description": "Service Unavailable" + } + } + } + }, + "/api/v1/public/atlas/landmarks": { + "get": { + "tags": [ + "Public · Atlas" + ], + "summary": "Points of interest (dungeon levels, town markers)", + "description": "From the shard\\'s Data/Locations files. `group` is the innermost enclosing parent (\"Covetous\"), which is the label worth showing over the individual marker (\"Level 1\").", + "parameters": [ + { + "name": "facet", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Limit to one facet." + }, + { + "name": "q", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Substring match on the landmark name or its group." + } + ], + "responses": { + "200": { + "description": "Landmarks, by facet then group", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AtlasLandmark" + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not Found" + }, + "500": { + "description": "Internal Server Error" + }, + "503": { + "description": "Service Unavailable" + } + } + } + }, + "/api/v1/public/atlas/meta": { + "get": { + "tags": [ + "Public · Atlas" + ], + "summary": "What atlas is loaded: facets, counts, when it was imported", + "description": "Drives the facet filter and the \"parsed from the shard\\'s own files on \" line. Reports the game world only — the ServUO path, the per-file hashes and any pending refresh are operator detail and live on the admin status route.", + "responses": { + "200": { + "description": "Atlas metadata", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AtlasMeta" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not Found" + }, + "500": { + "description": "Internal Server Error" + }, + "503": { + "description": "Service Unavailable" + } + } + } + }, + "/api/v1/public/atlas/regions": { + "get": { + "tags": [ + "Public · Atlas" + ], + "summary": "Named regions and their rectangles", + "description": "Flattened out of the shard\\'s nested Regions.xml. `priority` and the rectangles are what placed each spawn point, kept so the placement can be re-derived rather than taken on trust.", + "parameters": [ + { + "name": "facet", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Limit to one facet." + }, + { + "name": "q", + "in": "query", + "required": false, + "schema": { + "type": "string" + }, + "description": "Substring match on the region name." + } + ], + "responses": { + "200": { + "description": "Regions, by facet then name", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AtlasRegion" + } + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not Found" + }, + "500": { + "description": "Internal Server Error" + }, + "503": { + "description": "Service Unavailable" + } + } + } + }, "/api/v1/public/contact": { "post": { "tags": [ @@ -17979,6 +18565,1435 @@ } } }, + "AtlasCreature": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "A creature in the bestiary. `places`/`points`/`alsoHere` are present only on the single-creature route." + }, + "properties": { + "type": "object", + "properties": { + "slug": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "lizardman" + } + } + }, + "name": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "Lizardman" + } + } + }, + "total": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "description": { + "type": "string", + "example": "How many can be alive at once, summed across every spawner." + }, + "example": { + "type": "number", + "example": 214 + } + } + }, + "points": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "description": { + "type": "string", + "example": "How many spawners mention this creature." + }, + "example": { + "type": "number", + "example": 62 + } + } + }, + "facets": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "additionalProperties": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + } + } + }, + "description": { + "type": "string", + "example": "This creature's share per facet." + }, + "example": { + "type": "object", + "properties": { + "Felucca": { + "type": "number", + "example": 96 + }, + "Trammel": { + "type": "number", + "example": 88 + }, + "Tokuno": { + "type": "number", + "example": 30 + } + } + } + } + }, + "art": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "Operator-supplied art under uploads/atlas/. NULL on a fresh import — the repo ships no creature art." + } + } + }, + "places": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "description": { + "type": "string", + "example": "Where it spawns, aggregated by resolved place. The answer the atlas exists to give." + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "facet": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "Trammel" + } + } + }, + "label": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "description": { + "type": "string", + "example": "Resolved region, else nearest landmark group, else \"Wilderness\"." + }, + "example": { + "type": "string", + "example": "Shrines" + } + } + }, + "spawners": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 7 + } + } + }, + "maxAlive": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 21 + } + } + } + } + } + } + } + } + }, + "spawners": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "description": { + "type": "string", + "example": "The individual spawners. Named separately from `points` (the count) so one key never means two things." + }, + "items": { + "$ref": "#/components/schemas/AtlasSpawner" + } + } + }, + "spawnersTruncated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "description": { + "type": "string", + "example": "True when the spawner list was cut at the requested bound." + }, + "example": { + "type": "boolean", + "example": false + } + } + }, + "alsoHere": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "description": { + "type": "string", + "example": "Creatures sharing a spawner with this one." + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "slug": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "lizardman-warrior" + } + } + }, + "name": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "Lizardman Warrior" + } + } + }, + "shared": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 12 + } + } + } + } + } + } + } + } + } + } + } + } + }, + "AtlasSpawner": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "One ServUO spawner, with the place its coordinates resolved to." + }, + "properties": { + "type": "object", + "properties": { + "id": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + } + } + }, + "facet": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "Felucca" + } + } + }, + "name": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "The spawner's own name in the ServUO file." + } + } + }, + "x": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 5411 + } + } + }, + "y": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 1234 + } + } + }, + "width": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + } + } + }, + "height": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + } + } + }, + "range": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "description": { + "type": "string", + "example": "Spawn radius." + } + } + }, + "maxCount": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "description": { + "type": "string", + "example": "How many of THIS creature this spawner keeps alive." + }, + "example": { + "type": "number", + "example": 3 + } + } + }, + "minDelay": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "description": { + "type": "string", + "example": "Respawn window, in SECONDS. Normalised at parse time — the source stores minutes or seconds per record, decided by its own DelayInSec flag." + }, + "example": { + "type": "number", + "example": 300 + } + } + }, + "maxDelay": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 600 + } + } + }, + "todStart": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "description": { + "type": "string", + "example": "Meaningless unless todMode is non-zero." + } + } + }, + "todEnd": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + } + } + }, + "todMode": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + } + } + }, + "region": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "Despise" + } + } + }, + "landmark": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "Covetous" + } + } + }, + "label": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "description": { + "type": "string", + "example": "Region, else landmark group, else \"Wilderness\"." + }, + "example": { + "type": "string", + "example": "Despise" + } + } + } + } + } + } + }, + "AtlasCreaturePage": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "total": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "description": { + "type": "string", + "example": "Matching creatures before pagination." + }, + "example": { + "type": "number", + "example": 800 + } + } + }, + "limit": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 50 + } + } + }, + "offset": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 0 + } + } + }, + "creatures": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "$ref": "#/components/schemas/AtlasCreature" + } + } + } + } + } + } + }, + "AtlasRegion": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "A named region, flattened out of the shard's nested Regions.xml." + }, + "properties": { + "type": "object", + "properties": { + "facet": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "Felucca" + } + } + }, + "name": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "Despise" + } + } + }, + "type": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "ServUO region class." + }, + "example": { + "type": "string", + "example": "DungeonRegion" + } + } + }, + "priority": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 50 + } + } + }, + "parent": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "Britain" + } + } + }, + "rects": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "description": { + "type": "string", + "example": "The rectangles that placed each spawn point." + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "additionalProperties": { + "type": "boolean", + "example": true + } + } + } + } + } + } + } + } + }, + "AtlasLandmark": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "properties": { + "type": "object", + "properties": { + "facet": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "Trammel" + } + } + }, + "name": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "Level 1" + } + } + }, + "group": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "Innermost enclosing parent — the label worth showing." + }, + "example": { + "type": "string", + "example": "Covetous" + } + } + }, + "x": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 5411 + } + } + }, + "y": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 1234 + } + } + }, + "z": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 0 + } + } + } + } + } + } + }, + "AtlasChampion": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "A CONFIGURED champion altar. Not the live board — see GET /public/shard/champs for that." + }, + "properties": { + "type": "object", + "properties": { + "slug": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "felucca-deceit" + } + } + }, + "name": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "Deceit" + } + } + }, + "group": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "Spawn group; one altar active per group." + }, + "example": { + "type": "string", + "example": "Dungeons" + } + } + }, + "type": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "NULL when the champion is drawn at activation." + }, + "example": { + "type": "string", + "example": "UnholyTerror" + } + } + }, + "randomType": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": false + } + } + }, + "facet": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "Felucca" + } + } + }, + "x": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + } + } + }, + "y": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + } + } + }, + "z": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + } + } + }, + "radius": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "integer" + }, + "example": { + "type": "number", + "example": 60 + } + } + }, + "label": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "example": { + "type": "string", + "example": "Deceit" + } + } + } + } + } + } + }, + "AtlasMeta": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "What atlas is loaded. Game-world facts only: the ServUO path, source hashes and any pending refresh are operator detail and live on the admin status route." + }, + "properties": { + "type": "object", + "properties": { + "importedAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "generatedAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "counts": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "additionalProperties": { + "type": "boolean", + "example": true + }, + "example": { + "type": "object", + "properties": { + "facets": { + "type": "number", + "example": 6 + }, + "points": { + "type": "number", + "example": 6455 + }, + "creatures": { + "type": "number", + "example": 800 + }, + "regions": { + "type": "number", + "example": 387 + }, + "landmarks": { + "type": "number", + "example": 558 + }, + "champions": { + "type": "number", + "example": 25 + }, + "unresolvedPoints": { + "type": "number", + "example": 1086 + } + } + } + } + }, + "facets": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + }, + "example": { + "type": "array", + "example": [ + "Felucca", + "Ilshenar", + "Malas", + "TerMur", + "Tokuno", + "Trammel" + ], + "items": { + "type": "string" + } + } + } + } + } + } + } + }, + "AtlasStatus": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "Admin view of atlas state: where the tree is, whether it is readable, whether it has drifted from what is loaded, and any refresh staged for review." + }, + "properties": { + "type": "object", + "properties": { + "configured": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "path": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "example": { + "type": "string", + "example": "/srv/servuo" + } + } + }, + "treeReadable": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "example": { + "type": "boolean", + "example": true + } + } + }, + "drift": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "boolean" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "True when the tree's source hashes differ from the loaded atlas. NULL when the tree could not be read." + }, + "example": { + "type": "boolean", + "example": false + } + } + }, + "facets": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + } + } + }, + "importedAt": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "format": { + "type": "string", + "example": "date-time" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "counts": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "additionalProperties": { + "type": "boolean", + "example": true + } + } + }, + "pending": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "description": { + "type": "string", + "example": "A refresh that was parsed but NOT applied because it would remove a facet. `status` is pending or rejected." + }, + "additionalProperties": { + "type": "boolean", + "example": true + } + } + } + } + } + } + }, + "AtlasRefreshResult": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "description": { + "type": "string", + "example": "Outcome of a refresh. Reported rather than thrown, so an unreadable tree is an answer and not a 500." + }, + "properties": { + "type": "object", + "properties": { + "status": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "enum": { + "type": "array", + "example": [ + "skipped", + "unavailable", + "unchanged", + "imported", + "needsReview", + "failed", + "rejected", + "none" + ], + "items": { + "type": "string" + } + }, + "example": { + "type": "string", + "example": "imported" + } + } + }, + "reason": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "path": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + }, + "nullable": { + "type": "boolean", + "example": true + } + } + }, + "counts": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "object" + }, + "nullable": { + "type": "boolean", + "example": true + }, + "additionalProperties": { + "type": "boolean", + "example": true + } + } + }, + "addedFacets": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + } + } + }, + "removedFacets": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "array" + }, + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "example": "string" + } + } + } + } + } + } + } + } + }, "ShardLinkRequest": { "type": "object", "properties": { diff --git a/server/swagger/swagger.js b/server/swagger/swagger.js index 2e50ce3..36d4fad 100644 --- a/server/swagger/swagger.js +++ b/server/swagger/swagger.js @@ -54,6 +54,7 @@ const doc = { { name: 'Auth · SSO', description: 'OAuth2 / OIDC provider discovery and redirect flow' }, { name: 'Public', description: 'Unauthenticated site content (settings, posts, wiki, contact)' }, { name: 'Public · Shard', description: 'Live shard data ingested from the uo-link sidecar (status, feed, economy, IDOC, characters)' }, + { name: 'Public · Atlas', description: 'Spawn atlas / bestiary — static shard content parsed from the shard\'s own ServUO tree, independent of the sidecar' }, { name: 'Admin · Account', description: 'Self-service account security (2FA, linked identities)' }, { name: 'Player', description: 'Self-service player accounts (register, credentials, 2FA, linked identities)' }, { name: 'Player · Shard', description: 'Link an in-game account and read its roster / vendors (uo-link)' }, @@ -953,6 +954,185 @@ const doc = { }, }, }, + // ── Spawn atlas (Protocol 3.0 Part C) ──────────────────────────────── + // Static shard content, parsed from the shard's own ServUO tree. Nothing + // here comes from the sidecar, so it stays populated while the shard is + // down. Facet names are whatever the shard's files declare — the examples + // below are stock ServUO, not a fixed list. + AtlasCreature: { + type: 'object', + description: 'A creature in the bestiary. `places`/`points`/`alsoHere` are present only on the single-creature route.', + properties: { + slug: { type: 'string', example: 'lizardman' }, + name: { type: 'string', example: 'Lizardman' }, + total: { type: 'integer', description: 'How many can be alive at once, summed across every spawner.', example: 214 }, + points: { type: 'integer', description: 'How many spawners mention this creature.', example: 62 }, + facets: { + type: 'object', + additionalProperties: { type: 'integer' }, + description: "This creature's share per facet.", + example: { Felucca: 96, Trammel: 88, Tokuno: 30 }, + }, + art: { type: 'string', nullable: true, description: 'Operator-supplied art under uploads/atlas/. NULL on a fresh import — the repo ships no creature art.' }, + places: { + type: 'array', + description: 'Where it spawns, aggregated by resolved place. The answer the atlas exists to give.', + items: { + type: 'object', + properties: { + facet: { type: 'string', example: 'Trammel' }, + label: { type: 'string', description: 'Resolved region, else nearest landmark group, else "Wilderness".', example: 'Shrines' }, + spawners: { type: 'integer', example: 7 }, + maxAlive: { type: 'integer', example: 21 }, + }, + }, + }, + spawners: { + type: 'array', + description: 'The individual spawners. Named separately from `points` (the count) so one key never means two things.', + items: { $ref: '#/components/schemas/AtlasSpawner' }, + }, + spawnersTruncated: { type: 'boolean', description: 'True when the spawner list was cut at the requested bound.', example: false }, + alsoHere: { + type: 'array', + description: 'Creatures sharing a spawner with this one.', + items: { + type: 'object', + properties: { + slug: { type: 'string', example: 'lizardman-warrior' }, + name: { type: 'string', example: 'Lizardman Warrior' }, + shared: { type: 'integer', example: 12 }, + }, + }, + }, + }, + }, + AtlasSpawner: { + type: 'object', + description: 'One ServUO spawner, with the place its coordinates resolved to.', + properties: { + id: { type: 'integer' }, + facet: { type: 'string', example: 'Felucca' }, + name: { type: 'string', nullable: true, description: "The spawner's own name in the ServUO file." }, + x: { type: 'integer', example: 5411 }, + y: { type: 'integer', example: 1234 }, + width: { type: 'integer' }, + height: { type: 'integer' }, + range: { type: 'integer', description: 'Spawn radius.' }, + maxCount: { type: 'integer', description: 'How many of THIS creature this spawner keeps alive.', example: 3 }, + minDelay: { type: 'integer', description: 'Respawn window, in SECONDS. Normalised at parse time — the source stores minutes or seconds per record, decided by its own DelayInSec flag.', example: 300 }, + maxDelay: { type: 'integer', example: 600 }, + todStart: { type: 'integer', description: 'Meaningless unless todMode is non-zero.' }, + todEnd: { type: 'integer' }, + todMode: { type: 'integer' }, + region: { type: 'string', nullable: true, example: 'Despise' }, + landmark: { type: 'string', nullable: true, example: 'Covetous' }, + label: { type: 'string', description: 'Region, else landmark group, else "Wilderness".', example: 'Despise' }, + }, + }, + AtlasCreaturePage: { + type: 'object', + properties: { + total: { type: 'integer', description: 'Matching creatures before pagination.', example: 800 }, + limit: { type: 'integer', example: 50 }, + offset: { type: 'integer', example: 0 }, + creatures: { type: 'array', items: { $ref: '#/components/schemas/AtlasCreature' } }, + }, + }, + AtlasRegion: { + type: 'object', + description: 'A named region, flattened out of the shard\'s nested Regions.xml.', + properties: { + facet: { type: 'string', example: 'Felucca' }, + name: { type: 'string', example: 'Despise' }, + type: { type: 'string', nullable: true, description: 'ServUO region class.', example: 'DungeonRegion' }, + priority: { type: 'integer', example: 50 }, + parent: { type: 'string', nullable: true, example: 'Britain' }, + rects: { + type: 'array', + description: 'The rectangles that placed each spawn point.', + items: { type: 'object', additionalProperties: true }, + }, + }, + }, + AtlasLandmark: { + type: 'object', + properties: { + facet: { type: 'string', example: 'Trammel' }, + name: { type: 'string', example: 'Level 1' }, + group: { type: 'string', nullable: true, description: 'Innermost enclosing parent — the label worth showing.', example: 'Covetous' }, + x: { type: 'integer', example: 5411 }, + y: { type: 'integer', example: 1234 }, + z: { type: 'integer', example: 0 }, + }, + }, + AtlasChampion: { + type: 'object', + description: 'A CONFIGURED champion altar. Not the live board — see GET /public/shard/champs for that.', + properties: { + slug: { type: 'string', example: 'felucca-deceit' }, + name: { type: 'string', example: 'Deceit' }, + group: { type: 'string', nullable: true, description: 'Spawn group; one altar active per group.', example: 'Dungeons' }, + type: { type: 'string', nullable: true, description: 'NULL when the champion is drawn at activation.', example: 'UnholyTerror' }, + randomType: { type: 'boolean', example: false }, + facet: { type: 'string', example: 'Felucca' }, + x: { type: 'integer' }, + y: { type: 'integer' }, + z: { type: 'integer' }, + radius: { type: 'integer', example: 60 }, + label: { type: 'string', nullable: true, example: 'Deceit' }, + }, + }, + AtlasMeta: { + type: 'object', + description: 'What atlas is loaded. Game-world facts only: the ServUO path, source hashes and any pending refresh are operator detail and live on the admin status route.', + properties: { + importedAt: { type: 'string', format: 'date-time', nullable: true }, + generatedAt: { type: 'string', format: 'date-time', nullable: true }, + counts: { + type: 'object', + nullable: true, + additionalProperties: true, + example: { facets: 6, points: 6455, creatures: 800, regions: 387, landmarks: 558, champions: 25, unresolvedPoints: 1086 }, + }, + facets: { type: 'array', items: { type: 'string' }, example: ['Felucca', 'Ilshenar', 'Malas', 'TerMur', 'Tokuno', 'Trammel'] }, + }, + }, + AtlasStatus: { + type: 'object', + description: 'Admin view of atlas state: where the tree is, whether it is readable, whether it has drifted from what is loaded, and any refresh staged for review.', + properties: { + configured: { type: 'boolean', example: true }, + path: { type: 'string', example: '/srv/servuo' }, + treeReadable: { type: 'boolean', example: true }, + drift: { type: 'boolean', nullable: true, description: 'True when the tree\'s source hashes differ from the loaded atlas. NULL when the tree could not be read.', example: false }, + facets: { type: 'array', items: { type: 'string' } }, + importedAt: { type: 'string', format: 'date-time', nullable: true }, + counts: { type: 'object', nullable: true, additionalProperties: true }, + pending: { + type: 'object', + nullable: true, + description: 'A refresh that was parsed but NOT applied because it would remove a facet. `status` is pending or rejected.', + additionalProperties: true, + }, + }, + }, + AtlasRefreshResult: { + type: 'object', + description: 'Outcome of a refresh. Reported rather than thrown, so an unreadable tree is an answer and not a 500.', + properties: { + status: { + type: 'string', + enum: ['skipped', 'unavailable', 'unchanged', 'imported', 'needsReview', 'failed', 'rejected', 'none'], + example: 'imported', + }, + reason: { type: 'string', nullable: true }, + path: { type: 'string', nullable: true }, + counts: { type: 'object', nullable: true, additionalProperties: true }, + addedFacets: { type: 'array', items: { type: 'string' } }, + removedFacets: { type: 'array', items: { type: 'string' } }, + }, + }, ShardLinkRequest: { type: 'object', required: ['code'], diff --git a/server/test/atlasController.test.js b/server/test/atlasController.test.js new file mode 100644 index 0000000..653dfbb --- /dev/null +++ b/server/test/atlasController.test.js @@ -0,0 +1,238 @@ +// Point the DB at a closed port BEFORE requiring the controllers (their models +// build the pool). Every model call is monkeypatched, so no query runs; +// db.close() at the end releases the pool so the process exits cleanly. +process.env.DB_HOST = '127.0.0.1' +process.env.DB_PORT = '59999' + +const { test, after, afterEach } = require('node:test') +const assert = require('node:assert/strict') + +// The spawn atlas API, public and admin. What is worth asserting here is not the +// SQL (that is the parser suite's job) but the contracts the two surfaces make: +// +// • the public reads project through the visibility framework — v3.md §3.6.1's +// standing rule is that a read path returning shard data and not calling +// projectFeature is a bug, and `atlas` declaring no sensitive fields TODAY is +// exactly why the call has to be there before one does; +// • the public /meta route reports the game world only, never the operator's +// filesystem — the ServUO path, the per-file hashes and any pending refresh +// stay on the admin route; +// • a missing creature is a 404, not an empty 200; +// • an unreadable ServUO tree is a 200 carrying `status: 'unavailable'`, NOT a +// 500. The refresh contract reports outcomes rather than throwing (so boot is +// never blocked by a bad tree), and the admin needs to be told what is wrong +// with their path; +// • a model failure degrades to a 500 rather than a thrown/uncaught error. +const pub = require('../src/router/v1/public/atlas.controller') +const admin = require('../src/router/v1/admin/shardAtlas.controller') +const atlas = require('../src/model/shardAtlas/shardAtlas.model') +const activity = require('../src/model/activity/activity.model') +const visibility = require('../src/utils/shardVisibility') +const db = require('../src/utils/db') + +after(() => db.close()) + +// Stub the visibility MODEL rather than the util's exports: project() calls the +// module-internal getConfig, which an exports-level stub would not intercept — it +// would hit the closed DB port and cost a ~10s pool timeout per test before +// falling back to these same defaults. +const visibilityModel = require('../src/model/shardVisibility/shardVisibility.model') +visibilityModel.listAll = async () => [] // no overrides ⇒ compiled defaults +visibility.viewerLevel = async (req) => req?.viewerLevel || 'anonymous' + +// The admin controller logs every action; keep it off the DB. +activity.log = async () => {} + +function mockRes() { + return { + statusCode: 200, + body: null, + status(c) { + this.statusCode = c + return this + }, + json(b) { + this.body = b + return this + }, + } +} + +const originals = { + searchCreatures: atlas.searchCreatures, + getCreature: atlas.getCreature, + listRegions: atlas.listRegions, + listLandmarks: atlas.listLandmarks, + listChampions: atlas.listChampions, + publicMeta: atlas.publicMeta, + status: atlas.status, + refresh: atlas.refresh, + approvePending: atlas.approvePending, + rejectPending: atlas.rejectPending, + setServuoPath: atlas.setServuoPath, +} +afterEach(() => Object.assign(atlas, originals)) + +// ── Public reads ──────────────────────────────────────────────────────── +test('getCreatures passes the search through and returns the page shape', async () => { + let seen = null + atlas.searchCreatures = async (opts) => { + seen = opts + return { total: 1, limit: 50, offset: 0, creatures: [{ slug: 'lizardman', name: 'Lizardman' }] } + } + const res = mockRes() + await pub.getCreatures({ query: { q: ' lizard ', facet: 'Felucca', limit: '10', offset: '20' } }, res) + assert.deepEqual(seen, { q: 'lizard', facet: 'Felucca', limit: 10, offset: 20 }) + assert.equal(res.body.total, 1) + assert.equal(res.body.creatures[0].slug, 'lizardman') +}) + +test('getCreatures falls back to the documented defaults when nothing is passed', async () => { + let seen = null + atlas.searchCreatures = async (opts) => { + seen = opts + return { total: 0, limit: 50, offset: 0, creatures: [] } + } + await pub.getCreatures({ query: {} }, mockRes()) + assert.deepEqual(seen, { q: '', facet: '', limit: 50, offset: 0 }) +}) + +test('an unknown creature is a 404, not an empty 200', async () => { + atlas.getCreature = async () => null + const res = mockRes() + await pub.getCreature({ params: { slug: 'nosuchthing' }, query: {} }, res) + assert.equal(res.statusCode, 404) +}) + +test('getCreature returns places and spawners, and `points` stays the COUNT', async () => { + atlas.getCreature = async () => ({ + slug: 'lizardman', + name: 'Lizardman', + total: 214, + points: 62, + places: [{ facet: 'Trammel', label: 'Shrines', spawners: 7, maxAlive: 21 }], + spawners: [{ id: 1, facet: 'Trammel', label: 'Shrines', x: 1, y: 2 }], + spawnersTruncated: false, + alsoHere: [], + }) + const res = mockRes() + await pub.getCreature({ params: { slug: 'lizardman' }, query: {} }, res) + // The list route uses `points` as a number; the detail route must not quietly + // turn the same key into an array. + assert.equal(typeof res.body.points, 'number') + assert.ok(Array.isArray(res.body.spawners)) + assert.equal(res.body.places[0].label, 'Shrines') +}) + +// ── The projection rule (§3.6.1) ──────────────────────────────────────── +test('public reads run through projectFeature, so a locked field can never survive', async () => { + // `atlas` declares no sensitive fields, so nothing here is stripped by a + // FEATURE rule. acct/webId are stripped anyway — they are locked by meaning, + // for every feature, and this is what proves the read path projects at all. + atlas.searchCreatures = async () => ({ + total: 1, + limit: 50, + offset: 0, + creatures: [{ slug: 'lizardman', name: 'Lizardman', acct: 'someacct', ownerWebId: 7 }], + }) + const res = mockRes() + await pub.getCreatures({ query: {}, viewerLevel: 'anonymous' }, res) + const row = res.body.creatures[0] + assert.equal(row.name, 'Lizardman') + assert.ok(!('acct' in row), 'acct must never reach an anonymous caller') + assert.ok(!('ownerWebId' in row), 'a flattened webId spelling is locked too') +}) + +test('getMeta reports the game world only — never the operator’s filesystem', async () => { + // The model is what enforces this; the assertion documents the boundary so a + // future "just return status() here" shortcut fails loudly. + atlas.publicMeta = async () => ({ + importedAt: '2026-07-28T00:00:00.000Z', + generatedAt: '2026-07-28T00:00:00.000Z', + counts: { points: 6455, creatures: 800 }, + facets: ['Felucca', 'Trammel'], + }) + const res = mockRes() + await pub.getMeta({ query: {} }, res) + assert.deepEqual(Object.keys(res.body).sort(), ['counts', 'facets', 'generatedAt', 'importedAt']) + assert.ok(!('path' in res.body)) + assert.ok(!('pending' in res.body)) +}) + +test('a model failure degrades to a 500 rather than throwing', async () => { + atlas.listChampions = async () => { + throw new Error('table is gone') + } + const res = mockRes() + await pub.getChampions({ query: {} }, res) + assert.equal(res.statusCode, 500) +}) + +// ── Admin ─────────────────────────────────────────────────────────────── +test('an unreadable tree answers 200 with the reason, not a 500', async () => { + atlas.refresh = async () => ({ status: 'unavailable', reason: 'no Spawns directory', path: '/bad' }) + const res = mockRes() + await admin.importAtlas({ body: {}, user: { id: 1 } }, res) + assert.equal(res.statusCode, 200) + assert.equal(res.body.status, 'unavailable') + assert.equal(res.body.reason, 'no Spawns directory') +}) + +test('import passes `force` through and coerces it to a boolean', async () => { + let seen = null + atlas.refresh = async (opts) => { + seen = opts + return { status: 'unchanged' } + } + await admin.importAtlas({ body: { force: true }, user: { id: 1 } }, mockRes()) + assert.deepEqual(seen, { force: true }) +}) + +test('approve applies a staged refresh (facet loss included)', async () => { + let called = false + atlas.approvePending = async () => { + called = true + return { status: 'imported', removedFacets: ['Malas'], counts: { points: 6162 } } + } + const res = mockRes() + await admin.approve({ user: { id: 1 } }, res) + assert.ok(called) + assert.equal(res.body.status, 'imported') +}) + +test('rejecting when nothing is staged is a 404', async () => { + atlas.rejectPending = async () => ({ status: 'none' }) + const res = mockRes() + await admin.reject({ user: { id: 1 } }, res) + assert.equal(res.statusCode, 404) +}) + +test('setPath trims, persists, and answers with fresh status — it does not import', async () => { + let saved = null + let imported = false + atlas.setServuoPath = async (value) => { + saved = value + } + atlas.refresh = async () => { + imported = true + return { status: 'imported' } + } + atlas.status = async () => ({ configured: true, path: '/srv/servuo', treeReadable: true }) + const res = mockRes() + await admin.setPath({ body: { path: ' /srv/servuo ' }, user: { id: 3 } }, res) + assert.equal(saved, '/srv/servuo') + assert.equal(imported, false, 'changing the path must not reload the atlas as a side effect') + assert.equal(res.body.path, '/srv/servuo') +}) + +test('setPath accepts a blank path (clearing it turns the atlas off)', async () => { + let saved = 'unset' + atlas.setServuoPath = async (value) => { + saved = value + } + atlas.status = async () => ({ configured: false, path: '' }) + const res = mockRes() + await admin.setPath({ body: {}, user: { id: 3 } }, res) + assert.equal(saved, '') + assert.equal(res.statusCode, 200) +}) diff --git a/server/test/spawnAtlas.parse.test.js b/server/test/spawnAtlas.parse.test.js index 4a01434..bafa252 100644 --- a/server/test/spawnAtlas.parse.test.js +++ b/server/test/spawnAtlas.parse.test.js @@ -139,8 +139,10 @@ test('parsePoints: reads the kept fields and drops the rest', () => { assert.equal(covetous.width, 10) assert.equal(covetous.range, 5) assert.equal(covetous.maxCount, 3) - assert.equal(covetous.minDelay, 5) - assert.equal(covetous.maxDelay, 10) + // Delays are normalised to seconds; this record carries no DelayInSec, which + // means minutes. + assert.equal(covetous.minDelay, 300) + assert.equal(covetous.maxDelay, 600) assert.deepEqual(covetous.types, [{ type: 'Lizardman', max: 3 }]) // Dropped fields must not survive into the artifact — this is what keeps it // under 1 MB. @@ -561,3 +563,39 @@ test('slugify: produces URL-safe keys', () => { assert.equal(slugify("Mondain's Legacy"), 'mondain-s-legacy') assert.equal(slugify(' Orc '), 'orc') }) + +// ── Respawn delays: the unit is per record ────────────────────────────────── +// XmlSpawner writes minutes by default and switches to seconds only when a +// delay does not divide into whole minutes, flagged by DelayInSec. `5` therefore +// means five MINUTES on one spawner and five SECONDS on the next, and a reader +// assuming either unit is wrong about the other — silently, since both are +// plausible respawn times. +const DELAY_XML = ` + + Minutes + Sosaria + 11 + 5 + 10 + True + Orc:MX=1 + + + Seconds + Sosaria + 22 + True + 5 + 10 + True + Orc:MX=1 + +` + +test('parsePoints: DelayInSec decides the unit, and both come out in seconds', () => { + const [minutes, seconds] = parsePoints(DELAY_XML) + assert.equal(minutes.minDelay, 300) + assert.equal(minutes.maxDelay, 600) + assert.equal(seconds.minDelay, 5) + assert.equal(seconds.maxDelay, 10) +}) diff --git a/server/test/spawnAtlas.source.test.js b/server/test/spawnAtlas.source.test.js index 79a83a2..76df7ed 100644 --- a/server/test/spawnAtlas.source.test.js +++ b/server/test/spawnAtlas.source.test.js @@ -15,6 +15,7 @@ const { sameSources, hashSources, buildAtlas, + PARSER_VERSION, } = require('../src/utils/spawnAtlasSource') const shardAtlas = require('../src/model/shardAtlas/shardAtlas.model') const atlasDb = require('../src/model/shardAtlas/shardAtlas.db') @@ -273,15 +274,35 @@ test('refresh: a fresh database imports', async () => { test('refresh: an unchanged tree parses nothing and writes nothing', async () => { const root = tempTree({ facets: ['Sosaria'] }) - metaRow = { source: buildAtlas(root).meta.source } + metaRow = buildAtlas(root).meta const result = await shardAtlas.refresh({ path: root }) assert.equal(result.status, 'unchanged') assert.equal(applied, null) }) +// The hash gate alone would strand an install whose maps never change on +// whatever an older build derived: a corrected parse would ship and never reach +// the data, because the only thing compared is the tree. +test('refresh: an unchanged tree is REIMPORTED when the parser has moved on', async () => { + const root = tempTree({ facets: ['Sosaria'] }) + metaRow = { ...buildAtlas(root).meta, parserVersion: PARSER_VERSION - 1 } + const result = await shardAtlas.refresh({ path: root }) + assert.equal(result.status, 'imported') + assert.ok(applied) +}) + +test('refresh: an atlas imported before parser versions existed is stale', async () => { + const root = tempTree({ facets: ['Sosaria'] }) + const meta = buildAtlas(root).meta + delete meta.parserVersion + metaRow = meta + const result = await shardAtlas.refresh({ path: root }) + assert.equal(result.status, 'imported') +}) + test('refresh: --force reimports an unchanged tree', async () => { const root = tempTree({ facets: ['Sosaria'] }) - metaRow = { source: buildAtlas(root).meta.source } + metaRow = buildAtlas(root).meta const result = await shardAtlas.refresh({ path: root, force: true }) assert.equal(result.status, 'imported') assert.ok(applied)