Files
Module-uo/server/test/shardAssets.model.test.js
wtclaude c6c51b190d
Some checks failed
PR Checks / frozen-manifest (pull_request) Successful in 51s
PR Checks / client-build (pull_request) Successful in 8m9s
PR Checks / server-tests (pull_request) Failing after 14m27s
feat(assets): a creature's picture is whichever action has one (Phase 6)
The shard's catalogue can now answer for 73 bodies it used to report absent —
they have no art at action 0 and real art at a later one, and their key says
which (`body/820/a23` is a horse). This side stores that action and stops
assuming `a0` anywhere.

The atlas join is the part that mattered. It read

  a.asset_key = CONCAT('body/', b.body, '/a0')

which would have silently dropped exactly the creatures this phase adds. It now
reads the row's own action, with COALESCE for rows written before the column
existed — a NULL inside CONCAT makes the whole comparison NULL, which would have
taken every portrait off the site on upgrade with the database perfectly correct
and nothing in any log. It still matches at most one row per slug: a deeper key
(`body/820/a23/f4`) does not equal the catalogue key.

Verified against a real MariaDB with the live shard's own 1,095-row manifest: the
ALTER applies to an installed-shape table and is idempotent, the horse joins to
its a23 picture, a pre-phase-6 NULL-action row keeps its portrait, and a stored
frame key does not become a second candidate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
2026-09-14 01:09:50 -05:00

366 lines
13 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('a body catalogued at a later action is imported under that key', async (t) => {
// §11.2, phase 6. Body 820 has no art at action 0 and a horse at action 23, so
// its key is `body/820/a23` — and the filename, the stored row and the atlas
// join all have to agree on that. A name built as `uo-body-820-a0-…` would be
// a file nothing ever asks for, with the creature page still showing text.
const dir = useTempUploads(t)
const seen = stubEverything({
manifest: manifestOf([
{ ...row(820, 'new'), key: 'body/820/a23', action: 23 },
]),
fetched: {
assets: new Map([['body/820/a23', { ...sprite('new'), action: 23 }]]),
missing: { absent: 0, unsupported: 0 },
},
})
t.after(restore)
const result = await model.importAssets({ force: true })
assert.equal(result.status, 'imported')
assert.deepEqual(seen.fetchedKeys, ['body/820/a23'])
const saved = seen.saved[0]
assert.equal(saved.key, 'body/820/a23')
assert.equal(saved.action, 23)
// Content-addressed, and the stem is the key: the action is IN the filename.
assert.equal(saved.file, 'uo-body-820-a23-new.png')
assert.ok(fs.existsSync(path.join(dir, model.ART_SUBDIR, saved.file)))
})
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$/)
})