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
211 lines
8.1 KiB
JavaScript
211 lines
8.1 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'
|
||
import { CreaturePortrait } from './Atlas.jsx'
|
||
|
||
// 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 && (
|
||
<>
|
||
{/* The portrait sits BESIDE the header rather than inside it: `art`
|
||
is NULL for most creatures on most installs — no shard link, a
|
||
host that cannot render images, or simply a body this client has
|
||
no art for — and a header component that had to lay out around an
|
||
absent picture would be carrying that case forever. Here the row
|
||
collapses to exactly the header, which is what it was before. */}
|
||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 16 }}>
|
||
<CreaturePortrait art={data.art} name={data.name} size={96} />
|
||
<div style={{ minWidth: 0, flex: 1 }}>
|
||
<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>
|
||
</div>
|
||
|
||
<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>
|
||
)
|
||
}
|