feat(assets): creature artwork from the shard's own client (Phase 3)
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:
@@ -209,6 +209,43 @@ const getClilocTable = ({ lang, cursor } = {}) => {
|
||||
return call(`/cliloc${qs ? `?${qs}` : ''}`)
|
||||
}
|
||||
|
||||
// Stage 2 of the import gate, PAGED: every asset the shard could serve, with a
|
||||
// hash and a size and no pixels. The website diffs it against what it holds and
|
||||
// fetches only the keys whose hash moved — which on an ordinary restart is none
|
||||
// of them, and is the whole difference between an Update and a re-download.
|
||||
//
|
||||
// This family pages on the shard's WALL CLOCK rather than on bytes: its rows are
|
||||
// ~90 bytes, but building one means decoding a sprite, so a page ends when the
|
||||
// shard's scan budget is spent (`cut: 'limit'`) far more often than when the byte
|
||||
// budget is (`cut: 'budget'`). Neither means finished; only `cut: 'end'` does.
|
||||
const getAssetManifest = ({ family, cursor } = {}) => {
|
||||
const params = new URLSearchParams()
|
||||
if (family) params.set('family', family)
|
||||
if (cursor) params.set('cursor', cursor)
|
||||
const qs = params.toString()
|
||||
return call(`/assets/manifest${qs ? `?${qs}` : ''}`)
|
||||
}
|
||||
|
||||
// The pixels, for keys the caller names. POST because the key list IS the request.
|
||||
//
|
||||
// `catalog` is the mid-import guard and should always be passed: it is an id the
|
||||
// manifest derived from the client files themselves, and handing it back makes the
|
||||
// shard refuse (422) if those files moved in between. Without it, an operator who
|
||||
// patched their client halfway through an import gets one asset set stitched out
|
||||
// of two, with nothing anywhere reporting a problem.
|
||||
const fetchAssets = ({ keys, catalog, cursor } = {}) =>
|
||||
call('/assets/fetch', { method: 'POST', body: { keys, catalog, cursor } })
|
||||
|
||||
// Slug → body id (docs/link/v8.md §8). The atlas knows a creature by its ServUO
|
||||
// class name; the client knows it by a body id; nothing in the ServUO tree
|
||||
// declares the mapping, so the shard answers it by constructing the creature and
|
||||
// reading `Body.BodyID`.
|
||||
//
|
||||
// That runs on the shard's CORE THREAD, so the batch is small and the shard
|
||||
// refuses an over-long list rather than truncating it. `assetBridge.js` chunks;
|
||||
// nothing else should call this directly.
|
||||
const resolveBodies = (types) => call('/assets/bodies', { method: 'POST', body: { types } })
|
||||
|
||||
// ── Commands ──────────────────────────────────────────────────────────────
|
||||
const confirmLink = (code, websiteUserId) =>
|
||||
call('/link/confirm', { method: 'POST', body: { code, websiteUserId: String(websiteUserId) } })
|
||||
@@ -431,6 +468,9 @@ module.exports = {
|
||||
getMarket,
|
||||
getAssetSources,
|
||||
getClilocTable,
|
||||
getAssetManifest,
|
||||
fetchAssets,
|
||||
resolveBodies,
|
||||
confirmLink,
|
||||
linkLookup,
|
||||
createAccount,
|
||||
|
||||
Reference in New Issue
Block a user