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

@@ -660,6 +660,75 @@ CREATE TABLE IF NOT EXISTS shard_atlas_pending (
CONSTRAINT chk_shard_atlas_pending_singleton CHECK (id = 1)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ── The Asset Bridge (docs/link/v8.md, protocol 8 phase 3) ─────────────────
--
-- One row per imported asset: the manifest side of §6, and what makes an Update
-- a diff rather than a re-download. `sha256` is of the PNG the shard produced, so
-- a re-import fetches only the keys whose hash moved.
--
-- **`file` is a filename under the uploads directory, never a path.** Images are
-- written through `ctx.uploads`, the same door the operator's own atlas art comes
-- in by, and storing a path here would let a row decide where the server reads
-- from.
--
-- `bytes`/`width`/`height` are carried from the manifest rather than re-derived,
-- because the manifest reports them before the pixels are fetched and a screen
-- that lists what WOULD be imported needs them then.
CREATE TABLE IF NOT EXISTS shard_assets (
asset_key VARCHAR(191) NOT NULL PRIMARY KEY, -- §5's key: `body/34/a0`
family VARCHAR(24) NOT NULL DEFAULT 'body',
sha256 CHAR(64) NOT NULL,
bytes INT NOT NULL DEFAULT 0,
width INT NOT NULL DEFAULT 0,
height INT NOT NULL DEFAULT 0,
body INT NULL, -- the body id, for the atlas join
direction TINYINT NULL,
file VARCHAR(191) NULL, -- filename under uploads/, NULL until fetched
imported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- The atlas art derivation joins creature → body → asset on every atlas refresh,
-- so the body lookup is the read that has to be fast, not the key.
INDEX idx_shard_assets_body (body)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Slug → body id, as the shard itself answered it (§8).
--
-- **Deliberately NOT a column on `shard_spawn_creatures`.** That table is
-- IMPORT-OWNED: `replaceAtlas` empties and refills it inside one transaction on
-- every atlas refresh. A body id living there would be destroyed by a routine
-- re-parse of the ServUO tree — and the next asset Update would find the source
-- hashes unchanged, report "nothing to do", and never put it back. The portrait
-- would simply vanish from every creature page until somebody thought to force a
-- re-import.
--
-- So the resolution lives here, outside the atlas's blast radius, and
-- `replaceAtlas` READS it to derive `shard_spawn_creatures.art` on the way past.
--
-- `status` is the shard's own verdict and each value is a different thing an
-- operator can act on: `ok`, `unknown` (the spawn file names a type this shard's
-- scripts do not define — real drift), `notCreature` (a spawn file legitimately
-- naming an item or decoration, a permanent answer), `failed` (its constructor
-- threw). A row is kept for every one of them, because "asked and answered no" is
-- what stops the next pass asking again.
CREATE TABLE IF NOT EXISTS shard_creature_bodies (
slug VARCHAR(120) NOT NULL PRIMARY KEY, -- → shard_spawn_creatures.slug (no FK)
type_name VARCHAR(120) NOT NULL, -- the ServUO class name that was asked
body INT NULL, -- NULL unless status = 'ok'
status VARCHAR(16) NOT NULL DEFAULT 'ok',
resolved_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_shard_creature_bodies_body (body)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Singleton (id = 1) describing the asset import currently applied: the shard's
-- catalogue id, its extractor version, the counts and when it ran. Same shape and
-- same job as `shard_cliloc_meta` — it is what an Update compares against to
-- decide there is nothing to do.
CREATE TABLE IF NOT EXISTS shard_asset_meta (
id TINYINT NOT NULL PRIMARY KEY DEFAULT 1,
payload JSON NOT NULL,
imported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT chk_shard_asset_meta_singleton CHECK (id = 1)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- House registry (Protocol 2.0). The house.update full-state feed carries richer
-- fields than the house.decay transition feed shard_houses was built for. Rather
-- than a second table for one entity, extend shard_houses: house.update writes the