Files
Module-uo/server/test/shardAssets.model.test.js
wtclaude a194ec68e0
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
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
2026-09-10 18:40:46 -05:00

335 lines
12 KiB
JavaScript

const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const core = require('../core')
const model = require('../model/shardAssets/shardAssets.model')
const db = require('../model/shardAssets/shardAssets.db')
const atlasDb = require('../model/shardAtlas/shardAtlas.db')
const atlasModel = require('../model/shardAtlas/shardAtlas.model')
const bridge = require('../utils/assetBridge')
const uoLinkConfig = require('../model/uoLinkConfig/uoLinkConfig.model')
// The import as a decision, with the shard and the database both stubbed
// (docs/link/v8.md §6, §12 — protocol 8, phase 3).
//
// Each of these is a way the import can be wrong that an operator would either
// never notice or notice only weeks later, on a page:
//
// - Re-fetching every sprite on every Update. Correct output, and it makes the
// manifest — the entire reason stage 2 carries hashes instead of pixels —
// dead weight.
// - Silently dropping an asset the shard stopped offering. An unmounted client
// volume and a deliberate downgrade are the same thing from here, and the
// wrong guess deletes artwork nobody asked to delete.
// - Overwriting artwork the operator drew themselves. §12 states outright that
// theirs wins, and a sprite rip replacing hand-drawn portraits is not
// recoverable by pressing anything.
// - Treating a body with no art as a failure. Two thirds of the playable ghost
// and gargoyle bodies are in that state on a stock client.
const saved = {}
let uploadDir
function stubEverything({ manifest, fetched, held = new Map(), meta = null, sources } = {}) {
saved.sourceFingerprint = bridge.sourceFingerprint
saved.readManifest = bridge.readManifest
saved.fetchAssets = bridge.fetchAssets
saved.resolveBodies = bridge.resolveBodies
saved.allAssets = db.allAssets
saved.saveAssets = db.saveAssets
saved.getMeta = db.getMeta
saved.countAssets = db.countAssets
saved.countBodies = db.countBodies
saved.replaceBodies = db.replaceBodies
saved.artBySlug = db.artBySlug
saved.allCreatureTypes = atlasDb.allCreatureTypes
saved.setCreatureArt = atlasDb.setCreatureArt
saved.loadArtMap = atlasModel.loadArtMap
saved.getSafe = uoLinkConfig.getSafe
const seen = { saved: null, fetchedKeys: null, art: null }
uoLinkConfig.getSafe = async () => ({ enabled: true, baseUrl: 'http://127.0.0.1:8080' })
bridge.sourceFingerprint = async () =>
sources ?? {
files: { 'anim.mul': { size: 1, mtime: 2, sha256: 'x' } },
extractorVersion: 1,
hashing: false,
complete: true,
imaging: { ok: true },
}
bridge.readManifest = async () => manifest
bridge.fetchAssets = async ({ keys }) => {
seen.fetchedKeys = keys
return fetched ?? { assets: new Map(), missing: { absent: 0, unsupported: 0 } }
}
bridge.resolveBodies = async () => []
db.allAssets = async () => held
db.getMeta = async () => meta
db.countAssets = async () => ({ total: held.size, stored: held.size })
db.countBodies = async () => ({ total: 0, resolved: 0 })
db.saveAssets = async (rows) => {
seen.saved = rows
return rows.length
}
db.replaceBodies = async () => 0
db.artBySlug = async () => ({})
atlasDb.allCreatureTypes = async () => []
atlasDb.setCreatureArt = async (map) => {
seen.art = map
return Object.keys(map).length
}
atlasModel.loadArtMap = () => ({})
return seen
}
function restore() {
for (const [name, fn] of Object.entries(saved)) {
if (!fn) continue
if (name in db) db[name] = fn
if (name in bridge) bridge[name] = fn
if (name in atlasDb) atlasDb[name] = fn
if (name === 'loadArtMap') atlasModel.loadArtMap = fn
if (name === 'getSafe') uoLinkConfig.getSafe = fn
}
}
/** A real uploads directory, because the import checks the disk as well as the row. */
function useTempUploads(t) {
uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'uo-assets-'))
const previous = core.uploads
Object.defineProperty(core, 'uploads', {
configurable: true,
get: () => ({ ...previous, UPLOAD_DIR: uploadDir }),
})
t.after(() => {
Object.defineProperty(core, 'uploads', { configurable: true, get: () => previous })
fs.rmSync(uploadDir, { recursive: true, force: true })
})
return uploadDir
}
const row = (body, sha) => ({
key: `body/${body}/a0`,
family: 'body',
sha256: sha,
bytes: 900,
width: 24,
height: 63,
body,
direction: 1,
})
const manifestOf = (rows) => ({
rows,
catalog: 'cat1',
extractorVersion: 1,
playerBodies: [400],
pages: 1,
scanned: 2047,
})
const sprite = (sha) => ({
sha256: sha,
bytes: 4,
width: 24,
height: 63,
body: 12,
direction: 1,
png: Buffer.from([0x89, 0x50, 0x4e, 0x47]),
})
// ── the gate ──────────────────────────────────────────────────────────────
test('unchanged client files import nothing at all', async (t) => {
const sources = {
files: { 'anim.mul': { size: 1, mtime: 2, sha256: 'x' } },
extractorVersion: 1,
hashing: false,
complete: true,
imaging: { ok: true },
}
stubEverything({ manifest: manifestOf([]), meta: { sources }, sources })
t.after(restore)
bridge.readManifest = async () => {
throw new Error('the gate should have stopped before reading a manifest')
}
const result = await model.importAssets()
assert.equal(result.status, 'unchanged')
})
test('a host that cannot render images is named rather than walked', async (t) => {
// §4.4: reported on the SOURCE gate, so an operator meets it while setting the
// shard up rather than from an empty bestiary weeks later.
stubEverything({
manifest: manifestOf([]),
sources: {
files: {},
extractorVersion: 1,
hashing: false,
complete: true,
imaging: { ok: false, code: 'NO_IMAGING', reason: 'needs libgdiplus' },
},
})
t.after(restore)
const result = await model.importAssets()
assert.equal(result.status, 'unavailable')
assert.equal(result.code, 'NO_IMAGING')
})
// ── the diff (§6) ─────────────────────────────────────────────────────────
test('only the keys whose hash moved are fetched', async (t) => {
const dir = useTempUploads(t)
fs.mkdirSync(path.join(dir, model.ART_SUBDIR), { recursive: true })
fs.writeFileSync(path.join(dir, model.ART_SUBDIR, 'kept.png'), 'x')
const held = new Map([
['body/12/a0', { key: 'body/12/a0', sha256: 'same', file: 'kept.png' }],
['body/34/a0', { key: 'body/34/a0', sha256: 'old', file: 'kept.png' }],
])
const seen = stubEverything({
held,
manifest: manifestOf([row(12, 'same'), row(34, 'new')]),
fetched: { assets: new Map([['body/34/a0', sprite('new')]]), missing: { absent: 0, unsupported: 0 } },
})
t.after(restore)
const result = await model.importAssets({ force: true })
assert.equal(result.status, 'imported')
// The whole point of a manifest that carries hashes and not pixels.
assert.deepEqual(seen.fetchedKeys, ['body/34/a0'])
assert.equal(result.written, 1)
})
test('an unchanged key whose file is missing from disk is fetched again', async (t) => {
// The row and the file can disagree — a wiped uploads volume, a restore from a
// database dump. Trusting the row alone leaves a broken image on a creature
// page with nothing anywhere reporting a problem, and re-fetching a sprite is
// far cheaper than that.
useTempUploads(t)
const held = new Map([['body/12/a0', { key: 'body/12/a0', sha256: 'same', file: 'gone.png' }]])
const seen = stubEverything({
held,
manifest: manifestOf([row(12, 'same')]),
fetched: { assets: new Map([['body/12/a0', sprite('same')]]), missing: { absent: 0, unsupported: 0 } },
})
t.after(restore)
await model.importAssets({ force: true })
assert.deepEqual(seen.fetchedKeys, ['body/12/a0'])
})
test('a key that vanished from the manifest needs review before anything changes', async (t) => {
useTempUploads(t)
const held = new Map([['body/99/a0', { key: 'body/99/a0', sha256: 'a', file: 'x.png' }]])
const seen = stubEverything({ held, manifest: manifestOf([row(12, 'a')]) })
t.after(restore)
const result = await model.importAssets({ force: true })
assert.equal(result.status, 'needsReview')
assert.equal(result.vanishedCount, 1)
// Nothing was applied. An unmounted client volume and a deliberate downgrade
// look identical from here.
assert.equal(seen.saved, null)
})
test('approve accepts the vanished key and removes its file', async (t) => {
const dir = useTempUploads(t)
fs.mkdirSync(path.join(dir, model.ART_SUBDIR), { recursive: true })
fs.writeFileSync(path.join(dir, model.ART_SUBDIR, 'gone.png'), 'x')
const held = new Map([['body/99/a0', { key: 'body/99/a0', sha256: 'a', file: 'gone.png' }]])
stubEverything({
held,
manifest: manifestOf([row(12, 'a')]),
fetched: { assets: new Map([['body/12/a0', sprite('a')]]), missing: { absent: 0, unsupported: 0 } },
})
t.after(restore)
const result = await model.importAssets({ force: true, approve: true })
assert.equal(result.status, 'imported')
assert.equal(result.removed, 1)
assert.equal(fs.existsSync(path.join(dir, model.ART_SUBDIR, 'gone.png')), false)
})
// ── absence is not failure (§5.2) ─────────────────────────────────────────
test('a key the shard could not render keeps the picture already held', async (t) => {
useTempUploads(t)
const held = new Map([['body/12/a0', { key: 'body/12/a0', sha256: 'old', file: 'existing.png' }]])
const seen = stubEverything({
held,
manifest: manifestOf([row(12, 'new')]),
// Listed, asked for, and not served. A shard that suddenly cannot render one
// sprite must not cost the picture we already have.
fetched: { assets: new Map(), missing: { absent: 1, unsupported: 0 } },
})
t.after(restore)
const result = await model.importAssets({ force: true })
assert.equal(result.status, 'imported')
assert.equal(result.absent, 1)
assert.equal(seen.saved[0].file, 'existing.png')
})
// ── the derivation (§12) ──────────────────────────────────────────────────
test("the operator's own artwork wins over an imported sprite", async (t) => {
useTempUploads(t)
const seen = stubEverything({ manifest: manifestOf([]) })
t.after(restore)
db.artBySlug = async () => ({ 'giant-spider': 'uo-body-28-aaaabbbb.png', wolf: 'uo-body-34-ccccdddd.png' })
// Someone who drew their own giant spider must not have it replaced by a
// sprite rip on the next Update. §12 states this outright.
atlasModel.loadArtMap = () => ({ 'giant-spider': 'my-own-spider.png' })
await model.importAssets({ force: true })
assert.equal(seen.art['giant-spider'], 'my-own-spider.png')
assert.equal(seen.art.wolf, 'uo-body-34-ccccdddd.png')
})
test('a sprite filename carries its hash so a changed picture is a changed URL', () => {
const before = model.fileNameFor('body/34/a0', 'aaaaaaaabbbb')
const after = model.fileNameFor('body/34/a0', 'ccccccccdddd')
// A stable name would be overwritten in place, and every browser and CDN that
// had cached it would keep serving last month's client's sprite — with the
// database row correct and nothing to notice.
assert.notEqual(before, after)
assert.match(before, /^uo-body-34-a0-[0-9a-f]{8}\.png$/)
})