Protocol 3.0 order 3 (Part C), second of two website PRs. #112 built the data pipeline; this makes it reachable — six public routes, five admin ones, two public pages and an admin panel. Still website-only: no plugin, no sidecar, no new event kinds, no wire change. The API sits at /api/v1/public/atlas, not under /public/shard. Nothing here touches the sidecar, so the pages stay complete while the shard is down, and a /shard prefix would imply a dependency the atlas does not have. Unlike /shard/* it IS site-mode gated, like /posts and /wiki: a bestiary is site content. Every route carries requireFeature('atlas') and projects its response. The atlas feature declares no sensitive fields, so the projection is a no-op today — the call is there because v3.md 3.6.1's rule is that the FIRST field needing a gate should be covered by construction rather than by a retrofit. Two bugs the UI surfaced, both fixed here: Respawn delays were stored in the wrong unit, sometimes. XmlSpawner writes MinDelay/MaxDelay in minutes and switches to seconds only when a delay does not divide into whole minutes, flagging it per record with DelayInSec. A `5` means five minutes on one spawner and five seconds on the next, both plausible, and the pipeline stored the raw number. 170 of 6,455 stock spawners are second flagged. The parser normalises to seconds; the API and UI carry seconds. That exposed the hash gate as a trap. "Has the tree changed?" is the wrong question on its own: an install whose maps never change would have kept serving the old readings forever, because the only thing compared was the tree. PARSER_VERSION is now stored beside the source hashes and a mismatch counts as drift, so any future parse correction lands on the next boot. Also renamed the detail route's spawn-point array to `spawners` — it was `points`, which is the COUNT on the search route, so one key meant a number in one place and an array in the other. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U7CBg11prhLimL9iHSX1bP
311 lines
12 KiB
JavaScript
311 lines
12 KiB
JavaScript
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>
|
|
)
|
|
}
|