Until now the only way a creature got a picture on this site was for an
operator to open UOFiddler on a desktop, export sprites by hand, copy them to
the web host and write a spawnAtlas.art.json naming each one. Almost nobody
did, so shard_spawn_creatures.art was NULL on every install.
The shard has had those files the whole time. Admin -> Shard -> Import now
walks its asset manifest, fetches only the sprites whose hash changed, writes
them under uploads/atlas/, asks the shard for a body id per atlas creature
(§8: it CONSTRUCTS the creature and reads Body.BodyID, which is the only thing
that is right for a shard's own custom creatures) and points each creature at
its picture. On a stock client that is 787 portraits, about a megabyte.
**The one thing v8.md §12 got wrong, and it is not cosmetic.** It says
`shard_spawn_creatures.art` "starts being filled by the import". That table is
emptied and refilled by replaceAtlas on EVERY atlas refresh, and a refresh runs
on every boot -- so a filename stored there would be destroyed by an ordinary
re-parse of the ServUO tree, with the next Update finding the client files
unchanged, reporting "nothing to do", and never restoring it. Nothing would
report a fault; the pictures would just be gone.
So the assets and the body map live in their own tables outside that blast
radius, and applyAtlas re-derives `art` on the way past as
`{ ...derived, ...operatorMap }` -- which is also the one place "the operator's
own artwork wins" is enforced, on every rebuild rather than only at import.
Smaller decisions worth not rediscovering:
- The derivation joins on the catalogue KEY, not on the body id. The simpler
join is correct today and stops being correct the moment phase 6 adds
body/400/a2/f0, at which point one slug matches dozens of rows.
- Filenames are content-addressed. A stable name overwritten in place leaves
every browser and CDN serving the previous client's sprite, with the database
row perfectly correct.
- An unchanged key whose FILE is missing is fetched again. The row and the disk
can disagree (a wiped uploads volume, a restore from a dump), and a broken
image on a creature page is worse than one re-fetched sprite.
- A key the shard cannot render is not a failure. Two thirds of the playable
ghost and gargoyle bodies have no art on a stock client, and an import that
reported eight failures every time would teach an operator to ignore the panel.
- A key that VANISHED from the manifest needs review before anything changes:
an unmounted client volume and a deliberate downgrade look identical here.
23 new tests; 674 server and 42 client tests pass. The SQL was also run against
a real MariaDB, which is what proved the CONCAT join and the singleton CHECK.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
349 lines
13 KiB
JavaScript
349 lines
13 KiB
JavaScript
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||
import { Link } from 'react-router-dom'
|
||
import api from '../../api.js'
|
||
import { EmptyState, ErrorState, Loading, PageHeader, PublicLayout, useAsync } from '../../core.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>
|
||
)
|
||
}
|
||
|
||
// One creature's portrait, when there is one.
|
||
//
|
||
// `art` is a FILENAME under uploads/atlas/, never a path or a URL: it is either a
|
||
// sprite the shard extracted from the operator's own UO client (docs/link/v8.md
|
||
// §12) or a picture the operator drew and named in `spawnAtlas.art.json`, and the
|
||
// two are indistinguishable here on purpose.
|
||
//
|
||
// **NULL is the ordinary case and always will be.** An install with no shard link
|
||
// has never imported one; a shard whose host cannot render images has none; and
|
||
// even on a complete import, two thirds of the playable ghost and gargoyle bodies
|
||
// have no art in the client at all (§5.2). So this renders nothing rather than a
|
||
// placeholder, and every layout around it is written to sit correctly with the
|
||
// picture absent — which is the state the whole atlas was designed in.
|
||
//
|
||
// Sprites are small (a couple of dozen pixels square) and UO's art is pixel art,
|
||
// so `imageRendering: 'pixelated'` matters: a browser's default smoothing turns a
|
||
// 24×63 wolf into a smear at any size above its own.
|
||
export function CreaturePortrait({ art, name, size = 40 }) {
|
||
if (!art) return null
|
||
|
||
return (
|
||
<img
|
||
src={`/uploads/atlas/${encodeURIComponent(art)}`}
|
||
alt=""
|
||
// Decorative: the creature's name is already beside it as text, so an alt
|
||
// repeating it would make a screen reader say it twice.
|
||
aria-hidden="true"
|
||
loading="lazy"
|
||
style={{
|
||
width: size,
|
||
height: size,
|
||
flex: 'none',
|
||
objectFit: 'contain',
|
||
imageRendering: 'pixelated',
|
||
}}
|
||
title={name}
|
||
/>
|
||
)
|
||
}
|
||
|
||
function CreatureCard({ creature }) {
|
||
const facets = Object.entries(creature.facets || {}).sort((a, b) => b[1] - a[1])
|
||
return (
|
||
<Link
|
||
to={`/uo/atlas/${encodeURIComponent(creature.slug)}`}
|
||
className="panel"
|
||
style={{
|
||
padding: '13px 15px',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: 14,
|
||
textDecoration: 'none',
|
||
color: 'inherit',
|
||
}}
|
||
>
|
||
<CreaturePortrait art={creature.art} name={creature.name} />
|
||
<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, /uo/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>
|
||
)
|
||
}
|