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

@@ -452,8 +452,64 @@ function listChampions({ facet = '' } = {}) {
)
}
/**
* Every creature the atlas knows, as `{ slug, name }` (docs/link/v8.md §8).
*
* `name` is the ServUO CLASS NAME, not a display string invented here: the atlas
* build picks the winning spelling of the spawn type token, so "GiantSpider" is
* what the column holds and what `ScriptCompiler.FindTypeByName` will resolve.
* That is the one property that lets the asset import ask its question without a
* new column, and it is worth knowing before anyone "tidies" this into a
* prettified label.
*/
async function allCreatureTypes() {
return query('SELECT slug, name FROM shard_spawn_creatures ORDER BY slug')
}
/**
* Point creatures at their artwork, from a `{ slug: filename }` map.
*
* Everything NOT in the map is set back to NULL, which is deliberate: a creature
* whose body stopped resolving must lose its portrait rather than keep pointing
* at a file that is about to be deleted. A broken image is worse than no image,
* and no image is the state the whole atlas UI was designed around.
*
* One transaction, and a single `CASE` update rather than a statement per slug —
* at ~800 creatures the round trips are the cost, not the work.
*/
async function setCreatureArt(map) {
const entries = Object.entries(map ?? {}).filter(
([slug, file]) => typeof slug === 'string' && slug !== '' && typeof file === 'string' && file !== '',
)
const conn = await core.pool.getConnection()
try {
await conn.beginTransaction()
await conn.query('UPDATE shard_spawn_creatures SET art = NULL WHERE art IS NOT NULL')
for (let i = 0; i < entries.length; i += BATCH) {
await conn.batch(
'UPDATE shard_spawn_creatures SET art = ? WHERE slug = ?',
entries.slice(i, i + BATCH).map(([slug, file]) => [file, slug]),
)
}
await conn.commit()
return entries.length
} catch (err) {
await conn.rollback().catch(() => {})
throw err
} finally {
conn.release()
}
}
module.exports = {
replaceAtlas,
allCreatureTypes,
setCreatureArt,
getMeta,
getFacets,
getPending,

View File

@@ -107,8 +107,45 @@ function pointTypeRows(points) {
return rows
}
/**
* The art each creature gets when the atlas is rebuilt.
*
* **`replaceAtlas` empties `shard_spawn_creatures` and refills it**, so anything
* on that row is destroyed on every refresh — and a refresh happens on every
* boot. Before protocol 8 that cost nothing: `art` came from a file on disk and
* was simply re-read. As of phase 3 it can also come from an IMPORT, which is
* expensive to obtain and whose gate (the shard's client-file hashes) would say
* "unchanged" for weeks afterwards. So the imported values are re-derived here,
* on the way past, rather than being restored by an import that has no reason to
* run again.
*
* **The operator's map is spread last and therefore wins.** Someone who drew
* their own creature portraits must not have them replaced by a sprite rip on the
* next Update — the one property §12 states outright.
*
* Never throws: the asset tables are the newer half of this pair, and an atlas
* refresh must not start failing because an asset query did. Losing the imported
* art for one boot is recoverable by pressing Import; a boot that cannot rebuild
* the atlas is not.
*/
async function artForAtlas() {
const operator = loadArtMap()
try {
// eslint-disable-next-line global-require
const assetsDb = require('../shardAssets/shardAssets.db')
const derived = await assetsDb.artBySlug()
return { ...derived, ...operator }
} catch (err) {
log.warn('imported creature art could not be read; using the operator map alone', {
error: err.message,
})
return operator
}
}
async function applyAtlas(atlas) {
return db.replaceAtlas({ ...atlas, pointTypes: pointTypeRows(atlas.points) }, loadArtMap())
return db.replaceAtlas({ ...atlas, pointTypes: pointTypeRows(atlas.points) }, await artForAtlas())
}
/**