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)