The 35 files behind twelve public pages, seven admin views, two player views and three core-page extensions, ported onto `window.__rg`. Every one of them imports exactly the seven kit members plus `lib/format.js`, which is the finding §2.7.1 predicted and this confirms. `client/src/core.js` is the port mechanism, and unlike the server's it is a plain read: `window.__rg` is published before any module chunk evaluates, so there is no gap to defer around and a ported component keeps its ordinary import shape. `client/src/api.js` rebuilds the UO namespaces over the request primitive — same URLs, because §1.2 freezes the API surface. SPA paths changed and API paths did not. `/site/shard` is `/uo/shard`, and the admin paths lost their now-redundant `shard-` prefixes (`/admin/uo/ops`), a clean break being the only moment that is free. `shim/rg.js` becomes the single reader of the global, so the "core did not publish its dependencies" message is reachable from whichever module the bundler happens to touch first rather than from whichever one is imported first — a guarantee that used to last until someone sorted the imports. Co-Authored-By: Claude <noreply@anthropic.com>
199 lines
7.3 KiB
JavaScript
199 lines
7.3 KiB
JavaScript
import { useMemo, useState } from 'react'
|
||
import { Link, useParams } from 'react-router-dom'
|
||
import api from '../../api.js'
|
||
import { EmptyState, ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.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="/uo/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={`/uo/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>
|
||
)
|
||
}
|