Merge pull request 'feat(atlas): serve the spawn atlas and give operators a panel for it' (#113) from feat/spawn-atlas-api into edge

Reviewed-on: #113
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
This commit is contained in:
2026-07-29 00:53:21 +00:00
24 changed files with 4416 additions and 8 deletions

View File

@@ -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() {
<Route path="/site/governors" element={<Governors />} />
<Route path="/site/houses" element={<Houses />} />
<Route path="/site/rules" element={<Rules />} />
<Route path="/site/atlas" element={<Atlas />} />
<Route path="/site/atlas/:slug" element={<AtlasCreature />} />
<Route path="/wiki" element={<Wiki />} />
<Route path="/wiki/:slug" element={<WikiArticle />} />
{/* CMS pages: top-level /:slug, matched only after the named routes
@@ -146,6 +151,7 @@ export default function App() {
<Route path="discord-bot" element={<DiscordBotAdmin />} />
<Route path="shard" element={<ShardAdmin />} />
<Route path="shard-visibility" element={<ShardVisibility />} />
<Route path="shard-atlas" element={<SpawnAtlasAdmin />} />
<Route
path="shard-ops"
element={

View File

@@ -154,6 +154,43 @@ export const api = {
// resolved to. Drives nav so we never render a link that would 403.
features: () => 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: {

View File

@@ -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' },
]

View File

@@ -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',

View File

@@ -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 (
<div
className="sans"
style={{
display: 'flex',
alignItems: 'baseline',
justifyContent: 'space-between',
gap: 16,
padding: '7px 0',
borderBottom: '1px solid var(--line)',
fontSize: '0.86rem',
}}
>
<span className="dim">{label}</span>
<span style={{ color: 'var(--head)', textAlign: 'right', wordBreak: 'break-all' }}>{children}</span>
</div>
)
}
function PendingReview({ pending, busy, onApprove, onReject }) {
const declined = pending.status === 'rejected'
return (
<section
style={{
border: `1px solid ${declined ? 'var(--line)' : '#c58f4a'}`,
borderRadius: 10,
padding: 16,
background: declined ? 'transparent' : 'rgba(197,143,74,0.08)',
}}
>
<h3 className="display" style={{ margin: 0, fontSize: '1rem', color: 'var(--head)' }}>
{declined ? 'A refresh was declined' : 'A refresh is waiting for you'}
</h3>
<p className="sans" style={{ margin: '6px 0 12px', fontSize: '0.86rem', color: 'var(--muted)', lineHeight: 1.6 }}>
{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 <strong>remove {pending.removedFacets?.length || 0} facet
</strong>
{(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.
</>
)}
</p>
<Row label="Would remove">{(pending.removedFacets || []).join(', ') || '—'}</Row>
<Row label="Would add">{(pending.addedFacets || []).join(', ') || '—'}</Row>
<Row label="Detected">{pending.detectedAt ? new Date(pending.detectedAt).toLocaleString() : '—'}</Row>
<div style={{ display: 'flex', gap: 10, marginTop: 14, flexWrap: 'wrap' }}>
<button type="button" className="btn btn-primary btn-sq" disabled={busy} onClick={onApprove}>
Approve and import
</button>
{!declined && (
<button type="button" className="btn btn-sq" disabled={busy} onClick={onReject}>
Keep the current atlas
</button>
)}
</div>
</section>
)
}
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 <Loading />
if (error && !status) return <ErrorState message={error} />
const counts = status?.counts || null
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 20 }}>
<header>
<h2 className="display" style={{ margin: 0, fontSize: '1.3rem', color: 'var(--head)' }}>
Spawn atlas
</h2>
<p className="sans" style={{ margin: '6px 0 0', color: 'var(--muted)', fontSize: '0.88rem', lineHeight: 1.6, maxWidth: 760 }}>
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.
</p>
</header>
{status?.pending && (
<PendingReview
pending={status.pending}
busy={busy}
onApprove={() => run('approve the refresh', () => api.admin.atlas.approve())}
onReject={() => run('decline the refresh', () => api.admin.atlas.reject())}
/>
)}
<section style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16 }}>
<h3 className="display" style={{ margin: '0 0 10px', fontSize: '1rem', color: 'var(--head)' }}>
What is loaded
</h3>
<Row label="Imported">
{status?.importedAt ? new Date(status.importedAt).toLocaleString() : 'Never'}
</Row>
<Row label="Facets">{status?.facets?.length ? status.facets.join(', ') : '—'}</Row>
{counts && (
<>
<Row label="Spawners">{counts.points?.toLocaleString() ?? '—'}</Row>
<Row label="Creatures">{counts.creatures?.toLocaleString() ?? '—'}</Row>
<Row label="Regions / landmarks">
{`${counts.regions?.toLocaleString() ?? '—'} / ${counts.landmarks?.toLocaleString() ?? '—'}`}
</Row>
<Row label="Champion altars">{counts.champions?.toLocaleString() ?? '—'}</Row>
</>
)}
<Row label="Tree readable">
{!status?.configured ? 'No path set' : status.treeReadable ? 'Yes' : 'No'}
</Row>
<Row label="Tree changed since import">
{status?.drift == null ? '—' : status.drift ? 'Yes — an import would pick it up' : 'No'}
</Row>
</section>
<section style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16 }}>
<h3 className="display" style={{ margin: '0 0 4px', fontSize: '1rem', color: 'var(--head)' }}>
ServUO tree
</h3>
<p className="sans" style={{ margin: '0 0 12px', fontSize: '0.84rem', color: 'var(--muted)', lineHeight: 1.6 }}>
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 <code>SERVUO_PATH</code> deploy default, so the
mount can move without a redeploy. Leave it blank to turn the atlas off.
</p>
<div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'center' }}>
<input
className="input"
value={path}
onChange={(e) => setPath(e.target.value)}
placeholder="/srv/servuo"
style={{ flex: '1 1 320px', minWidth: 0 }}
/>
<button type="button" className="btn btn-sq" disabled={busy} onClick={savePath}>
Save path
</button>
</div>
</section>
<section style={{ border: '1px solid var(--line)', borderRadius: 10, padding: 16 }}>
<h3 className="display" style={{ margin: '0 0 4px', fontSize: '1rem', color: 'var(--head)' }}>
Re-import
</h3>
<p className="sans" style={{ margin: '0 0 12px', fontSize: '0.84rem', color: 'var(--muted)', lineHeight: 1.6 }}>
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.
</p>
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap', alignItems: 'center' }}>
<button
type="button"
className="btn btn-primary btn-sq"
disabled={busy || !status?.configured}
onClick={() => run('import the atlas', () => api.admin.atlas.import(force))}
>
{busy ? 'Working…' : 'Import now'}
</button>
<label className="sans" style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: '0.85rem', cursor: 'pointer' }}>
<input type="checkbox" checked={force} onChange={(e) => setForce(e.target.checked)} />
Re-import even if the tree is unchanged
</label>
</div>
</section>
{(msg || error) && (
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
{msg && <span className="sans" style={{ color: '#7fd0a4', fontSize: '0.85rem' }}>{msg}</span>}
{error && <span className="sans" style={{ color: '#d98b84', fontSize: '0.85rem' }}>{error}</span>}
</div>
)}
</div>
)
}

View File

@@ -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 (
<button
type="button"
onClick={onClick}
className="sans"
style={{
fontSize: '0.78rem',
padding: '5px 12px',
borderRadius: 999,
cursor: 'pointer',
color: active ? 'var(--bg-deep)' : 'var(--muted)',
background: active ? 'var(--accent)' : 'transparent',
border: `1px solid ${active ? 'var(--accent)' : 'var(--line)'}`,
}}
>
{children}
</button>
)
}
function CreatureCard({ creature }) {
const facets = Object.entries(creature.facets || {}).sort((a, b) => b[1] - a[1])
return (
<Link
to={`/site/atlas/${encodeURIComponent(creature.slug)}`}
className="panel"
style={{
padding: '13px 15px',
display: 'flex',
alignItems: 'center',
gap: 14,
textDecoration: 'none',
color: 'inherit',
}}
>
<div style={{ minWidth: 0, flex: 1 }}>
<div
className="display"
style={{
fontSize: '0.98rem',
color: 'var(--head)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{creature.name}
</div>
<div className="sans dim" style={{ fontSize: '0.74rem', marginTop: 3 }}>
{facets.length === 0
? '—'
: facets.map(([facet, n]) => `${facet} (${n})`).join(' · ')}
</div>
</div>
<div className="sans" style={{ flex: 'none', textAlign: 'right' }}>
<div style={{ color: 'var(--head)', fontSize: '0.92rem' }}>{num(creature.total)}</div>
<div className="dim" style={{ fontSize: '0.68rem', letterSpacing: '0.05em' }}>
{num(creature.points)} spawners
</div>
</div>
</Link>
)
}
// 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 <Loading />
if (state.error) return <ErrorState message="Could not load the bestiary right now." />
if (state.items.length === 0) {
return <EmptyState>Nothing in the atlas matches that.</EmptyState>
}
return (
<>
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 12px' }}>
Showing {num(state.items.length)} of {num(state.total)}
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{state.items.map((c) => (
<CreatureCard key={c.slug} creature={c} />
))}
</div>
{state.items.length < state.total && (
<div style={{ textAlign: 'center', marginTop: 16 }}>
<button type="button" className="btn" onClick={loadMore} disabled={more}>
{more ? 'Loading…' : 'Load more'}
</button>
</div>
)}
</>
)
}
// 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 <Loading />
if (error) return <ErrorState message="Could not load the champion altars right now." />
if (!data || data.length === 0) return <EmptyState>No champion altars are configured.</EmptyState>
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{data.map((champ) => (
<div key={champ.slug} className="panel" style={{ padding: '13px 15px', display: 'flex', gap: 14, alignItems: 'center' }}>
<div style={{ minWidth: 0, flex: 1 }}>
<div className="display" style={{ fontSize: '0.98rem', color: 'var(--head)' }}>
{champ.label || champ.name}
</div>
<div className="sans dim" style={{ fontSize: '0.74rem', marginTop: 3 }}>
{champ.facet}
{champ.group ? ` · ${champ.group}` : ''} · {champ.x}, {champ.y}
</div>
</div>
<span className="sans" style={{ flex: 'none', fontSize: '0.76rem', color: 'var(--muted)' }}>
{champ.randomType ? 'Random champion' : champ.type || '—'}
</span>
</div>
))}
</div>
)
}
// 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 <Loading />
if (error) return <ErrorState message="Could not load places right now." />
if (rows.length === 0) return <EmptyState>No regions or landmarks match that.</EmptyState>
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{rows.map((row) => (
<div key={row.key} className="panel" style={{ padding: '10px 14px', display: 'flex', gap: 12, alignItems: 'baseline' }}>
<span className="sans" style={{ flex: 1, minWidth: 0, color: 'var(--head)', fontSize: '0.88rem' }}>{row.name}</span>
<span className="sans dim" style={{ fontSize: '0.72rem' }}>{row.facet} · {row.detail}</span>
<span className="sans dim" style={{ fontSize: '0.66rem', letterSpacing: '0.06em', flex: 'none' }}>{row.kind}</span>
</div>
))}
</div>
)
}
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 (
<PublicLayout section="website">
<div className="shell-narrow page-body">
<PageHeader
eyebrow="Bestiary"
title="Spawn atlas"
lead="Where everything lives, read straight out of the shard's own spawn files — so it stays accurate whether or not the server is up."
/>
{/* 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 && (
<p className="sans dim" style={{ fontSize: '0.76rem', margin: '-12px 0 18px' }}>
{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()}` : ''}
</p>
)}
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 12 }}>
{TABS.map((t) => (
<Chip key={t.key} active={tab === t.key} onClick={() => setTab(t.key)}>
{t.label}
</Chip>
))}
</div>
{tab !== 'champions' && (
<input
className="input"
type="search"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder={tab === 'creatures' ? 'Search creatures…' : 'Search regions and landmarks…'}
style={{ width: '100%', marginBottom: 12 }}
/>
)}
{facets.length > 0 && (
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 18 }}>
<Chip active={facet === ''} onClick={() => setFacet('')}>
All facets
</Chip>
{facets.map((f) => (
<Chip key={f} active={facet === f} onClick={() => setFacet(f)}>
{f}
</Chip>
))}
</div>
)}
{meta.error && <ErrorState message="Could not load the atlas right now." />}
{!meta.error && !meta.loading && !imported && (
<EmptyState>The spawn atlas has not been imported yet.</EmptyState>
)}
{!meta.error && imported && (
<>
{tab === 'creatures' && <Creatures q={q} facet={facet} />}
{tab === 'champions' && <Champions facet={facet} />}
{tab === 'places' && <Places q={q} facet={facet} />}
</>
)}
</div>
</PublicLayout>
)
}

View File

@@ -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 (
<section className="panel" style={{ padding: 18 }}>
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12 }}>
<h2 className="display" style={{ margin: '0 0 12px', fontSize: '1.02rem', color: 'var(--head)' }}>
{title}
</h2>
{right}
</div>
{children}
</section>
)
}
function Places({ places }) {
if (places.length === 0) {
return <p className="sans dim" style={{ margin: 0 }}>No placed spawners.</p>
}
return (
<div>
{places.map((place) => (
<div
key={`${place.facet}:${place.label}`}
className="sans"
style={{
display: 'flex',
alignItems: 'baseline',
justifyContent: 'space-between',
gap: 12,
padding: '6px 0',
borderBottom: '1px solid var(--line)',
fontSize: '0.86rem',
}}
>
<span style={{ minWidth: 0, color: 'var(--head)' }}>{place.label}</span>
<span className="dim" style={{ flex: 'none' }}>
{place.facet} · {num(place.spawners)} spawner{place.spawners === 1 ? '' : 's'} · up to{' '}
{num(place.maxAlive)} at once
</span>
</div>
))}
</div>
)
}
function Spawners({ spawners, truncated }) {
const [open, setOpen] = useState(false)
if (spawners.length === 0) return null
return (
<Panel
title="Individual spawners"
right={
<button
type="button"
className="sans"
onClick={() => setOpen((v) => !v)}
style={{ background: 'none', border: 'none', color: 'var(--accent)', cursor: 'pointer', fontSize: '0.78rem' }}
>
{open ? 'Hide' : `Show ${num(spawners.length)}`}
</button>
}
>
{open && (
<div style={{ overflowX: 'auto' }}>
<table className="sans" style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8rem' }}>
<thead>
<tr style={{ textAlign: 'left', color: 'var(--muted)' }}>
<th style={{ padding: '4px 8px 8px 0' }}>Place</th>
<th style={{ padding: '4px 8px 8px 0' }}>Facet</th>
<th style={{ padding: '4px 8px 8px 0' }}>Coords</th>
<th style={{ padding: '4px 8px 8px 0' }}>Max</th>
<th style={{ padding: '4px 0 8px 0' }}>Respawn</th>
</tr>
</thead>
<tbody>
{spawners.map((s) => (
<tr key={s.id} style={{ borderTop: '1px solid var(--line)' }}>
<td style={{ padding: '6px 8px 6px 0', color: 'var(--head)' }}>{s.label}</td>
<td style={{ padding: '6px 8px 6px 0' }} className="dim">{s.facet}</td>
<td style={{ padding: '6px 8px 6px 0' }} className="dim">{s.x}, {s.y}</td>
<td style={{ padding: '6px 8px 6px 0' }} className="dim">{num(s.maxCount)}</td>
<td style={{ padding: '6px 0' }} className="dim">{delay(s.minDelay, s.maxDelay) || '—'}</td>
</tr>
))}
</tbody>
</table>
{truncated && (
<p className="sans dim" style={{ fontSize: '0.74rem', margin: '10px 0 0' }}>
Only the largest spawners are listed.
</p>
)}
</div>
)}
</Panel>
)
}
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 (
<PublicLayout section="website">
<div className="shell-narrow page-body">
<p className="sans" style={{ marginBottom: 8 }}>
<Link to="/site/atlas" style={{ color: 'var(--accent)', fontSize: '0.78rem' }}>
← Spawn atlas
</Link>
</p>
{loading && <Loading />}
{error && !missing && <ErrorState message="Could not load that creature right now." />}
{missing && <EmptyState>Nothing by that name spawns on this shard.</EmptyState>}
{!loading && !error && data && (
<>
<PageHeader
eyebrow="Bestiary"
title={data.name}
lead={`Up to ${num(data.total)} alive at once across ${num(data.points)} spawner${data.points === 1 ? '' : 's'}.`}
/>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<Panel
title="Where it spawns"
right={
<span className="sans dim" style={{ fontSize: '0.74rem' }}>
{facets.map(([facet, n]) => `${facet} (${n})`).join(' · ')}
</span>
}
>
<Places places={data.places || []} />
</Panel>
<Spawners spawners={data.spawners || []} truncated={!!data.spawnersTruncated} />
{data.alsoHere?.length > 0 && (
<Panel title="Shares a spawner with">
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{data.alsoHere.map((other) => (
<Link
key={other.slug}
to={`/site/atlas/${encodeURIComponent(other.slug)}`}
className="sans"
style={{
fontSize: '0.78rem',
padding: '4px 11px',
borderRadius: 999,
border: '1px solid var(--line)',
color: 'var(--muted)',
textDecoration: 'none',
}}
>
{other.name} <span className="dim">×{num(other.shared)}</span>
</Link>
))}
</div>
</Panel>
)}
</div>
</>
)}
</div>
</PublicLayout>
)
}

View File

@@ -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' }))
})

View File

@@ -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",

View File

@@ -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"

View File

@@ -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,
}

View File

@@ -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,
}

View File

@@ -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.

View File

@@ -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 }

View File

@@ -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,
}

View File

@@ -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 <date>" 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

View File

@@ -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

View File

@@ -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')),

View File

@@ -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,

File diff suppressed because it is too large Load Diff

View File

@@ -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'],

View File

@@ -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)
})

View File

@@ -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 = `<Spawns>
<Points>
<Name>Minutes</Name>
<Map>Sosaria</Map>
<X>1</X><Y>1</Y>
<MinDelay>5</MinDelay>
<MaxDelay>10</MaxDelay>
<IsRunning>True</IsRunning>
<Objects2>Orc:MX=1</Objects2>
</Points>
<Points>
<Name>Seconds</Name>
<Map>Sosaria</Map>
<X>2</X><Y>2</Y>
<DelayInSec>True</DelayInSec>
<MinDelay>5</MinDelay>
<MaxDelay>10</MaxDelay>
<IsRunning>True</IsRunning>
<Objects2>Orc:MX=1</Objects2>
</Points>
</Spawns>`
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)
})

View File

@@ -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)