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:
91
server/router/admin/shardAssets.controller.js
Normal file
91
server/router/admin/shardAssets.controller.js
Normal file
@@ -0,0 +1,91 @@
|
||||
// ── Admin · Client assets ──────────────────────────────────────────────────
|
||||
//
|
||||
// Operating the asset import: what the site holds, what the shard's client files
|
||||
// currently are, and a re-import after a client patch (docs/link/v8.md §6, §8,
|
||||
// §14, protocol 8 phase 3).
|
||||
//
|
||||
// The policy lives in the model. This controller does three things and no more:
|
||||
// it validates input, it maps an import RESULT onto an HTTP status, and it
|
||||
// records the action in the admin activity log.
|
||||
//
|
||||
// **An import result is not an exception**, exactly as for clilocs. A shard that
|
||||
// is down, an asset plane the operator has switched off, a Linux host with no
|
||||
// libgdiplus, a client patched halfway through the walk — each is a 200 carrying
|
||||
// `status: 'unavailable'` and a reason naming what to fix, not a 500 that says
|
||||
// only "something broke". The one thing that DOES 500 is this file having a bug.
|
||||
//
|
||||
// **This is the only thing that imports.** Boot never calls the shard for assets,
|
||||
// for the same reason it stopped calling it for clilocs: the files change when an
|
||||
// operator patches their client, which is an event they know about and the site
|
||||
// does not. So this endpoint is what an operator presses afterwards.
|
||||
//
|
||||
// The full panel — per-key review, the activity view, approve/reject as buttons —
|
||||
// is phase 8. This pair is what makes phase 3 reachable at all.
|
||||
|
||||
const assets = require('../../model/shardAssets/shardAssets.model')
|
||||
const { activity } = require('../../core')
|
||||
|
||||
const log = require('../../core').logger('admin-shard-assets')
|
||||
|
||||
// GET /admin/shard/assets — what is loaded, what the shard says, whether they
|
||||
// disagree. No public counterpart: the assets themselves are served as ordinary
|
||||
// files under /uploads, and this is the operating view of the import.
|
||||
async function getStatus(req, res) {
|
||||
try {
|
||||
return res.json(await assets.getStatus())
|
||||
} catch (err) {
|
||||
log.error('getStatus', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
// POST /admin/shard/assets/import — import or update the body catalogue, then
|
||||
// re-resolve the atlas's creatures and re-derive their artwork.
|
||||
//
|
||||
// `force` re-imports even when the client files are unchanged. It is also how an
|
||||
// operator recovers a wiped uploads volume: the database still holds every hash,
|
||||
// so the ordinary gate would report "unchanged" while every picture is missing.
|
||||
// (The import checks for the file on disk per key as well, so that case usually
|
||||
// heals itself — `force` is the answer when it does not.)
|
||||
//
|
||||
// `approve` accepts a catalogue that no longer offers keys this site holds.
|
||||
// Refused by default because an unmounted client volume and a deliberate
|
||||
// downgrade look identical from the server, and the wrong guess deletes artwork.
|
||||
async function importAssets(req, res) {
|
||||
try {
|
||||
const force = !!req.body?.force
|
||||
const approve = !!req.body?.approve
|
||||
const result = await assets.importAssets({ force, approve })
|
||||
|
||||
await activity.log({
|
||||
req,
|
||||
action: 'shard.assets.import',
|
||||
detail: {
|
||||
force,
|
||||
approve,
|
||||
status: result.status,
|
||||
code: result.code ?? null,
|
||||
assets: result.assets ?? null,
|
||||
fetched: result.fetched ?? null,
|
||||
written: result.written ?? null,
|
||||
removed: result.removed ?? null,
|
||||
// The body pass is logged as its own tally rather than as a single
|
||||
// number: `unknown` means the spawn files name a type this shard's
|
||||
// scripts do not define, which is real drift an operator should see, and
|
||||
// it reads identically to `failed` if both are summed into "not resolved".
|
||||
bodies: result.bodies?.tally ?? null,
|
||||
vanished: result.vanishedCount ?? null,
|
||||
},
|
||||
})
|
||||
|
||||
return res.json(result)
|
||||
} catch (err) {
|
||||
log.error('importAssets', err)
|
||||
return res.status(500).json({ message: 'Internal Server Error' })
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getStatus,
|
||||
importAssets,
|
||||
}
|
||||
Reference in New Issue
Block a user