feat(assets): creature artwork from the shard's own client (Phase 3)
All checks were successful
PR Checks / client-build (pull_request) Successful in 18s
PR Checks / server-tests (pull_request) Successful in 24s
PR Checks / frozen-manifest (pull_request) Successful in 49s

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
This commit is contained in:
2026-09-10 18:40:46 -05:00
parent 55df03496d
commit a194ec68e0
17 changed files with 3605 additions and 6 deletions

View File

@@ -45,6 +45,46 @@ function Chip({ active, onClick, children }) {
)
}
// 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 (
@@ -60,6 +100,7 @@ function CreatureCard({ creature }) {
color: 'inherit',
}}
>
<CreaturePortrait art={creature.art} name={creature.name} />
<div style={{ minWidth: 0, flex: 1 }}>
<div
className="display"

View File

@@ -2,6 +2,7 @@ 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.
//
@@ -146,11 +147,22 @@ export default function AtlasCreature() {
{!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'}.`}
/>
{/* 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