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
202 lines
7.4 KiB
JavaScript
202 lines
7.4 KiB
JavaScript
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>
|
||
)
|
||
}
|