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:
223
server/model/shardAssets/shardAssets.db.js
Normal file
223
server/model/shardAssets/shardAssets.db.js
Normal file
@@ -0,0 +1,223 @@
|
||||
const core = require('../../core')
|
||||
|
||||
const { query } = core
|
||||
|
||||
// Raw SQL for the Asset Bridge's three tables (docs/link/v8.md §6, §8, §12).
|
||||
//
|
||||
// Unlike `shard_clilocs` and the atlas tables, these are NOT import-owned in the
|
||||
// empty-and-refill sense, and the difference is the whole reason phase 3 put them
|
||||
// in their own tables rather than in columns on `shard_spawn_creatures`.
|
||||
//
|
||||
// An asset row is expensive to obtain — a decode on the shard, a PNG across the
|
||||
// wire, a file written under uploads/ — and it is valid until the operator
|
||||
// patches their client. An atlas refresh, by contrast, happens on every boot and
|
||||
// destroys everything it owns. Putting the two in one table would mean a routine
|
||||
// re-parse of the ServUO tree silently deleting every imported portrait, with the
|
||||
// next Update reporting "nothing changed" and never restoring them.
|
||||
//
|
||||
// So these are upserted per key, and the only thing that ever deletes from them
|
||||
// is an explicit removal of a key the shard no longer offers — which is staged
|
||||
// for review, never applied silently (§6).
|
||||
|
||||
const BATCH = 500
|
||||
|
||||
async function batched(conn, sql, rows) {
|
||||
for (let i = 0; i < rows.length; i += BATCH) {
|
||||
await conn.batch(sql, rows.slice(i, i + BATCH))
|
||||
}
|
||||
return rows.length
|
||||
}
|
||||
|
||||
// ── the manifest side ──────────────────────────────────────────────────────
|
||||
|
||||
/** Every asset row we hold, as a Map of key → row. */
|
||||
async function allAssets() {
|
||||
const rows = await query(
|
||||
'SELECT asset_key, family, sha256, bytes, width, height, body, direction, file FROM shard_assets',
|
||||
)
|
||||
|
||||
const map = new Map()
|
||||
|
||||
for (const row of rows) {
|
||||
map.set(row.asset_key, {
|
||||
key: row.asset_key,
|
||||
family: row.family,
|
||||
sha256: row.sha256,
|
||||
bytes: Number(row.bytes) || 0,
|
||||
width: Number(row.width) || 0,
|
||||
height: Number(row.height) || 0,
|
||||
body: row.body === null ? null : Number(row.body),
|
||||
direction: row.direction === null ? null : Number(row.direction),
|
||||
file: row.file || null,
|
||||
})
|
||||
}
|
||||
|
||||
return map
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the assets an import produced, and record what the import was.
|
||||
*
|
||||
* One transaction for the rows and the meta together: the meta row is what an
|
||||
* Update compares against to decide there is nothing to do, so a meta written
|
||||
* without its rows would make the site believe it holds a catalogue it does not.
|
||||
*
|
||||
* `ON DUPLICATE KEY UPDATE` rather than delete-and-insert, because an unchanged
|
||||
* key must keep the file it already points at — re-writing the file for every
|
||||
* asset on every Update is exactly the cost the manifest diff exists to avoid.
|
||||
*/
|
||||
async function saveAssets(rows, meta) {
|
||||
const conn = await core.pool.getConnection()
|
||||
|
||||
try {
|
||||
await conn.beginTransaction()
|
||||
|
||||
const values = rows.map((r) => [
|
||||
r.key,
|
||||
r.family || 'body',
|
||||
r.sha256,
|
||||
r.bytes ?? 0,
|
||||
r.width ?? 0,
|
||||
r.height ?? 0,
|
||||
r.body ?? null,
|
||||
r.direction ?? null,
|
||||
r.file ?? null,
|
||||
])
|
||||
|
||||
await batched(
|
||||
conn,
|
||||
'INSERT INTO shard_assets (asset_key, family, sha256, bytes, width, height, body, direction, file) ' +
|
||||
'VALUES (?,?,?,?,?,?,?,?,?) ' +
|
||||
'ON DUPLICATE KEY UPDATE family = VALUES(family), sha256 = VALUES(sha256), ' +
|
||||
'bytes = VALUES(bytes), width = VALUES(width), height = VALUES(height), ' +
|
||||
'body = VALUES(body), direction = VALUES(direction), file = VALUES(file), ' +
|
||||
'imported_at = CURRENT_TIMESTAMP',
|
||||
values,
|
||||
)
|
||||
|
||||
if (meta) {
|
||||
await conn.query(
|
||||
'INSERT INTO shard_asset_meta (id, payload) VALUES (1, ?) ' +
|
||||
'ON DUPLICATE KEY UPDATE payload = VALUES(payload), imported_at = CURRENT_TIMESTAMP',
|
||||
[JSON.stringify(meta)],
|
||||
)
|
||||
}
|
||||
|
||||
await conn.commit()
|
||||
|
||||
return values.length
|
||||
} catch (err) {
|
||||
await conn.rollback().catch(() => {})
|
||||
throw err
|
||||
} finally {
|
||||
conn.release()
|
||||
}
|
||||
}
|
||||
|
||||
async function getMeta() {
|
||||
const rows = await query('SELECT payload, imported_at FROM shard_asset_meta WHERE id = 1')
|
||||
if (rows.length === 0) return null
|
||||
const payload = typeof rows[0].payload === 'string' ? JSON.parse(rows[0].payload) : rows[0].payload
|
||||
return { ...payload, importedAt: rows[0].imported_at }
|
||||
}
|
||||
|
||||
async function countAssets() {
|
||||
const rows = await query(
|
||||
'SELECT COUNT(*) AS n, SUM(file IS NOT NULL) AS stored FROM shard_assets',
|
||||
)
|
||||
return { total: Number(rows[0]?.n) || 0, stored: Number(rows[0]?.stored) || 0 }
|
||||
}
|
||||
|
||||
// ── the body resolution side (§8) ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Replace the whole slug → body map.
|
||||
*
|
||||
* This one IS a replace, and for the opposite reason to the assets above: it is
|
||||
* derived from the atlas's creature list, so a slug that has left the atlas has
|
||||
* no meaning any more and keeping its row would leave the map growing forever
|
||||
* across map changes. The pass that produces it is cheap to redo — a shard round
|
||||
* trip, no files — which is what makes replacing safe here and not there.
|
||||
*/
|
||||
async function replaceBodies(rows) {
|
||||
const conn = await core.pool.getConnection()
|
||||
|
||||
try {
|
||||
await conn.beginTransaction()
|
||||
await conn.query('DELETE FROM shard_creature_bodies')
|
||||
|
||||
const values = rows.map((r) => [r.slug, r.typeName, r.body ?? null, r.status || 'ok'])
|
||||
|
||||
await batched(
|
||||
conn,
|
||||
'INSERT INTO shard_creature_bodies (slug, type_name, body, status) VALUES (?,?,?,?)',
|
||||
values,
|
||||
)
|
||||
|
||||
await conn.commit()
|
||||
|
||||
return values.length
|
||||
} catch (err) {
|
||||
await conn.rollback().catch(() => {})
|
||||
throw err
|
||||
} finally {
|
||||
conn.release()
|
||||
}
|
||||
}
|
||||
|
||||
async function allBodies() {
|
||||
return query(
|
||||
'SELECT slug, type_name, body, status, resolved_at FROM shard_creature_bodies ORDER BY slug',
|
||||
)
|
||||
}
|
||||
|
||||
async function countBodies() {
|
||||
const rows = await query(
|
||||
"SELECT COUNT(*) AS n, SUM(status = 'ok') AS resolved FROM shard_creature_bodies",
|
||||
)
|
||||
return { total: Number(rows[0]?.n) || 0, resolved: Number(rows[0]?.resolved) || 0 }
|
||||
}
|
||||
|
||||
/**
|
||||
* The derivation `replaceAtlas` applies on the way past: slug → uploaded filename.
|
||||
*
|
||||
* One join rather than two reads, because it runs inside the atlas transaction —
|
||||
* the atlas rows are being inserted at that moment and every extra round trip is
|
||||
* time the site's creature list does not exist.
|
||||
*
|
||||
* Rows with no body, no asset or an asset whose bytes were never fetched are
|
||||
* simply absent from the result, which is what leaves `art` NULL. That is a
|
||||
* first-class state everywhere it is consumed and the expected one for two thirds
|
||||
* of the player bodies (§5.2).
|
||||
*
|
||||
* **The join is pinned to the catalogue key, not merely to the body id.** Today
|
||||
* one body has exactly one asset, so `a.body = b.body` alone would be correct —
|
||||
* and it would stop being correct the moment phase 6 adds `body/400/a2/f0`, at
|
||||
* which point one slug would match dozens of rows and whichever the engine
|
||||
* returned last would become the portrait. Naming the key here means that phase
|
||||
* adds rows without changing what a creature page shows.
|
||||
*/
|
||||
async function artBySlug() {
|
||||
const rows = await query(
|
||||
'SELECT b.slug, a.file FROM shard_creature_bodies b ' +
|
||||
"JOIN shard_assets a ON a.asset_key = CONCAT('body/', b.body, '/a0') " +
|
||||
"WHERE b.status = 'ok' AND b.body IS NOT NULL AND a.file IS NOT NULL",
|
||||
)
|
||||
|
||||
const map = {}
|
||||
|
||||
for (const row of rows) map[row.slug] = row.file
|
||||
|
||||
return map
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
allAssets,
|
||||
saveAssets,
|
||||
getMeta,
|
||||
countAssets,
|
||||
replaceBodies,
|
||||
allBodies,
|
||||
countBodies,
|
||||
artBySlug,
|
||||
}
|
||||
Reference in New Issue
Block a user