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:
@@ -607,6 +607,136 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
},
|
||||
UoAssetStatus: {
|
||||
type: 'object',
|
||||
description:
|
||||
'Admin view of the client-asset import (docs/link/v8.md §6, §8). What the site holds beside what the shard’s UO client currently is. Holding nothing at all is a supported state — creature pages simply render without pictures, which is what every install did before this pipeline existed.',
|
||||
properties: {
|
||||
loaded: {
|
||||
type: 'object',
|
||||
description: 'What this site currently holds.',
|
||||
properties: {
|
||||
assets: { type: 'integer', description: 'Rows in the imported catalogue.', example: 787 },
|
||||
stored: { type: 'integer', description: 'How many of those have a picture on disk. Lower than `assets` when the shard listed a key it could not render.', example: 787 },
|
||||
creatures: { type: 'integer', description: 'Atlas creatures the shard has answered a body question about, resolved or not.', example: 812 },
|
||||
resolved: { type: 'integer', description: 'How many of those resolved to a body id. The rest are types this shard’s scripts do not define, or spawn entries naming an item rather than a creature.', example: 780 },
|
||||
catalog: { type: 'string', nullable: true, description: 'The shard’s catalogue id at the last import — derived from its client files, so it changes exactly when they do.', example: 'a3f9c21d4b8e0771' },
|
||||
extractorVersion: { type: 'integer', nullable: true, description: 'The version of the shard’s extraction code. A bump makes every derived byte drift even though the client files did not move.', example: 1 },
|
||||
importedAt: { type: 'string', format: 'date-time', nullable: true },
|
||||
},
|
||||
},
|
||||
shard: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
description: 'The shard’s own client files right now. NULL when there is no shard link or it could not be reached — see `reason`.',
|
||||
properties: {
|
||||
files: { type: 'integer', description: 'How many of the animation/definition files this catalogue reads the shard actually has. Few clients carry all five anim files.', example: 9 },
|
||||
extractorVersion: { type: 'integer', example: 1 },
|
||||
hashing: { type: 'boolean', description: 'A hash is being computed in the background. A null `sha256` while this is true means “not yet”, never “changed”.', example: false },
|
||||
complete: { type: 'boolean', description: 'Every client file has a content hash.', example: true },
|
||||
imaging: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
description: 'Whether the shard host can render an image at all. `ok: false` is the named NO_IMAGING state: ServUO under Mono needs libgdiplus, and without it a Linux shard cannot decode a sprite. Cliloc and atlas import are unaffected.',
|
||||
properties: {
|
||||
ok: { type: 'boolean', example: true },
|
||||
code: { type: 'string', nullable: true, example: null },
|
||||
reason: { type: 'string', nullable: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
drift: {
|
||||
type: 'boolean',
|
||||
nullable: true,
|
||||
description: 'True when the shard’s client files no longer match what was imported — press Import. NULL when they could not be read.',
|
||||
example: false,
|
||||
},
|
||||
reason: { type: 'string', nullable: true, description: 'Why the shard could not be asked, when it could not.' },
|
||||
code: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
description: 'Machine-readable cause of `reason`.',
|
||||
enum: ['DISABLED', 'NO_SOURCE', 'SHARD_DOWN', 'PROTOCOL', 'BUSY', 'NO_IMAGING', 'SOURCE_CHANGED', 'UNAVAILABLE'],
|
||||
},
|
||||
},
|
||||
},
|
||||
UoAssetImportResult: {
|
||||
type: 'object',
|
||||
description:
|
||||
'Outcome of an asset import. Reported rather than thrown, so a shard that is down or a host that cannot render images is an answer and not a 500.',
|
||||
properties: {
|
||||
status: {
|
||||
type: 'string',
|
||||
enum: ['skipped', 'unavailable', 'unchanged', 'imported', 'needsReview', 'failed'],
|
||||
description: '`skipped`: no shard is configured. `unchanged`: the client files match what was imported and nothing was fetched. `needsReview`: assets this site holds are no longer offered by the shard, and nothing was changed — re-run with `approve` to accept it.',
|
||||
example: 'imported',
|
||||
},
|
||||
reason: { type: 'string', nullable: true },
|
||||
code: {
|
||||
type: 'string',
|
||||
nullable: true,
|
||||
description: 'Machine-readable cause. `NO_IMAGING` is a shard host with no libgdiplus; `SOURCE_CHANGED` is a client patched partway through the walk, in which case nothing was applied.',
|
||||
enum: ['DISABLED', 'NO_SOURCE', 'SHARD_DOWN', 'PROTOCOL', 'BUSY', 'NO_IMAGING', 'SOURCE_CHANGED', 'INCOMPLETE', 'STUCK', 'MALFORMED', 'TOO_LARGE', 'UNAVAILABLE'],
|
||||
},
|
||||
catalog: { type: 'string', nullable: true, example: 'a3f9c21d4b8e0771' },
|
||||
extractorVersion: { type: 'integer', nullable: true, example: 1 },
|
||||
assets: { type: 'integer', nullable: true, description: 'Catalogue rows after the import.', example: 787 },
|
||||
fetched: { type: 'integer', nullable: true, description: 'How many sprites actually crossed the wire. On an Update after a client patch this is far smaller than `assets`, which is the point of the manifest.', example: 12 },
|
||||
written: { type: 'integer', nullable: true, description: 'How many were written to disk.', example: 12 },
|
||||
absent: {
|
||||
type: 'integer',
|
||||
nullable: true,
|
||||
description: 'Keys the shard listed but could not render. NOT a failure: this client has no art at that key, which is the expected answer for two thirds of the playable ghost and gargoyle bodies.',
|
||||
example: 0,
|
||||
},
|
||||
unsupported: { type: 'integer', nullable: true, description: 'Keys the shard does not serve at all. Unlike `absent` this indicates a bug on the site’s side, not a gap in the client.', example: 0 },
|
||||
removed: { type: 'integer', nullable: true, description: 'Assets deleted because the shard no longer offers them (only with `approve`).', example: 0 },
|
||||
scanned: { type: 'integer', nullable: true, description: 'Body ids the shard walked. Far larger than `assets` — most of the addressable range has no art.', example: 2047 },
|
||||
pages: { type: 'integer', nullable: true, description: 'Manifest pages. This family pages on the shard’s scan budget rather than on bytes, so several is normal.', example: 4 },
|
||||
playerBodies: {
|
||||
type: 'array',
|
||||
nullable: true,
|
||||
items: { type: 'integer' },
|
||||
description: 'The body ids the shard reports as player-character bodies — every registered race’s male, female and ghost bodies, asked of the shard rather than hardcoded. These render head-on; everything else renders three-quarter.',
|
||||
example: [400, 401, 402, 403, 605, 606, 607, 608, 666, 667, 694, 695],
|
||||
},
|
||||
vanished: { type: 'array', nullable: true, items: { type: 'string' }, description: 'On `needsReview`: up to fifty of the keys that disappeared.' },
|
||||
vanishedCount: { type: 'integer', nullable: true },
|
||||
bodies: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
description: 'The slug → body id pass (§8). The shard constructs each creature and reads its body id, which is the only thing correct for a shard’s own custom creatures.',
|
||||
properties: {
|
||||
asked: { type: 'integer', example: 812 },
|
||||
answered: { type: 'integer', example: 812 },
|
||||
resolved: { type: 'integer', example: 780 },
|
||||
tally: {
|
||||
type: 'object',
|
||||
description: 'Per-outcome counts. `unknown` is real drift worth acting on — a spawn file naming a type this shard’s scripts do not define. `notCreature` is a spawn entry for an item or decoration and is permanent.',
|
||||
properties: {
|
||||
ok: { type: 'integer', example: 780 },
|
||||
unknown: { type: 'integer', example: 20 },
|
||||
notCreature: { type: 'integer', example: 12 },
|
||||
failed: { type: 'integer', example: 0 },
|
||||
},
|
||||
},
|
||||
reason: { type: 'string', nullable: true },
|
||||
},
|
||||
},
|
||||
art: {
|
||||
type: 'object',
|
||||
nullable: true,
|
||||
description: 'The derivation onto `shard_spawn_creatures.art`. An operator-supplied `spawnAtlas.art.json` always wins over an imported sprite.',
|
||||
properties: {
|
||||
applied: { type: 'integer', description: 'Creatures now pointing at a picture.', example: 763 },
|
||||
derived: { type: 'integer', description: 'From the import.', example: 763 },
|
||||
operator: { type: 'integer', description: 'From the operator’s own map.', example: 0 },
|
||||
error: { type: 'string', nullable: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
UoShardLinkRequest: {
|
||||
type: 'object',
|
||||
required: ['code'],
|
||||
|
||||
Reference in New Issue
Block a user